diff --git a/.agents/lessons.md b/.agents/lessons.md index 70eae2c..04f4e14 100644 --- a/.agents/lessons.md +++ b/.agents/lessons.md @@ -19,7 +19,6 @@ alwaysApply: true - npm trusted publishing (OIDC) needs npm ≥ 11.5.1 + Node ≥ 22.14; oven-sh/setup-bun leaves npm 10.x in PATH, so a release job running `changeset publish`/`npm publish` must also run actions/setup-node (Node 24 → npm 11) or OIDC isn't detected → ENEEDAUTH. - Don't pin a GitHub action to a moving major tag's commit SHA (e.g. setup-node@) — the tag moves and orphans/GCs the commit → "unable to find version"; pin to an immutable release-tag commit or use the moving @vN tag. -- Subagent model choice: use `composer-2.5-fast` for fast implementation, `glm-5.2-high` for deeper exploration/design/debate; mix or run both for hard findings. - Format `apps/docs` non-content files at `printWidth: 80` — oxfmt uses **nearest-config-wins per file**, so the nested `apps/docs/.oxfmtrc.json` (ignore-only → default printWidth 100) shadows root `.oxfmtrc.json` (80) for every `apps/docs/*` file regardless of CLI scope; fix by adding `"printWidth": 80` to `apps/docs/.oxfmtrc.json` (or `-c .oxfmtrc.json --disable-nested-config`). `content/**` stays excluded by the nested ignore so writes won't collapse `:::note` callouts. - The layers repo has tracked `.oxlintrc.json` + root `.oxfmtrc.json` (since the initial commit) with real plugins/rules/ignorePatterns (`.oxlintrc.json` ignores `**/*.svelte` and `**/*.vue`); merge additions into them — never overwrite/replace. Verify tracked lint/format configs via `git ls-files '*.oxlintrc*' '*oxfmt*'` before touching (3 tracked today: `.oxlintrc.json`, `.oxfmtrc.json`, `apps/docs/.oxfmtrc.json`); the "no oxlint config" explore report was wrong. - A blume version bump needs `rm -rf .blume .blume-verify` (or a dev-server restart) before trusting build/preview output — `blume build --isolated` always regenerates the runtime, but incremental `writeIfChanged` skips, `ensureDepsLink` no-ops, `blume preview` serving an existing build, and `.blume/.astro` caches can persist stale output and silently mask new markdown/transformer features (e.g. 1.0.4 `blume:table-wrap`), reading as a false upstream regression. diff --git a/.changeset/devtools-brand-polish.md b/.changeset/devtools-brand-polish.md new file mode 100644 index 0000000..4f4b37c --- /dev/null +++ b/.changeset/devtools-brand-polish.md @@ -0,0 +1,6 @@ +--- +"@stainless-code/layers-devtools": patch +"@stainless-code/react-layers-devtools": patch +--- + +Polish Layers DevTools panel branding (docs-aligned tokens/logo, TanStack UI primitives) and follow the host theme; React doorbell defaults to OS `prefers-color-scheme` when the host omits `theme`. Plugin label is "Layers". diff --git a/apps/devtools-playground/README.md b/apps/devtools-playground/README.md new file mode 100644 index 0000000..d69b325 --- /dev/null +++ b/apps/devtools-playground/README.md @@ -0,0 +1,18 @@ +# Layers DevTools Playground + +Private local app (not published). Demo + visual harness for `@stainless-code/react-layers` and the Layers DevTools plugin. + +## Run + +Build workspace packages first, then: + +```bash +bun run playground:dev +``` + +→ http://localhost:5177 — TanStack DevTools → **Layers**. Theme via host Settings. + +```bash +bun run playground:build +bun run playground:preview +``` diff --git a/apps/devtools-playground/index.html b/apps/devtools-playground/index.html new file mode 100644 index 0000000..b02658e --- /dev/null +++ b/apps/devtools-playground/index.html @@ -0,0 +1,17 @@ + + + + + + Layers DevTools Playground + + + + +
+ + + diff --git a/apps/devtools-playground/package.json b/apps/devtools-playground/package.json new file mode 100644 index 0000000..517835b --- /dev/null +++ b/apps/devtools-playground/package.json @@ -0,0 +1,28 @@ +{ + "name": "@stainless-code/layers-devtools-playground", + "private": true, + "description": "Local Vite playground for Layers + TanStack DevTools demos and UI polish.", + "type": "module", + "scripts": { + "build": "tsc -b && vite build", + "dev": "vite", + "preview": "vite preview", + "typecheck": "bun run --filter '@stainless-code/layers' --filter '@stainless-code/react-layers' --filter '@stainless-code/react-layers-devtools' build && tsc -b --noEmit" + }, + "dependencies": { + "@stainless-code/react-layers": "workspace:*", + "@stainless-code/react-layers-devtools": "workspace:*", + "@tanstack/react-devtools": "0.10.8", + "react": "19.2.7", + "react-dom": "19.2.7" + }, + "devDependencies": { + "@tailwindcss/vite": "4.3.3", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "6.0.3", + "tailwindcss": "4.3.3", + "typescript": "6.0.3", + "vite": "8.1.5" + } +} diff --git a/apps/devtools-playground/src/App.tsx b/apps/devtools-playground/src/App.tsx new file mode 100644 index 0000000..d9a9695 --- /dev/null +++ b/apps/devtools-playground/src/App.tsx @@ -0,0 +1,24 @@ +import { StackOutlet, StackProvider } from "@stainless-code/react-layers"; +import { layersDevtoolsPlugin } from "@stainless-code/react-layers-devtools"; +import { TanStackDevtools } from "@tanstack/react-devtools"; + +import { layerClient, MODAL_STACK, TOAST_STACK, WIZARD_STACK } from "./client"; +import { DemoPage } from "./DemoPage"; + +export function App() { + return ( + + + + + +
+ +
+ + {import.meta.env.DEV ? ( + + ) : null} +
+ ); +} diff --git a/apps/devtools-playground/src/DemoPage.tsx b/apps/devtools-playground/src/DemoPage.tsx new file mode 100644 index 0000000..29b0647 --- /dev/null +++ b/apps/devtools-playground/src/DemoPage.tsx @@ -0,0 +1,466 @@ +import { + useLayer, + useLayerClient, + useQueuedStack, +} from "@stainless-code/react-layers"; +import { useEffect, useState } from "react"; + +import { WIZARD_STACK } from "./client"; +import { + animatedLayer, + blockedEditLayer, + confirmLayer, + deployLayer, + drawerLayer, + formatValidationError, + inviteLayer, + nestedParentLayer, + profileLayer, + progressLayer, + toastLayer, + wizardPayloads, + wizardSteps, +} from "./layers"; +import { Btn } from "./ui"; + +interface FeedItem { + id: number; + label: string; + detail: string; + tone: "neutral" | "ok" | "warn"; +} + +let feedSeq = 0; + +function pushFeed( + setFeed: React.Dispatch>, + label: string, + detail: string, + tone: FeedItem["tone"] = "neutral", +) { + setFeed((prev) => [ + { id: ++feedSeq, label, detail, tone }, + ...prev.slice(0, 7), + ]); +} + +function QueueBadge() { + const client = useLayerClient(); + const stack = client.getStack(WIZARD_STACK); + const [queued, setQueued] = useState(0); + + useEffect(() => { + const sync = () => setQueued(stack.getQueuedSnapshot().length); + sync(); + return stack.subscribe(sync); + }, [stack]); + + if (queued === 0) return null; + + return ( + + {queued} queued + + ); +} + +function Capability({ + title, + description, + children, +}: { + title: string; + description: string; + children: React.ReactNode; +}) { + return ( +
+

+ {title} +

+

+ {description} +

+ {children} +
+ ); +} + +export function DemoPage() { + const confirm = useLayer(confirmLayer); + const nested = useLayer(nestedParentLayer); + const blocked = useLayer(blockedEditLayer); + const profile = useLayer(profileLayer); + const animated = useLayer(animatedLayer); + const drawer = useLayer(drawerLayer); + const toast = useLayer(toastLayer); + const progress = useLayer(progressLayer); + const deploy = useLayer(deployLayer); + const invite = useLayer(inviteLayer); + const w1 = useLayer(wizardSteps[0]); + const w2 = useLayer(wizardSteps[1]); + const w3 = useLayer(wizardSteps[2]); + + const toastCount = useQueuedStack({ + stack: "toast", + select: (s) => s.length, + }); + + const [feed, setFeed] = useState([]); + const [validationError, setValidationError] = useState(null); + + return ( +
+
+
+

+ DevTools playground +

+

+ Layers orchestrates every overlay in your app. +

+

+ Headless stack manager — await confirms, queue wizards, nest + dialogs, block dismissals, load async data, animate enter/exit, and + inspect live state in TanStack DevTools. One cohesive surface, zero + UI library lock-in. +

+
+ +
+

+ Live signals +

+
+ + stacks:{" "} + + modal · toast · wizard + + + + toasts mounted: {toastCount} + + +
+

+ Open TanStack DevTools (bottom) → Layers panel. +

+
+
+ +
+
+ + { + const ok = await confirm.open({ + title: "Delete production deployment?", + detail: + "Traffic will fail over immediately. This cannot be undone.", + }); + pushFeed( + setFeed, + "Confirm", + ok ? "User confirmed delete" : "User cancelled", + ok ? "warn" : "neutral", + ); + }} + > + Delete deployment + + + + + { + void w1.open(wizardPayloads[0]); + void w2.open(wizardPayloads[1]); + void w3.open(wizardPayloads[2]); + pushFeed(setFeed, "Wizard", "Queued 3 onboarding steps"); + }} + > + Launch onboarding + + + + + { + void nested.open({ title: "Edit deployment" }); + pushFeed(setFeed, "Nested", "Parent opened"); + }} + > + Edit with nested confirm + + + + + { + const saved = await blocked.open({ title: "Edit runbook" }); + pushFeed( + setFeed, + "Blockers", + saved ? "Force-closed" : "Closed cleanly", + ); + }} + > + Dirty form dialog + + + + + { + void profile.open({ userId: "ada" }); + pushFeed(setFeed, "Async", "Profile layer opened"); + }} + > + Load profile + + + + + { + void animated.open({ title: "Animated handoff" }); + pushFeed(setFeed, "Transitions", "Animated dialog opened"); + }} + > + Animated dialog + + + + + { + const saved = await drawer.open({ title: "Stack settings" }); + pushFeed( + setFeed, + "Drawer", + saved ? "Settings saved" : "Drawer dismissed", + ); + }} + > + Open drawer + + + + +
+ { + void toast.open({ message: "Changes saved", tone: "ok" }); + pushFeed(setFeed, "Toast", "Success toast fired", "ok"); + }} + > + Success toast + + { + void toast.open({ + message: "Rate limit approaching", + tone: "warn", + }); + pushFeed(setFeed, "Toast", "Warning toast fired", "warn"); + }} + > + Warn toast + +
+
+ + + { + void progress.open({ percent: 0, label: "Uploading bundle…" }); + const layer = progress.stack.find(["playground", "progress"]); + if (!layer) return; + + let percent = 0; + const interval = setInterval(() => { + percent = Math.min(100, percent + 8); + progress.stack.update(layer, { percent }); + if (percent >= 100) { + clearInterval(interval); + void progress.stack + .dismiss(layer, undefined as void) + .then(() => { + pushFeed(setFeed, "Progress", "Upload complete", "ok"); + }); + } + }, 140); + }} + > + Simulate upload + + + + + { + const ok = await deploy.open({ target: "production" }); + pushFeed( + setFeed, + "Deploy", + ok ? "Deploy succeeded" : "Deploy cancelled", + ok ? "ok" : "neutral", + ); + }} + > + Deploy with mutation flow + + + + +
+ { + setValidationError(null); + try { + await invite.open({ email: "ops@layers.dev" }); + pushFeed(setFeed, "Validation", "Invite sent", "ok"); + } catch (err) { + setValidationError(formatValidationError(err)); + } + }} + > + Valid invite + + { + setValidationError(null); + try { + await invite.open({ email: "not-an-email" }); + } catch (err) { + const msg = formatValidationError(err); + setValidationError(msg); + pushFeed(setFeed, "Validation", msg, "warn"); + } + }} + > + Reject bad payload + + {validationError ? ( +

+ {validationError} +

+ ) : null} +
+
+
+ + +
+
+ ); +} diff --git a/apps/devtools-playground/src/client.ts b/apps/devtools-playground/src/client.ts new file mode 100644 index 0000000..7df0497 --- /dev/null +++ b/apps/devtools-playground/src/client.ts @@ -0,0 +1,16 @@ +import { LayerClient } from "@stainless-code/react-layers"; + +export const MODAL_STACK = "modal"; +export const TOAST_STACK = "toast"; +export const WIZARD_STACK = "wizard"; + +export const layerClient = new LayerClient({ + defaultStackOptions: { + [WIZARD_STACK]: { scope: { strategy: "serial" } }, + }, +}); + +// Seed stacks so the DevTools panel lists them before the first open. +layerClient.ensureStack(MODAL_STACK); +layerClient.ensureStack(TOAST_STACK); +layerClient.ensureStack(WIZARD_STACK); diff --git a/apps/devtools-playground/src/index.css b/apps/devtools-playground/src/index.css new file mode 100644 index 0000000..9cf30d8 --- /dev/null +++ b/apps/devtools-playground/src/index.css @@ -0,0 +1,99 @@ +@import "tailwindcss"; + +/* Palette from `apps/docs/theme.css`. */ +:root { + color-scheme: light; + --ink: #faf8f5; + --ink-raised: #f0ede8; + --ink-panel: #ffffff; + --ink-border: #e2ddd6; + --ink-muted: #6b6863; + --ink-text: #1c1b19; + --teal: #0d9488; + --teal-dim: #0f766e; + --teal-glow: rgb(13 148 136 / 0.16); + --danger: #dc2626; + --success: #059669; + --radius: 12px; + --radius-sm: 8px; + --shadow: 0 24px 80px rgb(28 27 25 / 0.12); + --font-display: "Inter Tight", Inter, ui-sans-serif, system-ui, sans-serif; + --font-body: + Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, + Helvetica, Arial, sans-serif; + --font-mono: + "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace; +} + +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --ink: #18181b; + --ink-raised: #27272a; + --ink-panel: #1f1f23; + --ink-border: #3f3f46; + --ink-muted: #a1a1aa; + --ink-text: #f5f0e8; + --teal: #2dd4bf; + --teal-dim: #14b8a6; + --teal-glow: rgb(45 212 191 / 0.18); + --danger: #f87171; + --success: #34d399; + --shadow: 0 24px 80px rgb(0 0 0 / 0.45); + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body, +#root { + min-height: 100%; +} + +body { + margin: 0; + font-family: var(--font-body); + background: + radial-gradient(1200px 600px at 10% -10%, var(--teal-glow), transparent), + radial-gradient(900px 500px at 90% 0%, var(--teal-glow), transparent), + var(--ink); + color: var(--ink-text); + -webkit-font-smoothing: antialiased; +} + +button, +input { + font: inherit; +} + +@keyframes fade-up { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse-ring { + 0%, + 100% { + box-shadow: 0 0 0 0 var(--teal-glow); + } + 50% { + box-shadow: 0 0 0 10px transparent; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/apps/devtools-playground/src/layers.tsx b/apps/devtools-playground/src/layers.tsx new file mode 100644 index 0000000..7cb9e43 --- /dev/null +++ b/apps/devtools-playground/src/layers.tsx @@ -0,0 +1,683 @@ +import { + isPayloadValidationError, + layerOptions, + useLayerGroup, + useMutationFlow, +} from "@stainless-code/react-layers"; +import type { + LayerComponentProps, + LayerKey, + LayerOptions, + LayerTransition, +} from "@stainless-code/react-layers"; +import type { ReactNode } from "react"; +import { useEffect, useState } from "react"; + +import { MODAL_STACK, TOAST_STACK, WIZARD_STACK } from "./client"; +import { overlayPortal } from "./portal"; +import { + ANIM_MS, + Backdrop, + Btn, + DialogActions, + DialogTitle, + DrawerShell, + Field, + ModalShell, + Spinner, + TextInput, + ToastShell, +} from "./ui"; + +type LayerProps = LayerComponentProps; + +interface TitlePayload { + title: string; +} + +interface ConfirmPayload { + title: string; + detail?: string; +} + +interface DiscardPayload { + title: string; + message: string; +} + +interface WizardPayload { + step: number; + title: string; + body: string; +} + +interface ProfilePayload { + userId: string; +} + +interface Profile { + name: string; + role: string; + initials: string; +} + +interface ToastPayload { + message: string; + tone?: "ok" | "warn"; +} + +interface ProgressPayload { + percent: number; + label: string; +} + +interface DeployPayload { + target: string; +} + +interface InvitePayload { + email: string; +} + +function playgroundKey(...parts: Array): LayerKey { + return ["playground", ...parts]; +} + +function playgroundLayer< + P, + R = void, + E = never, + D = unknown, + RootProps = unknown, + const Key extends LayerKey = LayerKey, +>(options: LayerOptions & { key: Key }) { + return layerOptions({ + stack: MODAL_STACK, + enteringDelay: ANIM_MS, + exitingDelay: ANIM_MS, + ...options, + }); +} + +function delay(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(new DOMException("Aborted", "AbortError")); + }, + { once: true }, + ); + }); +} + +function useSettleOnTransition( + call: { settle: () => void }, + transition: LayerTransition, +) { + useEffect(() => { + if (transition !== "entering" && transition !== "exiting") { + return; + } + const timer = setTimeout(() => call.settle(), ANIM_MS); + return () => clearTimeout(timer); + }, [transition, call]); +} + +function ModalOverlay(props: { + transition: LayerTransition; + role?: "dialog" | "alertdialog" | "status"; + onBackdropClick?: () => void; + children: ReactNode; +}) { + return overlayPortal( + <> + + + {props.children} + + , + ); +} + +function Muted({ + children, + className = "", +}: { + children: ReactNode; + className?: string; +}) { + return ( +

+ {children} +

+ ); +} + +function BooleanActions(props: { + onCancel: () => void; + onConfirm: () => void; + cancelLabel?: string; + confirmLabel?: string; + confirmVariant?: "danger" | "primary"; +}) { + return ( + + {props.cancelLabel ?? "Cancel"} + + {props.confirmLabel ?? "Confirm"} + + + ); +} + +function ConfirmDialog({ + call, + payload, + transition, +}: LayerProps) { + return ( + call.end(false)} + > + {payload.title} + + {payload.detail ?? + "This action cannot be undone. Proceed only if you are certain."} + + call.end(false)} + onConfirm={() => call.end(true)} + /> + + ); +} + +export const confirmLayer = playgroundLayer({ + key: playgroundKey("confirm"), + component: ConfirmDialog, +}); + +function WizardStep({ call, payload, transition }: LayerProps) { + return ( + + + {payload.title} + + {payload.body} +
+
+
+ + call.end()}> + {payload.step < 3 ? "Continue" : "Finish"} + + + + ); +} + +export const wizardPayloads = [ + { + step: 1, + title: "Welcome to Layers", + body: "Serial scope queues overlays — only one wizard step mounts at a time.", + }, + { + step: 2, + title: "Compose stacks", + body: "Modal, toast, and wizard stacks run in parallel across the app surface.", + }, + { + step: 3, + title: "Ship with confidence", + body: "Await results, block dismissals, and inspect everything in DevTools.", + }, +] as const satisfies readonly WizardPayload[]; + +export const wizardSteps = wizardPayloads.map((step) => + playgroundLayer({ + stack: WIZARD_STACK, + key: playgroundKey("wizard", step.step), + component: WizardStep, + }), +); + +function NestedChildConfirm({ + call, + payload, + transition, +}: LayerProps) { + return ( + + {payload.title} + + Child overlay on a scoped stack — parent stays mounted underneath. + + call.end(false)} + onConfirm={() => call.end(true)} + /> + + ); +} + +const nestedChild = playgroundLayer({ + key: playgroundKey("nested", "child"), + component: NestedChildConfirm, +}); + +function NestedParentDialog({ + call, + payload, + transition, +}: LayerProps) { + const group = useLayerGroup(call); + const [childResult, setChildResult] = useState(null); + + return ( + + {payload.title} + + Open a nested confirm without tearing down this dialog. + + {childResult !== null ? ( +

+ Child returned: {String(childResult)} +

+ ) : null} + + { + setChildResult(null); + const ok = await group.open({ + ...nestedChild, + payload: { title: "Really delete deployment?" }, + }); + setChildResult(ok); + }} + > + Delete deployment + + call.dismiss()}>Close + + +
+ ); +} + +export const nestedParentLayer = playgroundLayer({ + key: playgroundKey("nested", "parent"), + component: NestedParentDialog, +}); + +function DiscardConfirm({ + call, + payload, + transition, +}: LayerProps) { + return ( + + {payload.title} + {payload.message} + call.end(false)} + onConfirm={() => call.end(true)} + /> + + ); +} + +const discardConfirm = playgroundLayer({ + key: playgroundKey("blockers", "discard"), + component: DiscardConfirm, +}); + +function BlockedEditDialog({ + call, + payload, + transition, + dismissing, +}: LayerProps) { + const group = useLayerGroup(call, { name: "discard" }); + const [text, setText] = useState(""); + const dirty = text.trim().length > 0; + + useEffect(() => call.addBlocker(() => !dirty), [call, dirty]); + + const attemptClose = async () => { + if (!dirty) { + call.end(false); + return; + } + const discard = await group.open({ + ...discardConfirm, + payload: { + title: "Discard changes?", + message: "Unsaved edits will be lost.", + }, + }); + if (discard) call.end(false, { force: true }); + }; + + return ( + + {payload.title} + + setText(e.target.value)} + placeholder="Type to mark the form dirty…" + autoFocus + /> + + {dismissing ? ( +

+ Blocker consulted — dismiss vetoed until you confirm or force close. +

+ ) : null} + + void attemptClose()}>Close + call.end(true, { force: true })}> + Force close + + + +
+ ); +} + +export const blockedEditLayer = playgroundLayer({ + key: playgroundKey("blockers", "edit"), + component: BlockedEditDialog, +}); + +function ProfileDialog({ + call, + payload, + data, + phase, + transition, +}: LayerProps) { + return ( + + + Profile · {payload.userId} + + {phase === "pending" ? ( +
+ + Loading profile before mount settles… +
+ ) : ( +
+
+ {data?.initials} +
+
+

{data?.name}

+

{data?.role}

+
+
+ )} + + call.dismiss()}>Close + +
+ ); +} + +export const profileLayer = playgroundLayer< + ProfilePayload, + void, + never, + Profile +>({ + key: playgroundKey("async-profile"), + component: ProfileDialog, + loadFn: async ({ signal }) => { + await delay(900, signal); + return { + name: "Ada Lovelace", + role: "Founding Engineer", + initials: "AL", + }; + }, +}); + +function AnimatedDialog({ + call, + payload, + transition, +}: LayerProps) { + useSettleOnTransition(call, transition); + + return ( + + {payload.title} + + enteringDelay / exitingDelay hold the layer until{" "} + call.settle() runs. + +

+ transition:{" "} + {transition} +

+ + call.dismiss()}>Close + +
+ ); +} + +export const animatedLayer = playgroundLayer({ + key: playgroundKey("animated"), + component: AnimatedDialog, +}); + +function SettingsDrawer({ + call, + payload, + transition, +}: LayerProps) { + return overlayPortal( + +
+ {payload.title} + call.end(false)}> + ✕ + +
+
+

+ Side-panel layer on the modal stack — backdrop + sheet chrome you own. +

+ + + + + + +
+
+ call.end(false)}>Cancel + call.end(true)}> + Save + +
+
, + ); +} + +export const drawerLayer = playgroundLayer({ + key: playgroundKey("drawer"), + component: SettingsDrawer, +}); + +function ToastLayer({ call, payload, transition }: LayerProps) { + useEffect(() => { + const timer = setTimeout(() => call.dismiss(), 3200); + return () => clearTimeout(timer); + }, [call]); + + const tone = payload.tone ?? "ok"; + + return ( + + +
+

+ {payload.message} +

+

Parallel toast stack

+
+ call.dismiss()} + > + ✕ + +
+ ); +} + +export const toastLayer = playgroundLayer({ + stack: TOAST_STACK, + key: playgroundKey("toast"), + component: ToastLayer, +}); + +function ProgressOverlay({ payload, transition }: LayerProps) { + return ( + + {payload.label} +
+
+
+
+ {payload.percent}% complete +
+ + ); +} + +export const progressLayer = playgroundLayer({ + key: playgroundKey("progress"), + component: ProgressOverlay, +}); + +function DeployDialog({ + call, + payload, + transition, +}: LayerProps) { + const flow = useMutationFlow(call); + + return ( + + + Deploy to {payload.target} + + + Mutation flow sets running state while the async action completes. + + + call.end(false)} disabled={flow.pending}> + Cancel + + + void flow + .run(async () => { + await delay(1200); + }) + .orEnd(true) + } + > + {flow.pending ? ( + <> + Deploying… + + ) : ( + "Deploy" + )} + + + + ); +} + +export const deployLayer = playgroundLayer({ + key: playgroundKey("deploy"), + component: DeployDialog, +}); + +function InviteDialog({ + call, + payload, + transition, +}: LayerProps) { + return ( + + Send invite + Opening with a valid email parsed at the boundary: +

+ {payload.email} +

+ + call.dismiss()}>Close + call.end()}> + Send + + +
+ ); +} + +export const inviteLayer = playgroundLayer({ + key: playgroundKey("invite"), + component: InviteDialog, + validate: (input: unknown) => { + const email = + typeof input === "object" && input !== null && "email" in input + ? (input as { email: unknown }).email + : undefined; + if ( + typeof email !== "string" || + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) + ) { + throw new Error("Valid email required"); + } + return { email: email.trim().toLowerCase() }; + }, +}); + +export function formatValidationError(err: unknown): string { + if (isPayloadValidationError(err)) { + return err.issues.map((i) => i.message).join("; ") || "Invalid payload"; + } + if (err instanceof Error) return err.message; + return "Unknown validation error"; +} diff --git a/apps/devtools-playground/src/main.tsx b/apps/devtools-playground/src/main.tsx new file mode 100644 index 0000000..2595601 --- /dev/null +++ b/apps/devtools-playground/src/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import { App } from "./App"; + +import "./index.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/apps/devtools-playground/src/portal.tsx b/apps/devtools-playground/src/portal.tsx new file mode 100644 index 0000000..11a8b6f --- /dev/null +++ b/apps/devtools-playground/src/portal.tsx @@ -0,0 +1,7 @@ +import type { ReactNode } from "react"; +import { createPortal } from "react-dom"; + +export function overlayPortal(node: ReactNode): ReactNode { + if (typeof document === "undefined") return node; + return createPortal(node, document.body); +} diff --git a/apps/devtools-playground/src/ui.tsx b/apps/devtools-playground/src/ui.tsx new file mode 100644 index 0000000..7d79998 --- /dev/null +++ b/apps/devtools-playground/src/ui.tsx @@ -0,0 +1,235 @@ +import type { LayerTransition } from "@stainless-code/react-layers"; +import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from "react"; +import { useEffect, useState } from "react"; + +/** Keep in sync with `playgroundLayer` delays in `layers.tsx`. */ +export const ANIM_MS = 220; + +const baseBtn = + "inline-flex items-center justify-center gap-2 rounded-[var(--radius-sm)] px-4 py-2.5 text-sm font-medium transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-45"; + +const variants = { + primary: `${baseBtn} bg-[var(--teal)] text-[var(--ink)] hover:brightness-110`, + ghost: `${baseBtn} border border-[var(--ink-border)] bg-transparent text-[var(--ink-text)] hover:border-[var(--teal-dim)] hover:bg-[var(--teal-glow)]`, + danger: `${baseBtn} bg-[var(--danger)] text-white hover:brightness-110`, + subtle: `${baseBtn} bg-[var(--ink-raised)] text-[var(--ink-muted)] hover:text-[var(--ink-text)]`, +} as const; + +export function Btn({ + variant = "ghost", + className = "", + ...props +}: ButtonHTMLAttributes & { + variant?: keyof typeof variants; +}) { + return ( + - + Run +
); } diff --git a/packages/devtools/src/components/StackTable.tsx b/packages/devtools/src/components/StackTable.tsx index 97a8229..6d9b672 100644 --- a/packages/devtools/src/components/StackTable.tsx +++ b/packages/devtools/src/components/StackTable.tsx @@ -1,7 +1,70 @@ -import type { LayerNotifyView } from "@stainless-code/layers"; -import { For, Show } from "solid-js"; +import type { + LayerActionStatus, + LayerNotifyView, + LayerPhase, + LayerTransition, +} from "@stainless-code/layers"; +import { + JsonTree, + Section, + SectionDescription, + SectionTitle, + Tag, + useTheme, +} from "@tanstack/devtools-ui"; +import type { ComponentProps } from "solid-js"; +import { createMemo, For, Show } from "solid-js"; -function formatPayload(layer: LayerNotifyView): string { +import { brandTokens } from "../brand"; + +type TagColor = ComponentProps["color"]; + +function phaseTagColor(phase: LayerPhase): TagColor { + switch (phase) { + case "active": { + return "green"; + } + case "queued": { + return "yellow"; + } + case "pending": { + return "blue"; + } + case "dismissed": { + return "gray"; + } + case "error": { + return "red"; + } + } +} + +function transitionTagColor(transition: LayerTransition): TagColor { + switch (transition) { + case "entering": { + return "cyan"; + } + case "settled": { + return "gray"; + } + case "exiting": { + return "purple"; + } + } +} + +function actionStatusTagColor(status: LayerActionStatus): TagColor { + switch (status) { + case "running": { + return "pink"; + } + case "idle": { + return "gray"; + } + } +} + +function formatPayloadFallback(layer: LayerNotifyView): string { if (layer.payloadTruncated) { return "[truncated]"; } @@ -15,19 +78,31 @@ function formatPayload(layer: LayerNotifyView): string { } } +function canShowJsonTree(layer: LayerNotifyView): boolean { + if (layer.payloadTruncated || layer.payload === undefined) { + return false; + } + return typeof layer.payload === "object" && layer.payload !== null; +} + function LayerTable(props: { title: string; rows: LayerNotifyView[] }) { + const { theme } = useTheme(); + const tableStyles = createMemo(() => { + const t = brandTokens(theme()); + return { + border: t.border, + headerBg: t.headerBg, + }; + }); + return ( -
-

- {props.title} ({props.rows.length}) -

+
+ + {props.title} + 0} - fallback={ -

- Empty -

- } + fallback={Empty} >
- - + - - - - + - @@ -60,38 +173,86 @@ function LayerTable(props: { title: string; rows: LayerNotifyView[] }) { {(layer) => ( - - + - - - )} @@ -100,7 +261,7 @@ function LayerTable(props: { title: string; rows: LayerNotifyView[] }) {
+
phase + transition + actionStatus id + + id + key + payload
- {layer.phase} +
+ - {layer.transition} + + - {layer.actionStatus} + + {layer.id} + {layer.key} - {formatPayload(layer)} + + {formatPayloadFallback(layer)} + + } + > + +
-
+
); } @@ -113,16 +274,10 @@ export function StackTable(props: { return (
-

- Last action: {props.action} + + Last action: · seq {props.seq} -

+
diff --git a/packages/devtools/src/components/index.tsx b/packages/devtools/src/components/index.tsx index 1d4dfd1..b261035 100644 --- a/packages/devtools/src/components/index.tsx +++ b/packages/devtools/src/components/index.tsx @@ -12,7 +12,7 @@ export default function LayersDevtools(props: DevtoolsProps) { return ( - + ); diff --git a/packages/react-devtools/package.json b/packages/react-devtools/package.json index 48f243a..6866280 100644 --- a/packages/react-devtools/package.json +++ b/packages/react-devtools/package.json @@ -70,7 +70,7 @@ "@testing-library/react": "16.3.2", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", - "@vitejs/plugin-react": "6.0.1", + "@vitejs/plugin-react": "6.0.3", "jsdom": "29.1.1", "react": "19.2.7", "react-dom": "19.2.7", diff --git a/packages/react-devtools/src/ReactLayersDevtools.tsx b/packages/react-devtools/src/ReactLayersDevtools.tsx index 9728272..8ba1d44 100644 --- a/packages/react-devtools/src/ReactLayersDevtools.tsx +++ b/packages/react-devtools/src/ReactLayersDevtools.tsx @@ -9,6 +9,8 @@ import type { DevtoolsPanelProps } from "@tanstack/devtools-utils/react"; import type { JSX } from "react"; import { useEffect } from "react"; +import { resolveDefaultTheme } from "./resolveDefaultTheme"; + export interface LayersDevtoolsReactInit extends Partial { /** When set, skips {@link useLayerClient} for attach. */ client?: LayerClient; @@ -24,7 +26,7 @@ function resolvePanelProps( props?: LayersDevtoolsReactInit, ): DevtoolsPanelProps { return { - theme: props?.theme ?? "dark", + theme: props?.theme ?? resolveDefaultTheme(), devtoolsOpen: props?.devtoolsOpen ?? false, }; } diff --git a/packages/react-devtools/src/plugin.test.ts b/packages/react-devtools/src/plugin.test.ts index 5259001..49271d6 100644 --- a/packages/react-devtools/src/plugin.test.ts +++ b/packages/react-devtools/src/plugin.test.ts @@ -5,7 +5,7 @@ import { layersDevtoolsNoOpPlugin, layersDevtoolsPlugin } from "./plugin"; describe("layersDevtoolsPlugin", () => { it("returns a TanStack plugin descriptor", () => { const plugin = layersDevtoolsPlugin(); - expect(plugin.name).toBe("TanStack Layers"); + expect(plugin.name).toBe("Layers"); expect(typeof plugin.render).toBe("function"); }); @@ -13,13 +13,13 @@ describe("layersDevtoolsPlugin", () => { const plugin = layersDevtoolsPlugin({ client: { getStackIds: () => [] } as never, }); - expect(plugin.name).toBe("TanStack Layers"); + expect(plugin.name).toBe("Layers"); expect(typeof plugin.render).toBe("function"); }); it("exposes a no-op plugin factory", () => { const plugin = layersDevtoolsNoOpPlugin(); - expect(plugin.name).toBe("TanStack Layers"); + expect(plugin.name).toBe("Layers"); expect(typeof plugin.render).toBe("function"); }); }); diff --git a/packages/react-devtools/src/plugin.tsx b/packages/react-devtools/src/plugin.tsx index 8fae04c..b0efaa4 100644 --- a/packages/react-devtools/src/plugin.tsx +++ b/packages/react-devtools/src/plugin.tsx @@ -11,7 +11,7 @@ export interface LayersDevtoolsPluginOptions { type LayersDevtoolsPluginFactory = ReturnType[0]; const defaultLayersPlugins = createReactPlugin({ - name: "TanStack Layers", + name: "Layers", Component: LayersDevtoolsPanel, }); @@ -23,7 +23,7 @@ export function layersDevtoolsPlugin( return defaultLayersPlugins[0](); } const withClient = createReactPlugin({ - name: "TanStack Layers", + name: "Layers", Component: (panelProps) => ( ), diff --git a/packages/react-devtools/src/resolveDefaultTheme.test.ts b/packages/react-devtools/src/resolveDefaultTheme.test.ts new file mode 100644 index 0000000..590da42 --- /dev/null +++ b/packages/react-devtools/src/resolveDefaultTheme.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it } from "bun:test"; + +import { resolveDefaultTheme } from "./resolveDefaultTheme"; + +function stubWindowMatchMedia(matches: boolean) { + const previous = (globalThis as { window?: Window }).window; + const matchMedia = (query: string) => + ({ + matches, + media: query, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { + return false; + }, + }) as MediaQueryList; + + Object.defineProperty(globalThis, "window", { + configurable: true, + writable: true, + value: { matchMedia }, + }); + + return () => { + if (previous === undefined) { + Reflect.deleteProperty(globalThis, "window"); + } else { + Object.defineProperty(globalThis, "window", { + configurable: true, + writable: true, + value: previous, + }); + } + }; +} + +describe("resolveDefaultTheme", () => { + let restore: (() => void) | undefined; + + afterEach(() => { + restore?.(); + restore = undefined; + }); + + it("returns dark when window is unavailable", () => { + expect(resolveDefaultTheme()).toBe("dark"); + }); + + it("returns dark when prefers-color-scheme: dark matches", () => { + restore = stubWindowMatchMedia(true); + expect(resolveDefaultTheme()).toBe("dark"); + }); + + it("returns light when prefers-color-scheme: dark does not match", () => { + restore = stubWindowMatchMedia(false); + expect(resolveDefaultTheme()).toBe("light"); + }); +}); diff --git a/packages/react-devtools/src/resolveDefaultTheme.ts b/packages/react-devtools/src/resolveDefaultTheme.ts new file mode 100644 index 0000000..df510e1 --- /dev/null +++ b/packages/react-devtools/src/resolveDefaultTheme.ts @@ -0,0 +1,12 @@ +/** OS preference when the host does not pass `theme`. */ +export function resolveDefaultTheme(): "light" | "dark" { + if ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" + ) { + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + } + return "dark"; +}