diff --git a/.claude/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md index ac6d53b48c..d5c07c600d 100644 --- a/.claude/CLAUDE-KNOWLEDGE.md +++ b/.claude/CLAUDE-KNOWLEDGE.md @@ -356,6 +356,8 @@ Then restart the dev server. This rebuilds all packages and generates the necess ## Q: How is backwards compatibility for the offer→product rename handled in the payments purchase APIs? A: API v1 requests are routed through the `v2beta1` migration. The migration wraps the latest handlers, accepts legacy `offer_id`/`offer_inline` request fields, translates product-related errors back to the old offer error codes/messages, and augments responses (like `validate-code`) with `offer`/`conflicting_group_offers` aliases alongside the new `product` fields. Newer API versions keep the product-only contract. +## Q: How does the Stack Auth template dev tool decide whether to iframe the dashboard? +A: The dev tool always iframes the Dashboard tab and provides an "Open in New Tab" escape hatch for auth or framing issues. ## Q: How does `/api/v1/ai/query/generate` reject invalid AI tool names? A: Invalid `tools` entries are rejected by `requestBodySchema` in `apps/backend/src/lib/ai/schema.ts` via `yupString().oneOf(TOOL_NAMES)`, so the endpoint returns a structured `SCHEMA_ERROR` object mentioning `body.tools[n]` rather than a custom `"Invalid tool names"` string from handler logic. @@ -364,3 +366,51 @@ A: The `/api/v1/internal/metrics` response now intentionally includes `analytics ## Q: Why can environment config override writes fail with a product/product-line customer type warning after creating a preview project? A: The environment override endpoint validates the new environment override against the rendered branch config. Preview dummy payments data must therefore be internally coherent: products assigned to a product line need the same `customerType` as that product line, otherwise unrelated environment patches can fail with warnings like `Product "growth" has customer type "user" but its product line "workspace" has customer type "team"`. + +## Q: How do you keep the Stack Auth dev tool from reopening automatically after navigation or reload? +A: Treat `isOpen` as mount-local state in `packages/template/src/dev-tool/dev-tool-core.ts`: load persisted preferences with `isOpen: false`, and save state back to localStorage with `isOpen: false` so tab/size preferences persist without reopening the panel on the next mount. + +## Q: How should the Stack Auth dev tool indicator avoid being hidden by other dev indicators? +A: Do not dynamically reflow around framework indicators; that makes pointer interaction brittle. Keep the trigger anchored to its saved corner and give `.sdt-trigger` a max practical z-index (`2147483647`) so the Stack indicator renders above Next/Turbo overlays. + +## Q: How should Stack Auth dev tool trigger movement feel? +A: Dragging should remain instant/direct, but programmatic moves like snap-to-corner after drag, resize reposition, and post-measurement correction should use a short snappy left/top transition. In `dev-tool-core.ts`, toggle a dedicated animation class only for those programmatic updates and remove it shortly after. + +## Q: How do you prevent duplicate Stack Auth dev tool indicators from multiple package/module instances? +A: `createDevTool` in `packages/template/src/dev-tool/dev-tool-core.ts` should register a browser-wide singleton instance on `window` with an idempotent cleanup function, call any previous global cleanup before mounting, and remove leftover `#__stack-dev-tool-root` nodes as a fallback for older instances that did not register cleanup. + +## Q: How should the Stack Auth dev tool handle Dashboard tab sizing? +A: Keep the user's default panel width/height in state for normal tabs, but apply a transient fullscreen class while the active tab is `dashboard`. The fullscreen class should override fixed dimensions and hide resize handles, then remove itself and restore the saved default dimensions when any other tab is selected. + +## Q: How should the Stack Auth dev tool animate Dashboard fullscreen transitions? +A: Add a short-lived geometry animation class only around tab-driven switches into or out of Dashboard fullscreen. Animate `width`, `height`, `right`, `bottom`, and radius for the mode change, then remove the class so manual dragging/resizing remains direct and does not lag. + +## Q: Should the Stack Auth dev tool Dashboard tab be gated on local emulator mode? +A: No. The Dashboard tab should always render the dashboard URL in an iframe inside the dev tool, and should also show an "Open in New Tab" link so users can escape iframe/auth/framing issues without losing the embedded view. + +## Q: How should the Dashboard iframe use space in the Stack Auth dev tool? +A: In Dashboard fullscreen mode, the panel should cover the full viewport with no inset or rounded frame, and the iframe should fill all available content space. Put auxiliary actions like "Open in New Tab" in a top-edge overlay below the tab bar so they do not reserve layout height from the iframe. + +## Q: How do you maximize iframe tabs inside the Stack Auth dev tool panel? +A: Mark Docs/Dashboard panes with an iframe-specific class, remove the normal 16px tab-pane padding, hide pane overflow, and give the iframe container explicit `width: 100%` and `height: 100%`. Keep toolbar actions as absolute overlays so they do not reduce iframe layout space. + +## Q: How should docs access work in the Stack Auth dev tool? +A: Docs should not be an iframe-backed tab. Keep docs as a top-bar external link to `https://docs.stack-auth.com` with an up-right arrow icon, and migrate any persisted `activeTab: "docs"` value back to `overview`. + +## Q: How should the Stack Auth dev tool Console tab handle large log volumes? +A: Keep the full log history in the shared log store, but render only the newest 100 entries initially. When the log scroll area nears the bottom, increase the visible count by another 100 and rerender. The Console tab should be a single logs view with Copy, Export, and Clear buttons in the header rather than nested Logs/Config subtabs. + +## Q: How should the Stack Auth dev tool Customize page detail show page metadata? +A: Avoid repeated page-type badges like `Handler` in page tiles or detail headers. Keep actionable badges such as `Outdated`, show the compact route path next to the page title, use `Open` for the page action, and phrase the customization prompt as "Want to customize this page? Paste this prompt into your coding agent." with a `Copy prompt` button below it. + +## Q: How should the Stack Auth dev tool Customize page Open action behave? +A: The page detail `Open` action should be a taller button matching the redirect code chip height, include a small up-right arrow icon, and always open the selected page in a new tab. + +## Q: How should the Stack Auth dev tool Support tab be structured? +A: Do not keep top-level Feedback/Feature Request subtabs unless the backend supports them. The Support tab should mount the feedback form directly, show Discord/Email/GitHub links at the top, keep only Feedback and Bug Report choices in the form, and use a Submit button with a right-arrow icon after the text. + +## Q: Which tab should the Stack Auth dev tool show when opened? +A: Opening the dev tool should always reset `activeTab` to `overview` before creating the panel. Other preferences like size can persist, but each fresh open should start on Overview instead of the last-used tab. + +## Q: How should Stack Auth dev tool PR review comments around Overview and trigger behavior be handled? +A: Keep trigger corner resolution on-screen even in tiny viewports by snapping to bounded edge positions, remove unused trigger-position helpers, treat browser fetch `TypeError`s such as Safari's `Load failed` as best-effort Overview hydration errors, replace auth-method skeletons with a fallback on every load failure, and compute the `Auth method active` checklist row from loaded project config instead of hard-coding it as passing. diff --git a/packages/template/src/dev-tool/dev-tool-core.ts b/packages/template/src/dev-tool/dev-tool-core.ts index 1463cb53fb..99ba4cacdf 100644 --- a/packages/template/src/dev-tool/dev-tool-core.ts +++ b/packages/template/src/dev-tool/dev-tool-core.ts @@ -3,21 +3,20 @@ import type { RequestLogEntry } from "@stackframe/stack-shared/dist/interface/client-interface"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import { isLocalhost } from "@stackframe/stack-shared/dist/utils/urls"; -import { envVars } from "../lib/env"; import type { StackClientApp } from "../lib/stack-app"; import { getBaseUrl } from "../lib/stack-app/apps/implementations/common"; import type { HandlerUrlOptions, HandlerUrls, HandlerUrlTarget } from "../lib/stack-app/common"; import { stackAppInternalsSymbol } from "../lib/stack-app/common"; import { getPagePrompt } from "../lib/stack-app/url-targets"; import { devToolCSS } from "./dev-tool-styles"; -import type { TriggerPlacement } from "./dev-tool-trigger-position"; +import type { TriggerCorner, TriggerPlacement } from "./dev-tool-trigger-position"; import { clampTriggerPosition, getSnappedTriggerPlacement, resolveTriggerPosition } from "./dev-tool-trigger-position"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -type TabId = 'overview' | 'components' | 'ai' | 'docs' | 'dashboard' | 'console' | 'support'; +type TabId = 'overview' | 'customize' | 'ai' | 'dashboard' | 'console' | 'support'; type TabResult = { element: HTMLElement, cleanup?: () => void }; @@ -38,13 +37,9 @@ type EventLogEntry = { message: string; }; -type ConsoleSubTab = 'logs' | 'config'; -type SupportSubTab = 'feedback' | 'feature-requests'; - type DevToolState = { isOpen: boolean; activeTab: TabId; - consoleSubTab: ConsoleSubTab; panelWidth: number; panelHeight: number; }; @@ -55,15 +50,18 @@ type DevToolState = { const STORAGE_KEY = '__stack-dev-tool-state'; const TRIGGER_POS_KEY = 'stack-devtool-trigger-position'; +const ROOT_ID = '__stack-dev-tool-root'; +const GLOBAL_INSTANCE_KEY = '__stack-dev-tool-instance'; const MAX_LOG_ENTRIES = 500; +const CONSOLE_LOG_BATCH_SIZE = 100; const DRAG_THRESHOLD = 5; +const DOCS_URL = 'https://docs.stack-auth.com'; const TABS: { id: TabId; label: string; icon: string }[] = [ { id: 'overview', label: 'Overview', icon: '' }, - { id: 'components', label: 'Components', icon: '' }, + { id: 'customize', label: 'Customize', icon: '' }, { id: 'ai', label: 'AI', icon: '' }, { id: 'console', label: 'Console', icon: '' }, - { id: 'docs', label: 'Docs', icon: '' }, { id: 'dashboard', label: 'Dashboard', icon: '' }, { id: 'support', label: 'Support', icon: '' }, ]; @@ -71,7 +69,6 @@ const TABS: { id: TabId; label: string; icon: string }[] = [ const DEFAULT_STATE: DevToolState = { isOpen: false, activeTab: 'overview', - consoleSubTab: 'logs', panelWidth: 800, panelHeight: 520, }; @@ -86,7 +83,11 @@ function loadState(): DevToolState { try { const stored = localStorage.getItem(STORAGE_KEY); if (stored) { - return { ...DEFAULT_STATE, ...JSON.parse(stored) }; + const parsed = JSON.parse(stored); + // Migrate old 'components' tab name to 'customize' + if (parsed.activeTab === 'components') parsed.activeTab = 'customize'; + if (parsed.activeTab === 'docs') parsed.activeTab = 'overview'; + return { ...DEFAULT_STATE, ...parsed, isOpen: false }; } } catch {} return { ...DEFAULT_STATE }; @@ -94,7 +95,8 @@ function loadState(): DevToolState { function saveState(state: DevToolState) { try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + // Keep layout preferences across pages, but do not reopen the panel automatically on remount. + localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...state, isOpen: false })); } catch {} } @@ -132,6 +134,29 @@ type LogStore = { subscribe(fn: () => void): () => void; }; +type DevToolGlobalInstance = { + cleanup: () => void; +}; + +function isDevToolGlobalInstance(value: unknown): value is DevToolGlobalInstance { + return typeof value === 'object' && value !== null && typeof Reflect.get(value, 'cleanup') === 'function'; +} + +function getGlobalDevToolInstance(): DevToolGlobalInstance | null { + if (typeof window === 'undefined') return null; + const value: unknown = Reflect.get(window, GLOBAL_INSTANCE_KEY); + return isDevToolGlobalInstance(value) ? value : null; +} + +function setGlobalDevToolInstance(instance: DevToolGlobalInstance | null) { + if (typeof window === 'undefined') return; + if (instance === null) { + Reflect.deleteProperty(window, GLOBAL_INSTANCE_KEY); + } else { + Reflect.set(window, GLOBAL_INSTANCE_KEY, instance); + } +} + function getGlobalLogStore(): LogStore { const g = globalThis as any; if (!g.__STACK_DEV_TOOL_LOG_STORE__) { @@ -329,18 +354,15 @@ function appendInlineMarkdown(container: HTMLElement, text: string) { } // --------------------------------------------------------------------------- -// Trigger button (draggable pill) +// Trigger button (draggable pill — corner-snapping, icon only) // --------------------------------------------------------------------------- -function createTrigger(onClick: () => void): HTMLElement { +function createTrigger(onClick: () => void): { element: HTMLElement; cleanup: () => void } { type Position = { left: number; top: number }; type Placement = TriggerPlacement; - const triggerSize = { width: 76, height: 36 }; - const defaultPos = (): Position => ({ - left: window.innerWidth - 76 - 16, - top: window.innerHeight - 36 - 16, - }); + // Measured lazily after the element is appended to the DOM. + let triggerSize = { width: 36, height: 36 }; function isPosition(value: unknown): value is Position { if (typeof value !== 'object' || value === null) return false; @@ -349,8 +371,8 @@ function createTrigger(onClick: () => void): HTMLElement { function isPlacement(value: unknown): value is Placement { if (typeof value !== 'object' || value === null) return false; - const side = Reflect.get(value, 'side'); - return ['left', 'right', 'top', 'bottom'].includes(String(side)) && typeof Reflect.get(value, 'offset') === 'number'; + const corner = Reflect.get(value, 'corner'); + return ['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(String(corner)); } function loadPlacement(): Placement | null { @@ -358,7 +380,24 @@ function createTrigger(onClick: () => void): HTMLElement { const raw = localStorage.getItem(TRIGGER_POS_KEY); if (!raw) return null; const parsed = JSON.parse(raw); + if (isPlacement(parsed)) return parsed; + + // Migrate old side-based placement { side, offset } to nearest corner. + if (typeof parsed === 'object' && parsed !== null && 'side' in parsed && 'offset' in parsed) { + const side = String(Reflect.get(parsed, 'side')); + const offset = Number(Reflect.get(parsed, 'offset')); + const vw = window.innerWidth; + const vh = window.innerHeight; + let corner: TriggerCorner; + if (side === 'right') corner = offset < vh / 2 ? 'top-right' : 'bottom-right'; + else if (side === 'left') corner = offset < vh / 2 ? 'top-left' : 'bottom-left'; + else if (side === 'top') corner = offset < vw / 2 ? 'top-left' : 'top-right'; + else corner = offset < vw / 2 ? 'bottom-left' : 'bottom-right'; + return { corner }; + } + + // Migrate old absolute position. if (isPosition(parsed)) { return getSnappedTriggerPlacement(parsed, triggerSize, { width: window.innerWidth, height: window.innerHeight }); } @@ -372,26 +411,60 @@ function createTrigger(onClick: () => void): HTMLElement { } catch {} } - function applyPos(nextPos: Position) { + let animationTimeout: number | null = null; + + function setPositionAnimation(isAnimated: boolean) { + if (animationTimeout !== null) { + window.clearTimeout(animationTimeout); + animationTimeout = null; + } + btn.classList.toggle('sdt-trigger-position-animated', isAnimated); + if (isAnimated) { + animationTimeout = window.setTimeout(() => { + animationTimeout = null; + btn.classList.remove('sdt-trigger-position-animated'); + }, 180); + } + } + + function applyPos(nextPos: Position, options?: { animate?: boolean }) { + setPositionAnimation(options?.animate === true); pos = nextPos; btn.style.left = pos.left + 'px'; btn.style.top = pos.top + 'px'; } - const btn = h('button', { className: 'sdt-trigger', 'aria-label': 'Toggle Stack Auth Dev Tools', title: 'Stack Auth Dev Tools' }); + const btn = h('button', { + className: 'sdt-trigger', + 'aria-label': 'Toggle Stack Auth Dev Tools', + 'data-stack-devtool-trigger': 'true', + title: 'Stack Auth Dev Tools', + }); const logoSpan = h('span', { className: 'sdt-trigger-logo' }); setHtml(logoSpan, STACK_LOGO_SVG); btn.appendChild(logoSpan); - btn.appendChild(h('span', { className: 'sdt-trigger-text' }, 'DEV')); - let placement = loadPlacement() ?? getSnappedTriggerPlacement(defaultPos(), triggerSize, { width: window.innerWidth, height: window.innerHeight }); + let placement = loadPlacement() ?? { corner: 'bottom-right' as TriggerCorner }; let pos = resolveTriggerPosition(placement, triggerSize, { width: window.innerWidth, height: window.innerHeight }); applyPos(pos); let dragState: { startX: number; startY: number; startLeft: number; startTop: number; didDrag: boolean } | null = null; + // After mount, measure the actual rendered size and re-snap if needed. + requestAnimationFrame(() => { + const rect = btn.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + triggerSize = { width: rect.width, height: rect.height }; + const measured = resolveTriggerPosition(placement, triggerSize, { width: window.innerWidth, height: window.innerHeight }); + if (measured.left !== pos.left || measured.top !== pos.top) { + applyPos(measured, { animate: true }); + } + } + }); + btn.addEventListener('pointerdown', (e) => { e.preventDefault(); + setPositionAnimation(false); btn.setPointerCapture(e.pointerId); dragState = { startX: e.clientX, startY: e.clientY, startLeft: pos.left, startTop: pos.top, didDrag: false }; }); @@ -416,23 +489,33 @@ function createTrigger(onClick: () => void): HTMLElement { btn.releasePointerCapture(e.pointerId); if (ds.didDrag) { placement = getSnappedTriggerPlacement(pos, triggerSize, { width: window.innerWidth, height: window.innerHeight }); - applyPos(resolveTriggerPosition(placement, triggerSize, { width: window.innerWidth, height: window.innerHeight })); + applyPos(resolveTriggerPosition(placement, triggerSize, { width: window.innerWidth, height: window.innerHeight }), { animate: true }); savePlacement(placement); } else { onClick(); } }); - window.addEventListener('resize', () => { + // On viewport resize, reapply the existing corner placement to the new dimensions. + // Placement (corner) only changes when the user drags. + function onResize() { const resizedPos = resolveTriggerPosition(placement, triggerSize, { width: window.innerWidth, height: window.innerHeight }); if (resizedPos.left !== pos.left || resizedPos.top !== pos.top) { - applyPos(resizedPos); - placement = getSnappedTriggerPlacement(pos, triggerSize, { width: window.innerWidth, height: window.innerHeight }); - savePlacement(placement); + applyPos(resizedPos, { animate: true }); } - }); + } + + window.addEventListener('resize', onResize); - return btn; + return { + element: btn, + cleanup: () => { + if (animationTimeout !== null) { + window.clearTimeout(animationTimeout); + } + window.removeEventListener('resize', onResize); + }, + }; } // --------------------------------------------------------------------------- @@ -513,8 +596,13 @@ function createTabBar( // Iframe helper // --------------------------------------------------------------------------- -function createIframeTab(src: string, title: string, loadingMsg = 'Loading\u2026', errorMsg = 'Unable to load content', errorDetail?: string): HTMLElement { +function createIframeTab(src: string, title: string, loadingMsg = 'Loading\u2026', errorMsg = 'Unable to load content', errorDetail?: string, openExternallyLabel?: string): HTMLElement { const container = h('div', { className: 'sdt-iframe-container' }); + if (openExternallyLabel != null) { + container.appendChild(h('div', { className: 'sdt-iframe-toolbar' }, + h('a', { href: src, target: '_blank', rel: 'noopener noreferrer', className: 'sdt-iframe-open-link' }, openExternallyLabel), + )); + } const loadingEl = h('div', { className: 'sdt-iframe-loading' }, loadingMsg); container.appendChild(loadingEl); @@ -539,8 +627,7 @@ function createIframeTab(src: string, title: string, loadingMsg = 'Loading\u2026 } const retryBtn = h('button', { className: 'sdt-iframe-error-btn' }, 'Retry'); retryBtn.addEventListener('click', () => { - container.innerHTML = ''; - container.appendChild(createIframeTab(src, title, loadingMsg, errorMsg, errorDetail)); + container.replaceWith(createIframeTab(src, title, loadingMsg, errorMsg, errorDetail, openExternallyLabel)); }); errDiv.appendChild(retryBtn); const link = h('a', { href: src, target: '_blank', rel: 'noopener noreferrer', style: { color: 'var(--sdt-accent)', fontSize: '12px', textDecoration: 'none' } }, 'Open in new tab'); @@ -562,9 +649,8 @@ function createIframeTab(src: string, title: string, loadingMsg = 'Loading\u2026 function createOverviewTab(app: StackClientApp): TabResult { const container = h('div', { className: 'sdt-ov' }); - const apiBaseUrl = resolveApiBaseUrl(app); - // -- User hero card -- + // ── Identity card ────────────────────────────────────────────────────────── const heroCard = h('div', { className: 'sdt-ov-card sdt-ov-card-hero' }); heroCard.appendChild(h('div', { className: 'sdt-ov-label' }, 'Identity')); @@ -586,6 +672,22 @@ function createOverviewTab(app: StackClientApp): TabResult { setHtml(emailBtn, ''); emailRow.append(emailInput, emailBtn); + function isBestEffortOverviewError(error: unknown) { + if (error instanceof DOMException && error.name === 'AbortError') { + return true; + } + if (error instanceof TypeError) { + return true; + } + if (error instanceof Error) { + return error.message.includes('Failed to fetch') + || error.message.includes('NetworkError') + || error.message.includes('Load failed') + || error.message.includes('network connection'); + } + return false; + } + function showToast(msg: string, type: 'success' | 'error') { toast.textContent = msg; toast.className = `sdt-ov-toast sdt-ov-toast-${type}`; @@ -712,114 +814,27 @@ function createOverviewTab(app: StackClientApp): TabResult { } }); - async function refreshUser() { - try { - currentUser = await app.getUser(); - } catch { - currentUser = null; - } - if (currentUser) { - const initials = (currentUser.displayName || currentUser.primaryEmail || '?') - .split(' ').map((s: string) => s[0]).join('').slice(0, 2).toUpperCase(); - avatar.className = 'sdt-ov-avatar sdt-ov-avatar-active'; - if (currentUser.profileImageUrl) { - avatar.innerHTML = ``; - } else { - avatar.textContent = initials; - } - userName.textContent = currentUser.displayName || 'Anonymous'; - userEmail.textContent = currentUser.primaryEmail || 'No email'; - authIndicator.style.display = ''; - } else { - avatar.className = 'sdt-ov-avatar'; - avatar.textContent = '?'; - userName.textContent = 'No user signed in'; - userEmail.textContent = 'Sign in to test auth flows'; - authIndicator.style.display = 'none'; - } - rebuildActions(); - buildChecklist(); - } - heroCard.append(actions, toast); - runAsynchronously(refreshUser()); - const userPoll = setInterval(() => { - runAsynchronously(refreshUser()); - }, 3000); - // -- Project info card -- - const projectCard = h('div', { className: 'sdt-ov-card sdt-ov-card-project' }); - projectCard.appendChild(h('div', { className: 'sdt-ov-label' }, 'Project')); - const projectRows = h('div', { className: 'sdt-ov-project-rows' }); - - const sdkVersion = app.version; - const projectId = app.projectId; - - function addProjectRow(key: string, val: string | HTMLElement) { - const row = h('div', { className: 'sdt-ov-project-row' }); - row.appendChild(h('span', { className: 'sdt-ov-project-key' }, key)); - const valEl = h('span', { className: 'sdt-ov-project-val' }); - if (typeof val === 'string') { - valEl.textContent = val; - } else { - valEl.appendChild(val); - } - row.appendChild(valEl); - projectRows.appendChild(row); - } - - const sdkValSpan = h('span', null, sdkVersion || '?'); - addProjectRow('SDK', sdkValSpan); - - // Check latest version - const parsed = sdkVersion.match(/(@[\w-]+\/[\w-]+)@(\d+\.\d+\.\d+)/); - if (parsed) { - runAsynchronously( - fetch(`https://registry.npmjs.org/${parsed[1]}/latest`) - .then((r) => r.json()) - .then((data) => { - if (data.version) { - const pa = parsed[2].split('.').map(Number); - const pb = data.version.split('.').map(Number); - let outdated = false; - for (let i = 0; i < 3; i++) { - if (pa[i] !== pb[i]) { - outdated = pa[i] < pb[i]; - break; - } - } - if (outdated) { - const badge = h('span', { className: 'sdt-ov-sdk-badge', title: `Latest: ${data.version}` }, 'Outdated'); - sdkValSpan.parentElement!.appendChild(badge); - } - } - }) - ); - } - - const idValSpan = h('span', { className: 'sdt-ov-project-val-mono' }, projectId || 'N/A'); - addProjectRow('Project ID', idValSpan); - - const envVal = h('span', { className: 'sdt-ov-env-val' }); - const dot = h('span', { className: 'sdt-ov-pulse-dot' }); - envVal.append(dot, h('span', null, 'Development')); - addProjectRow('Environment', envVal); - - projectCard.appendChild(projectRows); - - // -- Auth config card -- - const authCard = h('div', { className: 'sdt-ov-card sdt-ov-card-auth' }); - authCard.appendChild(h('div', { className: 'sdt-ov-label' }, 'Config')); + // ── Auth methods card ────────────────────────────────────────────────────── + const methodsCard = h('div', { className: 'sdt-ov-card sdt-ov-card-auth' }); + methodsCard.appendChild(h('div', { className: 'sdt-ov-label' }, 'Auth Methods')); const authGrid = h('div', { className: 'sdt-ov-auth-grid' }); for (let i = 0; i < 3; i++) { authGrid.appendChild(h('div', { className: 'sdt-ov-method sdt-ov-skeleton-pill' })); } - authCard.appendChild(authGrid); + methodsCard.appendChild(authGrid); + let hasActiveAuthMethod: boolean | null = null; - runAsynchronously( - app.getProject().then((project: any) => { + async function loadAuthMethods() { + try { + const project = await app.getProject(); authGrid.innerHTML = ''; const config = project.config; + hasActiveAuthMethod = config.credentialEnabled + || config.magicLinkEnabled + || config.passkeyEnabled + || config.oauthProviders.length > 0; const methods = [ { label: 'Password', enabled: config.credentialEnabled }, { label: 'Magic Link', enabled: config.magicLinkEnabled }, @@ -840,32 +855,53 @@ function createOverviewTab(app: StackClientApp): TabResult { pill.appendChild(h('span', { className: 'sdt-ov-method-name' }, 'Sign-up off')); authGrid.appendChild(pill); } - }).catch(() => { - authGrid.innerHTML = '
Could not load config
'; - }) - ); + buildChecklist(); + } catch (error) { + authGrid.innerHTML = '
Could not load auth methods
'; + hasActiveAuthMethod = null; + buildChecklist(); + if (!isBestEffortOverviewError(error)) { + throw error; + } + } + } + + // Overview hydration is best-effort while the local Stack backend is still booting. + runAsynchronously(loadAuthMethods()); - // -- Checklist card -- + // ── Setup checklist (only shown when something is incomplete) ────────────── const checksCard = h('div', { className: 'sdt-ov-card sdt-ov-card-checks' }); + const projectId = app.projectId; + let checksCardMounted = false; + function buildChecklist() { checksCard.innerHTML = ''; const checks = [ - { ok: !!projectId && projectId !== 'default', label: 'Project' }, - { ok: true, label: 'Provider' }, - { ok: !!currentUser, label: 'Auth' }, + { ok: !!projectId && projectId !== 'default', label: 'Project configured', hint: null }, + { ok: hasActiveAuthMethod === true, label: 'Auth method active', hint: hasActiveAuthMethod === null ? 'Still checking project config' : null }, + { ok: !!currentUser, label: 'Sign in a test user', hint: 'Use \u201cQuick Sign In\u201d above \u2192' }, ]; const passCount = checks.filter((c) => c.ok).length; const allGood = passCount === checks.length; + if (allGood) { - checksCard.classList.add('sdt-ov-card-checks-ok'); - } else { - checksCard.classList.remove('sdt-ov-card-checks-ok'); + if (checksCardMounted && checksCard.parentElement) { + container.removeChild(checksCard); + checksCardMounted = false; + } + return; + } + + if (!checksCardMounted) { + container.appendChild(checksCard); + checksCardMounted = true; } - const header = h('div', { className: 'sdt-ov-checks-header' }); - header.appendChild(h('div', { className: 'sdt-ov-label', style: { marginBottom: '0' } }, 'Setup')); - header.appendChild(h('span', { className: `sdt-ov-checks-badge ${allGood ? 'sdt-ov-checks-badge-ok' : 'sdt-ov-checks-badge-warn'}` }, allGood ? 'All good' : `${passCount}/${checks.length}`)); - checksCard.appendChild(header); + const titleRow = h('div', { className: 'sdt-ov-checks-header' }); + const titleLabel = h('div', { className: 'sdt-ov-label', style: { marginBottom: '0', color: 'var(--sdt-warning)' } }, 'Setup'); + const badge = h('span', { className: 'sdt-ov-checks-badge sdt-ov-checks-badge-warn' }, `${passCount}\u200a/\u200a${checks.length}`); + titleRow.append(titleLabel, badge); + checksCard.appendChild(titleRow); const bar = h('div', { className: 'sdt-ov-checks-bar' }); const fill = h('div', { className: 'sdt-ov-checks-bar-fill' }); @@ -873,135 +909,61 @@ function createOverviewTab(app: StackClientApp): TabResult { bar.appendChild(fill); checksCard.appendChild(bar); - const checksRow = h('div', { className: 'sdt-ov-checks' }); for (const c of checks) { - const check = h('div', { className: `sdt-ov-check ${c.ok ? 'sdt-ov-check-ok' : 'sdt-ov-check-warn'}` }); - check.appendChild(h('span', { className: 'sdt-ov-check-icon' }, c.ok ? '\u2713' : '!')); - check.appendChild(h('span', { className: 'sdt-ov-check-label' }, c.label)); - checksRow.appendChild(check); + const row = h('div', { className: 'sdt-ov-setup-row' }); + row.appendChild(h('span', { className: `sdt-ov-setup-dot ${c.ok ? 'sdt-ov-setup-dot-ok' : 'sdt-ov-setup-dot-warn'}` })); + row.appendChild(h('span', { className: 'sdt-ov-setup-label' }, c.label)); + if (!c.ok && c.hint) { + row.appendChild(h('span', { className: 'sdt-ov-setup-hint' }, c.hint)); + } + checksCard.appendChild(row); } - checksCard.appendChild(checksRow); } - buildChecklist(); - - // -- Changelog card -- - const changelogCard = h('div', { className: 'sdt-ov-card sdt-ov-card-changelog' }); - changelogCard.appendChild(h('div', { className: 'sdt-ov-label' }, "What's New")); - - const changelogPath = '/api/latest/internal/changelog'; - const changelogContent = h('div', { className: 'sdt-ov-changelog-content' }); - changelogContent.innerHTML = '
Loading changelog...
'; - changelogCard.appendChild(changelogContent); - runAsynchronously((async () => { - let entries: any[] = []; + async function refreshUser() { try { - const res = await fetch(apiBaseUrl + changelogPath); - if (res.ok) { - const data = await res.json(); - entries = data.entries ?? []; - } - } catch {} - if (entries.length === 0) { - try { - const res = await fetch('https://api.stack-auth.com' + changelogPath); - if (res.ok) { - const data = await res.json(); - entries = data.entries ?? []; - } - } catch {} - } - - changelogContent.innerHTML = ''; - if (entries.length === 0) { - changelogContent.innerHTML = '
Could not load changelog.
'; - return; - } - - const changelogDiv = h('div', { className: 'sdt-ov-changelog' }); - let expandedVersion: string | null = entries[0]?.version ?? null; - - function renderEntries() { - changelogDiv.innerHTML = ''; - for (const entry of entries.slice(0, 5)) { - const isExpanded = expandedVersion === entry.version; - const release = h('div', { className: 'sdt-ov-release' }); - const head = h('div', { className: 'sdt-ov-release-head', style: { cursor: 'pointer' } }); - head.textContent = entry.version; - if (entry.releasedAt) { - head.appendChild(h('span', { className: 'sdt-ov-release-date' }, entry.releasedAt)); - } - const arrow = h('span', { style: { marginLeft: 'auto', fontSize: '10px', color: 'var(--sdt-text-tertiary)' } }, isExpanded ? '\u25B2' : '\u25BC'); - head.appendChild(arrow); - head.addEventListener('click', () => { - expandedVersion = isExpanded ? null : entry.version; - renderEntries(); - }); - release.appendChild(head); - - if (isExpanded && entry.markdown) { - const body = h('div', { className: 'sdt-ov-release-body', style: { padding: '4px 0 8px' } }); - const lines = entry.markdown.split('\n'); - for (const line of lines) { - const trimmedLine = line.trim(); - if (trimmedLine === '') continue; - - const image = parseMarkdownImage(trimmedLine); - if (image) { - const figure = h('figure', { className: 'sdt-ov-release-image-figure' }); - const imageLink = h('a', { - className: 'sdt-ov-release-image-link', - href: image.src, - target: '_blank', - rel: 'noopener noreferrer', - }); - imageLink.appendChild(h('img', { - className: 'sdt-ov-release-image', - src: image.src, - alt: image.alt, - loading: 'lazy', - decoding: 'async', - })); - figure.appendChild(imageLink); - if (image.alt !== '') { - figure.appendChild(h('figcaption', { className: 'sdt-ov-release-image-caption' }, image.alt)); - } - body.appendChild(figure); - continue; - } + currentUser = await app.getUser(); - const headingMatch = line.match(/^###\s+(.+)/); - if (headingMatch) { - const heading = h('div', { style: { fontWeight: '600', color: 'var(--sdt-text)', marginTop: '8px', marginBottom: '4px', fontSize: '12px' } }); - appendInlineMarkdown(heading, headingMatch[1]); - body.appendChild(heading); - continue; - } - if (line.startsWith('- ')) { - const li = h('div', { style: { fontSize: '12px', color: 'var(--sdt-text-secondary)', lineHeight: '1.6', paddingLeft: '12px' } }); - li.appendChild(document.createTextNode('\u2022 ')); - appendInlineMarkdown(li, line.slice(2)); - body.appendChild(li); - continue; - } - const paragraph = h('div', { style: { fontSize: '12px', color: 'var(--sdt-text-secondary)', lineHeight: '1.6' } }); - appendInlineMarkdown(paragraph, line); - body.appendChild(paragraph); - } - release.appendChild(body); + if (currentUser) { + const initials = (currentUser.displayName || currentUser.primaryEmail || '?') + .split(' ').map((s: string) => s[0]).join('').slice(0, 2).toUpperCase(); + avatar.className = 'sdt-ov-avatar sdt-ov-avatar-active'; + if (currentUser.profileImageUrl) { + avatar.innerHTML = ``; + } else { + avatar.textContent = initials; } - changelogDiv.appendChild(release); + userName.textContent = currentUser.displayName || 'Anonymous'; + userEmail.textContent = currentUser.primaryEmail || 'No email'; + authIndicator.style.display = ''; + } else { + avatar.className = 'sdt-ov-avatar'; + avatar.textContent = '?'; + userName.textContent = 'No user signed in'; + userEmail.textContent = 'Sign in to test auth flows'; + authIndicator.style.display = 'none'; + } + } catch (error) { + avatar.className = 'sdt-ov-avatar'; + avatar.textContent = '?'; + userName.textContent = 'Could not load user'; + userEmail.textContent = 'Check your local Stack backend'; + authIndicator.style.display = 'none'; + currentUser = null; + if (!isBestEffortOverviewError(error)) { + throw error; } } - renderEntries(); - changelogContent.appendChild(changelogDiv); - })()); - - const allReleasesLink = h('a', { className: 'sdt-ov-all-releases', href: 'https://github.com/stack-auth/stack/releases', target: '_blank', rel: 'noopener noreferrer' }); - setHtml(allReleasesLink, 'All releases '); - changelogCard.appendChild(allReleasesLink); + rebuildActions(); + buildChecklist(); + } - container.append(heroCard, projectCard, authCard, checksCard, changelogCard); + container.append(heroCard, methodsCard); + buildChecklist(); + runAsynchronously(refreshUser()); + const userPoll = setInterval(() => { + runAsynchronously(refreshUser()); + }, 3000); return { element: container, cleanup: () => clearInterval(userPoll) }; } @@ -1010,41 +972,94 @@ function createOverviewTab(app: StackClientApp): TabResult { // Console tab // --------------------------------------------------------------------------- -function createConsoleTab(app: StackClientApp, logStore: LogStore, state: ReturnType): TabResult { - const container = h('div', { style: { display: 'flex', flexDirection: 'column', height: '100%' } }); +type MergedLogEntry = + | { kind: 'api', entry: ApiLogEntry } + | { kind: 'event', entry: EventLogEntry }; + +function createConsoleTab(logStore: LogStore): TabResult { + const container = h('div', { className: 'sdt-console-panel' }); const EVENT_TYPE_STYLES: Record = { 'error': 'sdt-badge-error', 'info': 'sdt-badge-info', }; - const trailingBtns = h('div', { style: { display: 'flex', gap: '4px' } }); - const exportBtn = h('button', { className: 'sdt-close-btn', title: 'Export logs & config', style: { fontSize: '11px', width: 'auto', padding: '0 8px' } }); - setHtml(exportBtn, 'Export'); - const clearBtn = h('button', { className: 'sdt-close-btn', title: 'Clear logs', style: { fontSize: '11px', width: 'auto', padding: '0 8px' } }, 'Clear'); - clearBtn.addEventListener('click', () => logStore.clear()); - trailingBtns.append(exportBtn, clearBtn); - - const subTabBar = createTabBar( - [{ id: 'logs', label: 'Logs' }, { id: 'config', label: 'Config' }], - state.get().consoleSubTab, - (id) => { - state.update({ consoleSubTab: id as ConsoleSubTab }); - renderSubTab(); - }, - { variant: 'pills', trailing: trailingBtns }, - ); - container.appendChild(h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' } }, subTabBar.el)); - - const contentArea = h('div', { className: 'sdt-tab-content-fade', style: { flex: '1', overflow: 'auto' } }); + const title = h('div', { className: 'sdt-console-title' }, 'Logs'); + const actions = h('div', { className: 'sdt-console-actions' }); + const copyBtn = h('button', { className: 'sdt-console-action-btn', title: 'Copy logs' }); + setHtml(copyBtn, 'Copy'); + const exportBtn = h('button', { className: 'sdt-console-action-btn', title: 'Export logs' }); + setHtml(exportBtn, 'Export'); + const clearBtn = h('button', { className: 'sdt-console-action-btn', title: 'Clear logs' }); + setHtml(clearBtn, 'Clear'); + actions.append(copyBtn, exportBtn, clearBtn); + container.appendChild(h('div', { className: 'sdt-console-header' }, title, actions)); + + const contentArea = h('div', { className: 'sdt-console-log-scroll sdt-tab-content-fade' }); container.appendChild(contentArea); + let visibleLogCount = CONSOLE_LOG_BATCH_SIZE; + + function getMergedLogs(): MergedLogEntry[] { + return [ + ...logStore.apiLogs.map((entry) => ({ kind: 'api' as const, entry })), + ...logStore.eventLogs.map((entry) => ({ kind: 'event' as const, entry })), + ].sort((a, b) => b.entry.timestamp - a.entry.timestamp); + } + + function formatLogLine(item: MergedLogEntry): string { + if (item.kind === 'api') { + const log = item.entry; + const status = log.status !== undefined ? ` [${log.status}]` : ''; + const duration = log.duration !== undefined ? ` ${log.duration}ms` : ''; + const error = log.error !== undefined ? ` ${log.error}` : ''; + return `${new Date(log.timestamp).toISOString()} ${log.method} ${log.url}${status}${duration}${error}`; + } + + const log = item.entry; + return `${new Date(log.timestamp).toISOString()} ${log.type.toUpperCase()} ${log.message}`; + } + + function formatLogsForExport(): string { + const lines = [ + '=== Stack Auth Dev Tool Logs ===', + `Generated: ${new Date().toISOString()}`, + `Total logs: ${getMergedLogs().length}`, + '', + ...getMergedLogs().map(formatLogLine), + ]; + return lines.join('\n'); + } + + function renderLogItem(item: MergedLogEntry): HTMLElement { + if (item.kind === 'api') { + const log = item.entry; + const row = h('div', { className: 'sdt-log-item' }); + row.appendChild(h('span', { className: 'sdt-log-time' }, formatTimestamp(log.timestamp))); + row.appendChild(h('span', { className: `sdt-log-method sdt-log-method-${log.method.toLowerCase()}` }, log.method)); + row.appendChild(h('span', { className: 'sdt-log-url' }, log.url)); + if (log.status !== undefined) { + row.appendChild(h('span', { className: `sdt-log-status ${log.status < 400 ? 'sdt-log-status-ok' : 'sdt-log-status-err'}` }, String(log.status))); + } + if (log.duration !== undefined) { + row.appendChild(h('span', { className: 'sdt-log-time' }, log.duration + 'ms')); + } + return row; + } + + const log = item.entry; + const row = h('div', { className: 'sdt-log-item' }); + row.appendChild(h('span', { className: 'sdt-log-time' }, formatTimestamp(log.timestamp))); + row.appendChild(h('span', { className: `sdt-badge ${EVENT_TYPE_STYLES[log.type] || 'sdt-badge-info'}` }, log.type)); + row.appendChild(h('span', { className: 'sdt-log-message' }, log.message)); + return row; + } + function renderLogs() { + const previousScrollTop = contentArea.scrollTop; contentArea.innerHTML = ''; - const merged = [ - ...logStore.apiLogs.map((e) => ({ kind: 'api' as const, entry: e })), - ...logStore.eventLogs.map((e) => ({ kind: 'event' as const, entry: e })), - ].sort((a, b) => b.entry.timestamp - a.entry.timestamp); + const merged = getMergedLogs(); + visibleLogCount = Math.min(Math.max(visibleLogCount, CONSOLE_LOG_BATCH_SIZE), Math.max(merged.length, CONSOLE_LOG_BATCH_SIZE)); if (merged.length === 0) { contentArea.innerHTML = '
\uD83D\uDCCB
No logs recorded yet
API calls and auth events will appear here
'; @@ -1052,113 +1067,66 @@ function createConsoleTab(app: StackClientApp, logStore: LogStore, state: } const list = h('div', { className: 'sdt-log-list' }); - for (const item of merged) { - if (item.kind === 'api') { - const log = item.entry as ApiLogEntry; - const row = h('div', { className: 'sdt-log-item' }); - row.appendChild(h('span', { className: 'sdt-log-time' }, formatTimestamp(log.timestamp))); - row.appendChild(h('span', { className: `sdt-log-method sdt-log-method-${log.method.toLowerCase()}` }, log.method)); - row.appendChild(h('span', { className: 'sdt-log-url' }, log.url)); - if (log.status !== undefined) { - row.appendChild(h('span', { className: `sdt-log-status ${log.status < 400 ? 'sdt-log-status-ok' : 'sdt-log-status-err'}` }, String(log.status))); - } - if (log.duration !== undefined) { - row.appendChild(h('span', { className: 'sdt-log-time' }, log.duration + 'ms')); - } - list.appendChild(row); - } else { - const log = item.entry as EventLogEntry; - const row = h('div', { className: 'sdt-log-item' }); - row.appendChild(h('span', { className: 'sdt-log-time' }, formatTimestamp(log.timestamp))); - row.appendChild(h('span', { className: `sdt-badge ${EVENT_TYPE_STYLES[log.type] || 'sdt-badge-info'}` }, log.type)); - row.appendChild(h('span', { className: 'sdt-log-message' }, log.message)); - list.appendChild(row); - } + for (const item of merged.slice(0, visibleLogCount)) { + list.appendChild(renderLogItem(item)); + } + if (visibleLogCount < merged.length) { + list.appendChild(h('div', { className: 'sdt-log-load-hint' }, `${merged.length - visibleLogCount} older logs available`)); } contentArea.appendChild(list); + contentArea.scrollTop = Math.min(previousScrollTop, contentArea.scrollHeight); } - function renderConfig() { - contentArea.innerHTML = '
Loading config...
'; - runAsynchronously( - app.getProject().then((project: any) => { - contentArea.innerHTML = ''; - const table = h('table', { className: 'sdt-config-table' }); - const tbody = h('tbody', null); - const items: [string, string][] = [ - ['Project ID', project.id], - ['Display Name', project.displayName], - ['Sign-Up Enabled', String(project.config.signUpEnabled)], - ['Credential Auth', String(project.config.credentialEnabled)], - ['Magic Link', String(project.config.magicLinkEnabled)], - ['Passkey', String(project.config.passkeyEnabled)], - ['Client Team Creation', String(project.config.clientTeamCreationEnabled)], - ['Client User Deletion', String(project.config.clientUserDeletionEnabled)], - ['User API Keys', String(project.config.allowUserApiKeys)], - ['Team API Keys', String(project.config.allowTeamApiKeys)], - ['OAuth Providers', project.config.oauthProviders.length > 0 ? project.config.oauthProviders.map((p: any) => p.id).join(', ') : 'None'], - ]; - for (const [label, value] of items) { - const tr = h('tr', null); - tr.appendChild(h('td', null, label)); - const td = h('td', null); - if (value === 'true') { - setHtml(td, 'Enabled'); - } else if (value === 'false') { - setHtml(td, 'Disabled'); - } else { - td.textContent = value; - } - tr.appendChild(td); - tbody.appendChild(tr); - } - table.appendChild(tbody); - contentArea.appendChild(table); - }).catch(() => { - contentArea.innerHTML = '
Could not load config.
'; - }) - ); - } - - function renderSubTab() { - subTabBar.setActive(state.get().consoleSubTab); - clearBtn.style.display = state.get().consoleSubTab === 'logs' ? '' : 'none'; - if (state.get().consoleSubTab === 'logs') { + function maybeLoadOlderLogs() { + const mergedLength = getMergedLogs().length; + if (visibleLogCount >= mergedLength) return; + const distanceFromBottom = contentArea.scrollHeight - contentArea.scrollTop - contentArea.clientHeight; + if (distanceFromBottom <= 48) { + visibleLogCount = Math.min(visibleLogCount + CONSOLE_LOG_BATCH_SIZE, mergedLength); renderLogs(); - } else { - renderConfig(); } } - renderSubTab(); + contentArea.addEventListener('scroll', maybeLoadOlderLogs); + renderLogs(); - exportBtn.addEventListener('click', () => { - const lines: string[] = []; - lines.push('=== Stack Auth Dev Tool Report ==='); - lines.push(`Generated: ${new Date().toISOString()}`); - lines.push(''); - for (const log of logStore.apiLogs.slice(0, 50)) { - const status = log.status !== undefined ? ` [${log.status}]` : ''; - const duration = log.duration !== undefined ? ` ${log.duration}ms` : ''; - lines.push(`${new Date(log.timestamp).toISOString()} ${log.method} ${log.url}${status}${duration}`); - } + copyBtn.addEventListener('click', () => { runAsynchronously( - navigator.clipboard.writeText(lines.join('\n')).then(() => { - exportBtn.textContent = '\u2713 Copied'; + navigator.clipboard.writeText(formatLogsForExport()).then(() => { + copyBtn.textContent = '\u2713 Copied'; setTimeout(() => { - setHtml(exportBtn, 'Export'); + setHtml(copyBtn, 'Copy'); }, 1500); }) ); }); + exportBtn.addEventListener('click', () => { + const blob = new Blob([formatLogsForExport()], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = h('a', { href: url, download: `stack-auth-dev-tool-logs-${new Date().toISOString()}.txt` }); + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + }); + + clearBtn.addEventListener('click', () => { + visibleLogCount = CONSOLE_LOG_BATCH_SIZE; + logStore.clear(); + }); + const unsub = logStore.subscribe(() => { - if (state.get().consoleSubTab === 'logs') { - renderLogs(); - } + renderLogs(); }); - return { element: container, cleanup: unsub }; + return { + element: container, + cleanup: () => { + contentArea.removeEventListener('scroll', maybeLoadOlderLogs); + unsub(); + }, + }; } // --------------------------------------------------------------------------- @@ -1769,31 +1737,13 @@ function createAITab(app: StackClientApp): HTMLElement { return container; } -// --------------------------------------------------------------------------- -// Docs tab -// --------------------------------------------------------------------------- - -function createDocsTab(): HTMLElement { - return createIframeTab('https://docs.stack-auth.com', 'Stack Auth Documentation', 'Loading documentation\u2026', 'Unable to load documentation'); -} - // --------------------------------------------------------------------------- // Dashboard tab // --------------------------------------------------------------------------- function createDashboardTab(app: StackClientApp): HTMLElement { const dashboardUrl = resolveDashboardUrl(app); - const isLocalEmulator = envVars.NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR === 'true'; - - if (!isLocalEmulator) { - const ctr = h('div', { className: 'sdt-iframe-container', style: { display: 'flex', alignItems: 'center', justifyContent: 'center' } }); - const inner = h('div', { style: { textAlign: 'center', display: 'flex', flexDirection: 'column', gap: '12px', alignItems: 'center' } }); - inner.appendChild(h('a', { href: dashboardUrl, target: '_blank', rel: 'noopener noreferrer', className: 'sdt-iframe-error-btn', style: { textDecoration: 'none' } }, 'Open Dashboard in New Tab')); - ctr.appendChild(inner); - return ctr; - } - - return createIframeTab(dashboardUrl, 'Stack Auth Dashboard', 'Loading dashboard\u2026', 'Unable to load dashboard', 'The dashboard may require authentication or block framing'); + return createIframeTab(dashboardUrl, 'Stack Auth Dashboard', 'Loading dashboard\u2026', 'Unable to load dashboard', 'The dashboard may require authentication or block framing', 'Open in New Tab'); } // --------------------------------------------------------------------------- @@ -1804,41 +1754,6 @@ function createSupportTab(app: StackClientApp): HTMLElement { const container = h('div', { className: 'sdt-support-tab' }); const apiBaseUrl = resolveApiBaseUrl(app); - let subTab: SupportSubTab = 'feedback'; - const contentArea = h('div', { className: 'sdt-support-content' }); - - const subTabBar = createTabBar( - [{ id: 'feedback', label: 'Feedback' }, { id: 'feature-requests', label: 'Feature Requests' }], - subTab, - (id) => { - subTab = id as SupportSubTab; - subTabBar.setActive(subTab); - renderSubTab(); - }, - { variant: 'pills' }, - ); - container.appendChild(subTabBar.el); - container.appendChild(contentArea); - - let feedbackPane: HTMLElement | null = null; - let featurePane: HTMLElement | null = null; - - function renderSubTab() { - contentArea.innerHTML = ''; - if (subTab === 'feedback') { - if (!feedbackPane) { - feedbackPane = createFeedbackForm(); - } - contentArea.appendChild(feedbackPane); - } else { - if (!featurePane) { - featurePane = h('div', { className: 'sdt-support-iframe-pane' }); - featurePane.appendChild(createIframeTab('https://feedback.stack-auth.com', 'Stack Auth Feature Requests', 'Loading feature requests\u2026', 'Unable to load feature requests')); - } - contentArea.appendChild(featurePane); - } - } - function createFeedbackForm(): HTMLElement { const pane = h('div', { className: 'sdt-support-feedback-pane' }); const form = h('form', { className: 'sdt-support-form' }); @@ -1925,7 +1840,7 @@ function createSupportTab(app: StackClientApp): HTMLElement { form.appendChild(typeCards); const submitBtn = h('button', { type: 'submit', className: 'sdt-support-submit' }); - setHtml(submitBtn, ' Submit'); + setHtml(submitBtn, 'Submit '); submitBtn.disabled = status === 'submitting'; form.appendChild(submitBtn); @@ -1944,6 +1859,7 @@ function createSupportTab(app: StackClientApp): HTMLElement { GitHub `; form.appendChild(channels); + form.insertBefore(channels, form.firstChild); } form.addEventListener('submit', (e) => { @@ -1985,7 +1901,7 @@ function createSupportTab(app: StackClientApp): HTMLElement { return pane; } - renderSubTab(); + container.appendChild(createFeedbackForm()); return container; } @@ -2015,18 +1931,6 @@ function createComponentsTab(app: StackClientApp): HTMLElement { type PageClassification = 'handler-component' | 'hosted' | 'custom'; - const classificationLabel: Record = { - 'handler-component': 'Handler', - 'hosted': 'Hosted', - 'custom': 'Custom', - }; - - const classificationBadgeClass: Record = { - 'handler-component': 'sdt-pg-badge-handler', - 'hosted': 'sdt-pg-badge-hosted', - 'custom': 'sdt-pg-badge-custom', - }; - function classifyPage(key: keyof HandlerUrls): { classification: PageClassification; version: number | null } { const target: HandlerUrlTarget = (urlOptions as any)[key] ?? (urlOptions as any).default ?? { type: 'handler-component' }; if (typeof target === 'string') { @@ -2093,6 +1997,11 @@ function createComponentsTab(app: StackClientApp): HTMLElement { }); } + function getCompactUrl(url: string): string { + const resolved = new URL(url, window.location.origin); + return `${resolved.pathname}${resolved.search}${resolved.hash}`; + } + const sidebar = h('div', { className: 'sdt-pg-sidebar' }); const mainArea = h('div', { className: 'sdt-pg-main' }); @@ -2125,8 +2034,6 @@ function createComponentsTab(app: StackClientApp): HTMLElement { item.appendChild(h('span', { className: 'sdt-pg-item-label' }, page.label)); if (isOutdated) { item.appendChild(h('span', { className: 'sdt-pg-badge sdt-pg-badge-outdated' }, 'Outdated')); - } else { - item.appendChild(h('span', { className: `sdt-pg-badge ${classificationBadgeClass[page.classification]}` }, classificationLabel[page.classification])); } item.addEventListener('click', () => { selectedKey = page.key as string; @@ -2145,25 +2052,22 @@ function createComponentsTab(app: StackClientApp): HTMLElement { const header = h('div', { className: 'sdt-pg-header' }); const headerTop = h('div', { className: 'sdt-pg-header-top' }); headerTop.appendChild(h('h3', { className: 'sdt-pg-title' }, `${page.label} Page`)); + headerTop.appendChild(h('a', { href: page.url, target: '_blank', rel: 'noopener noreferrer', className: 'sdt-pg-title-url' }, getCompactUrl(page.url))); if (page.versionStatus === 'outdated') { headerTop.appendChild(h('span', { className: 'sdt-pg-badge sdt-pg-badge-outdated' }, 'Outdated')); } - headerTop.appendChild(h('span', { className: `sdt-pg-badge ${classificationBadgeClass[page.classification]}` }, classificationLabel[page.classification])); header.appendChild(headerTop); const redirectMethod = `stackApp.redirectTo${(page.key as string).charAt(0).toUpperCase()}${(page.key as string).slice(1)}()`; const codeRow = h('div', { className: 'sdt-pg-code-inline' }); codeRow.appendChild(h('code', { className: 'sdt-pg-code' }, redirectMethod)); - const viewBtn = h('button', { className: 'sdt-pg-copy-btn' }, 'View'); - viewBtn.addEventListener('click', () => { + const openBtn = h('button', { className: 'sdt-pg-copy-btn sdt-pg-open-btn' }); + setHtml(openBtn, 'Open '); + openBtn.addEventListener('click', () => { const resolved = new URL(page.url, window.location.origin); - if (resolved.origin === window.location.origin) { - window.location.href = resolved.toString(); - } else { - window.open(resolved.toString(), '_blank', 'noopener,noreferrer'); - } + window.open(resolved.toString(), '_blank', 'noopener,noreferrer'); }); - codeRow.appendChild(viewBtn); + codeRow.appendChild(openBtn); header.appendChild(codeRow); detail.appendChild(header); @@ -2183,7 +2087,7 @@ function createComponentsTab(app: StackClientApp): HTMLElement { if (promptText) { const section = h('div', { className: 'sdt-pg-section' }); - section.appendChild(h('div', { className: 'sdt-pg-section-label' }, isOutdated ? 'Use this prompt to upgrade your component:' : 'Customization prompt:')); + section.appendChild(h('div', { className: 'sdt-pg-section-label' }, isOutdated ? 'Use this prompt to upgrade your component:' : 'Want to customize this page? Paste this prompt into your coding agent.')); section.appendChild(h('pre', { className: 'sdt-pg-pre' }, promptText)); const footer = h('div', { className: 'sdt-pg-section-footer' }); const copyBtn = h('button', { className: 'sdt-pg-copy-btn' }, 'Copy prompt'); @@ -2202,11 +2106,6 @@ function createComponentsTab(app: StackClientApp): HTMLElement { } } - const urlRow = h('div', { className: 'sdt-pg-url-row' }); - urlRow.appendChild(h('span', { className: 'sdt-pg-url-label' }, 'URL')); - urlRow.appendChild(h('a', { href: page.url, target: '_blank', rel: 'noopener noreferrer', className: 'sdt-pg-url' }, page.url)); - detail.appendChild(urlRow); - mainArea.appendChild(detail); } @@ -2239,8 +2138,37 @@ function createPanel( onClose: () => void, ): { element: HTMLElement, cleanup: () => void } { const panel = h('div', { className: 'sdt-panel' }); - panel.style.width = state.get().panelWidth + 'px'; - panel.style.height = state.get().panelHeight + 'px'; + let panelAnimationTimeout: ReturnType | null = null; + + function animateNextPanelGeometryChange() { + panel.classList.add('sdt-panel-geometry-animated'); + if (panelAnimationTimeout !== null) { + clearTimeout(panelAnimationTimeout); + } + panelAnimationTimeout = setTimeout(() => { + panel.classList.remove('sdt-panel-geometry-animated'); + panelAnimationTimeout = null; + }, 220); + } + + function applyPanelMode(tabId: TabId, opts?: { animate?: boolean }) { + if (opts?.animate === true) { + animateNextPanelGeometryChange(); + } + + if (tabId === 'dashboard') { + panel.classList.add('sdt-panel-fullscreen'); + panel.style.width = ''; + panel.style.height = ''; + return; + } + + panel.classList.remove('sdt-panel-fullscreen'); + panel.style.width = state.get().panelWidth + 'px'; + panel.style.height = state.get().panelHeight + 'px'; + } + + applyPanelMode(state.get().activeTab); const inner = h('div', { className: 'sdt-panel-inner' }); @@ -2248,10 +2176,19 @@ function createPanel( setHtml(closeBtn, ''); closeBtn.addEventListener('click', onClose); + const docsLink = h('a', { href: DOCS_URL, target: '_blank', rel: 'noopener noreferrer', className: 'sdt-docs-link' }); + docsLink.appendChild(document.createTextNode('Docs')); + const docsIcon = h('span', { className: 'sdt-docs-link-icon', 'aria-hidden': 'true' }); + setHtml(docsIcon, ''); + docsLink.appendChild(docsIcon); + + const trailingControls = h('div', { className: 'sdt-tabbar-actions' }, docsLink, closeBtn); + const tabBar = createTabBar(TABS, state.get().activeTab, (id) => { state.update({ activeTab: id as TabId }); + applyPanelMode(id as TabId, { animate: true }); showTab(id as TabId); - }, { trailing: closeBtn }); + }, { trailing: trailingControls }); inner.appendChild(tabBar.el); const content = h('div', { className: 'sdt-content' }); @@ -2278,12 +2215,15 @@ function createPanel( return mountedPanes.get(tabId)!; } const pane = h('div', { className: 'sdt-tab-pane' }); + if (tabId === 'dashboard') { + pane.classList.add('sdt-tab-pane-iframe'); + } switch (tabId) { case 'overview': { mountTab(pane, createOverviewTab(app)); break; } - case 'components': { + case 'customize': { mountTab(pane, createComponentsTab(app)); break; } @@ -2292,11 +2232,7 @@ function createPanel( break; } case 'console': { - mountTab(pane, createConsoleTab(app, logStore, state)); - break; - } - case 'docs': { - mountTab(pane, createDocsTab()); + mountTab(pane, createConsoleTab(logStore)); break; } case 'dashboard': { @@ -2333,6 +2269,11 @@ function createPanel( handle.addEventListener('pointerdown', (e) => { e.preventDefault(); + if (panelAnimationTimeout !== null) { + clearTimeout(panelAnimationTimeout); + panelAnimationTimeout = null; + } + panel.classList.remove('sdt-panel-geometry-animated'); handle.setPointerCapture(e.pointerId); startX = e.clientX; startY = e.clientY; @@ -2370,6 +2311,9 @@ function createPanel( return { element: panel, cleanup: () => { + if (panelAnimationTimeout !== null) { + clearTimeout(panelAnimationTimeout); + } for (const fn of cleanups) fn(); }, }; @@ -2386,8 +2330,15 @@ export function createDevTool(app: StackClientApp): () => void { const body = Reflect.get(document, 'body'); if (!hasAppendChild(body)) return () => {}; + getGlobalDevToolInstance()?.cleanup(); + let existingRoot = document.getElementById(ROOT_ID); + while (existingRoot !== null) { + existingRoot.remove(); + existingRoot = document.getElementById(ROOT_ID); + } + const root = document.createElement('div'); - root.id = '__stack-dev-tool-root'; + root.id = ROOT_ID; body.appendChild(root); const wrapper = h('div', { className: 'stack-devtool' }); @@ -2403,18 +2354,19 @@ export function createDevTool(app: StackClientApp): () => void { let panel: { element: HTMLElement, cleanup: () => void } | null = null; function closePanelAndPersistClosed() { - state.update({ isOpen: false }); closePanel(); } function openPanel() { if (panel) return; + state.update({ activeTab: 'overview' }); panel = createPanel(app, state, logStore, closePanelAndPersistClosed); wrapper.appendChild(panel.element); } function closePanel() { if (!panel) return; + state.update({ isOpen: false }); const closing = panel; panel = null; closing.cleanup(); @@ -2428,7 +2380,6 @@ export function createDevTool(app: StackClientApp): () => void { function togglePanel() { if (state.get().isOpen) { - state.update({ isOpen: false }); closePanel(); } else { state.update({ isOpen: true }); @@ -2437,7 +2388,7 @@ export function createDevTool(app: StackClientApp): () => void { } const trigger = createTrigger(togglePanel); - wrapper.appendChild(trigger); + wrapper.appendChild(trigger.element); if (state.get().isOpen) { openPanel(); @@ -2461,12 +2412,26 @@ export function createDevTool(app: StackClientApp): () => void { } }); + let didCleanup = false; + const instance: DevToolGlobalInstance = { + cleanup: () => { + if (didCleanup) return; + didCleanup = true; + if (getGlobalDevToolInstance() === instance) { + setGlobalDevToolInstance(null); + } + trigger.cleanup(); + removeRequestListener(); + panel?.cleanup(); + if (root.parentNode) { + root.parentNode.removeChild(root); + } + }, + }; + setGlobalDevToolInstance(instance); + return () => { - removeRequestListener(); - panel?.cleanup(); - if (root.parentNode) { - root.parentNode.removeChild(root); - } + instance.cleanup(); }; } diff --git a/packages/template/src/dev-tool/dev-tool-styles.ts b/packages/template/src/dev-tool/dev-tool-styles.ts index 39064588f4..7e0e3ef310 100644 --- a/packages/template/src/dev-tool/dev-tool-styles.ts +++ b/packages/template/src/dev-tool/dev-tool-styles.ts @@ -25,6 +25,7 @@ export const devToolCSS = ` --sdt-error-muted: rgba(239, 68, 68, 0.15); --sdt-info: #3b82f6; --sdt-info-muted: rgba(59, 130, 246, 0.15); + --sdt-overlay-bg: rgba(17, 17, 19, 0.92); --sdt-radius: 8px; --sdt-radius-sm: 4px; --sdt-radius-lg: 12px; @@ -50,25 +51,25 @@ export const devToolCSS = ` /* Trigger pill */ .stack-devtool .sdt-trigger { position: fixed; - z-index: 99999; + z-index: 2147483647; display: flex; align-items: center; - gap: 6px; + justify-content: center; + width: 36px; height: 36px; - padding: 0 12px 0 8px; + padding: 0; background: var(--sdt-bg-elevated); border: 1px solid var(--sdt-border); - border-radius: 20px; + border-radius: 10px; cursor: grab; box-shadow: var(--sdt-trigger-shadow); transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; user-select: none; touch-action: none; - font-family: var(--sdt-font); - font-size: 12px; - font-weight: 600; - color: var(--sdt-text); - letter-spacing: 0.5px; + } + + .stack-devtool .sdt-trigger-position-animated { + transition: left 0.14s cubic-bezier(0.2, 0.8, 0.2, 1), top 0.14s cubic-bezier(0.2, 0.8, 0.2, 1), background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; } .stack-devtool .sdt-trigger:hover { @@ -82,9 +83,9 @@ export const devToolCSS = ` } .stack-devtool .sdt-trigger-logo { - width: 20px; - height: 20px; - border-radius: 50%; + width: 22px; + height: 22px; + border-radius: 6px; background: var(--sdt-accent); display: flex; align-items: center; @@ -93,14 +94,6 @@ export const devToolCSS = ` line-height: 0; } - .stack-devtool .sdt-trigger-text { - font-size: 11px; - font-weight: 700; - letter-spacing: 1.5px; - text-transform: uppercase; - color: var(--sdt-text-secondary); - } - /* Panel overlay */ .stack-devtool .sdt-panel { position: fixed; @@ -120,6 +113,26 @@ export const devToolCSS = ` overflow: visible; } + .stack-devtool .sdt-panel-geometry-animated { + transition: width 0.18s cubic-bezier(0.2, 0.8, 0.2, 1), + height 0.18s cubic-bezier(0.2, 0.8, 0.2, 1), + right 0.18s cubic-bezier(0.2, 0.8, 0.2, 1), + bottom 0.18s cubic-bezier(0.2, 0.8, 0.2, 1), + border-radius 0.18s cubic-bezier(0.2, 0.8, 0.2, 1), + border-color 0.18s cubic-bezier(0.2, 0.8, 0.2, 1); + } + + .stack-devtool .sdt-panel-fullscreen { + right: 0; + bottom: 0; + width: 100vw; + max-width: none; + height: 100vh; + max-height: none; + border: none; + border-radius: 0; + } + .stack-devtool .sdt-panel-inner { display: flex; flex-direction: column; @@ -130,6 +143,14 @@ export const devToolCSS = ` animation: sdt-panel-enter 0.2s ease-out; } + .stack-devtool .sdt-panel-fullscreen .sdt-panel-inner { + border-radius: 0; + } + + .stack-devtool .sdt-panel-fullscreen .sdt-resize-handle { + display: none; + } + @keyframes sdt-panel-enter { from { opacity: 0; @@ -170,6 +191,18 @@ export const devToolCSS = ` overflow-x: auto; } + .stack-devtool .sdt-panel-fullscreen .sdt-tabbar { + position: absolute; + top: 8px; + left: 8px; + right: 8px; + z-index: 2; + background: var(--sdt-overlay-bg); + border: 1px solid var(--sdt-border); + border-radius: var(--sdt-radius); + box-shadow: var(--sdt-trigger-shadow); + } + .stack-devtool .sdt-tab-indicator { position: absolute; top: 6px; @@ -225,6 +258,42 @@ export const devToolCSS = ` flex: 1; } + .stack-devtool .sdt-tabbar-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + } + + .stack-devtool .sdt-docs-link { + display: inline-flex; + align-items: center; + gap: 4px; + height: 28px; + padding: 0 8px; + color: var(--sdt-text-secondary); + border-radius: var(--sdt-radius-sm); + font-family: var(--sdt-font); + font-size: 12px; + font-weight: 500; + line-height: 1; + text-decoration: none; + white-space: nowrap; + transition: color 0.15s ease, background 0.15s ease; + } + + .stack-devtool .sdt-docs-link:hover { + color: var(--sdt-text); + background: var(--sdt-bg-hover); + } + + .stack-devtool .sdt-docs-link-icon { + display: flex; + width: 13px; + height: 13px; + line-height: 0; + } + .stack-devtool .sdt-close-btn { display: flex; align-items: center; @@ -253,6 +322,13 @@ export const devToolCSS = ` min-height: 0; } + .stack-devtool .sdt-panel-fullscreen .sdt-content { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + } + .stack-devtool .sdt-tab-layers { position: absolute; inset: 0; @@ -268,6 +344,11 @@ export const devToolCSS = ` pointer-events: none; } + .stack-devtool .sdt-tab-pane-iframe { + padding: 0; + overflow: hidden; + } + .stack-devtool .sdt-tab-pane-active { visibility: visible; pointer-events: auto; @@ -298,17 +379,14 @@ export const devToolCSS = ` border-radius: 3px; } - /* ===== Overview tab — MSN bento grid ===== */ + /* ===== Overview tab — single column ===== */ .stack-devtool .sdt-ov { - margin: -16px; - padding: 8px; - display: grid; - grid-template-columns: 2fr 1fr; - grid-template-rows: auto auto 1fr; - gap: 8px; - height: calc(100% + 32px); - overflow: hidden; + display: flex; + flex-direction: column; + gap: 10px; + max-width: 660px; + margin: 0 auto; } /* Card base */ @@ -319,14 +397,14 @@ export const devToolCSS = ` padding: 16px; display: flex; flex-direction: column; + gap: 0; transition: box-shadow 0.2s ease, border-color 0.2s ease; overflow: hidden; min-width: 0; } - .stack-devtool .sdt-ov-card:hover { - border-color: var(--sdt-border); - box-shadow: 0 0 0 1px rgba(99,102,241,0.12); + .stack-devtool .sdt-ov-card-hero { + background: linear-gradient(135deg, rgba(99,102,241,0.04) 0%, transparent 50%), var(--sdt-bg-elevated); } .stack-devtool .sdt-ov-label { @@ -338,11 +416,6 @@ export const devToolCSS = ` margin-bottom: 10px; } - /* --- User hero card (span 2 cols) --- */ - .stack-devtool .sdt-ov-card-hero { - background: linear-gradient(135deg, rgba(99,102,241,0.04) 0%, transparent 50%), var(--sdt-bg-elevated); - } - .stack-devtool .sdt-ov-user-row { display: flex; align-items: center; @@ -428,7 +501,7 @@ export const devToolCSS = ` display: flex; flex-wrap: wrap; gap: 6px; - margin-top: auto; + margin-top: 4px; } .stack-devtool .sdt-ov-btn { @@ -519,183 +592,9 @@ export const devToolCSS = ` .stack-devtool .sdt-ov-toast-success { background: var(--sdt-success-muted); color: var(--sdt-success); } .stack-devtool .sdt-ov-toast-error { background: var(--sdt-error-muted); color: var(--sdt-error); } - /* --- Project info card (stacked key-value rows) --- */ - .stack-devtool .sdt-ov-card-project { - } - - .stack-devtool .sdt-ov-project-rows { - display: flex; - flex-direction: column; - gap: 0; - flex: 1; - } - - .stack-devtool .sdt-ov-project-row { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; - padding: 8px 0; - border-bottom: 1px solid var(--sdt-border-subtle); - } - - .stack-devtool .sdt-ov-project-row:last-child { border-bottom: none; } - - .stack-devtool .sdt-ov-project-key { - font-size: 11px; - font-weight: 600; - color: var(--sdt-text-tertiary); - flex-shrink: 0; - text-transform: uppercase; - letter-spacing: 0.3px; - } - - .stack-devtool .sdt-ov-project-val { - font-size: 13px; - font-weight: 600; - color: var(--sdt-text); - text-align: right; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - display: flex; - align-items: center; - gap: 6px; - } - - .stack-devtool .sdt-ov-project-val-mono { - font-family: var(--sdt-font-mono); - font-size: 12px; - } - - .stack-devtool .sdt-ov-sdk-badge { - font-size: 9px; - font-weight: 700; - padding: 1px 5px; - border-radius: 4px; - background: var(--sdt-warning-muted); - color: var(--sdt-warning); - text-transform: uppercase; - letter-spacing: 0.3px; - flex-shrink: 0; - } - - .stack-devtool .sdt-ov-sdk-badge-error { - background: var(--sdt-error-muted); - color: var(--sdt-error); - } - - .stack-devtool .sdt-ov-env-val { - display: inline-flex; - align-items: center; - gap: 8px; - } - - .stack-devtool .sdt-ov-pulse-dot { - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--sdt-success); - flex-shrink: 0; - display: inline-block; - animation: sdt-ov-pulse 2s ease-in-out infinite; - } - - @keyframes sdt-ov-pulse { - 0%, 100% { box-shadow: 0 0 0 0 rgba(34,197,94,0.5); } - 50% { box-shadow: 0 0 0 5px rgba(34,197,94,0); } - } - - /* --- Setup checklist card --- */ - .stack-devtool .sdt-ov-card-checks { - padding: 12px 14px; - } - - .stack-devtool .sdt-ov-card-checks-ok { - border-color: rgba(34, 197, 94, 0.15); - } - - .stack-devtool .sdt-ov-checks-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-bottom: 8px; - } - - .stack-devtool .sdt-ov-checks-badge { - font-size: 10px; - font-weight: 700; - padding: 1px 6px; - border-radius: 4px; - } - - .stack-devtool .sdt-ov-checks-badge-ok { - background: var(--sdt-success-muted); - color: var(--sdt-success); - } - - .stack-devtool .sdt-ov-checks-badge-warn { - background: var(--sdt-warning-muted); - color: var(--sdt-warning); - } - - .stack-devtool .sdt-ov-checks-bar { - height: 3px; - border-radius: 2px; - background: var(--sdt-border-subtle); - margin-bottom: 10px; - overflow: hidden; - } - - .stack-devtool .sdt-ov-checks-bar-fill { - height: 100%; - border-radius: 2px; - background: var(--sdt-success); - transition: width 0.4s ease; - } - - .stack-devtool .sdt-ov-checks { - display: flex; - gap: 6px; - } - - .stack-devtool .sdt-ov-check { - display: flex; - align-items: center; - gap: 4px; - font-size: 11px; - font-weight: 600; - } - - .stack-devtool .sdt-ov-check-icon { - width: 16px; - height: 16px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 9px; - flex-shrink: 0; - } - - .stack-devtool .sdt-ov-check-ok .sdt-ov-check-icon { - background: var(--sdt-success-muted); - color: var(--sdt-success); - } - - .stack-devtool .sdt-ov-check-warn .sdt-ov-check-icon { - background: var(--sdt-warning-muted); - color: var(--sdt-warning); - } - - .stack-devtool .sdt-ov-check-ok .sdt-ov-check-label { color: var(--sdt-text); } - .stack-devtool .sdt-ov-check-warn .sdt-ov-check-label { color: var(--sdt-text-secondary); } - /* --- Auth methods card --- */ .stack-devtool .sdt-ov-card-auth { - padding: 12px 14px; + padding: 14px 16px; } .stack-devtool .sdt-ov-auth-grid { @@ -752,134 +651,83 @@ export const devToolCSS = ` 50% { opacity: 0.7; } } - /* --- Changelog card (span 2 cols) --- */ - .stack-devtool .sdt-ov-card-changelog { - grid-column: span 2; - } - - .stack-devtool .sdt-ov-changelog-content { - flex: 1; - min-height: 0; - overflow-y: auto; - } - - .stack-devtool .sdt-ov-changelog-content::-webkit-scrollbar { - width: 6px; - } - - .stack-devtool .sdt-ov-changelog-content::-webkit-scrollbar-track { - background: transparent; - } - - .stack-devtool .sdt-ov-changelog-content::-webkit-scrollbar-thumb { - background: var(--sdt-border); - border-radius: 3px; - } - - .stack-devtool .sdt-ov-changelog { - display: flex; - flex-direction: column; - gap: 0; - overflow-y: auto; - flex: 1; - min-height: 0; - padding-right: 4px; - } - - .stack-devtool .sdt-ov-release + .sdt-ov-release { - margin-top: 10px; - padding-top: 10px; - border-top: 1px dotted var(--sdt-border-subtle); + /* --- Setup checklist card (only shown when something is incomplete) --- */ + .stack-devtool .sdt-ov-card-checks { + padding: 14px 16px; + border-color: rgba(234, 179, 8, 0.25); } - .stack-devtool .sdt-ov-release-head { - font-size: 13px; - font-weight: 700; - color: var(--sdt-text); - margin-bottom: 5px; + .stack-devtool .sdt-ov-checks-header { display: flex; align-items: center; + justify-content: space-between; gap: 8px; + margin-bottom: 8px; } - .stack-devtool .sdt-ov-release-date { - font-size: 11px; - font-weight: 400; - color: var(--sdt-text-tertiary); - } - - .stack-devtool .sdt-ov-release-line { - display: flex; - align-items: flex-start; - gap: 6px; - font-size: 12px; - color: var(--sdt-text-secondary); - line-height: 1.5; - padding: 1px 0; + .stack-devtool .sdt-ov-checks-badge { + font-size: 10px; + font-weight: 700; + padding: 1px 6px; + border-radius: 4px; } - .stack-devtool .sdt-ov-release-text { - min-width: 0; + .stack-devtool .sdt-ov-checks-badge-ok { + background: var(--sdt-success-muted); + color: var(--sdt-success); } - .stack-devtool .sdt-ov-release-image-figure { - margin: 10px 0 6px; - display: flex; - flex-direction: column; - gap: 6px; + .stack-devtool .sdt-ov-checks-badge-warn { + background: var(--sdt-warning-muted); + color: var(--sdt-warning); } - .stack-devtool .sdt-ov-release-image-link { - display: block; - width: 45%; - max-width: 100%; + .stack-devtool .sdt-ov-checks-bar { + height: 3px; + border-radius: 2px; + background: var(--sdt-border-subtle); + margin-bottom: 10px; overflow: hidden; - border-radius: 10px; - border: 1px solid var(--sdt-border-subtle); - background: var(--sdt-bg-subtle); } - .stack-devtool .sdt-ov-release-image { - display: block; - width: 100%; - max-width: 100%; - height: auto; + .stack-devtool .sdt-ov-checks-bar-fill { + height: 100%; + border-radius: 2px; + background: var(--sdt-warning); + transition: width 0.4s ease; } - .stack-devtool .sdt-ov-release-image-caption { - font-size: 11px; - color: var(--sdt-text-tertiary); - line-height: 1.4; + .stack-devtool .sdt-ov-setup-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0; + font-size: 12px; + border-bottom: 1px solid var(--sdt-border-subtle); } - .stack-devtool .sdt-ov-tag { - font-size: 9px; - font-weight: 700; + .stack-devtool .sdt-ov-setup-row:last-child { border-bottom: none; } + + .stack-devtool .sdt-ov-setup-dot { + width: 7px; + height: 7px; + border-radius: 50%; flex-shrink: 0; - text-transform: uppercase; - letter-spacing: 0.3px; - padding: 1px 5px; - border-radius: 3px; - margin-top: 2px; } - .stack-devtool .sdt-ov-tag-feature { background: var(--sdt-accent-muted); color: var(--sdt-accent-hover); } - .stack-devtool .sdt-ov-tag-fix { background: var(--sdt-error-muted); color: var(--sdt-error); } - .stack-devtool .sdt-ov-tag-breaking { background: var(--sdt-error-muted); color: var(--sdt-error); } - .stack-devtool .sdt-ov-tag-improvement { background: var(--sdt-success-muted); color: var(--sdt-success); } - .stack-devtool .sdt-ov-all-releases { - display: inline-flex; - align-items: center; - gap: 4px; - margin-top: 10px; + .stack-devtool .sdt-ov-setup-dot-ok { background: var(--sdt-success); } + .stack-devtool .sdt-ov-setup-dot-warn { background: var(--sdt-warning); } + + .stack-devtool .sdt-ov-setup-label { + color: var(--sdt-text); + font-size: 12px; + } + + .stack-devtool .sdt-ov-setup-hint { + margin-left: auto; font-size: 11px; - font-weight: 600; color: var(--sdt-text-tertiary); - text-decoration: none; - font-family: var(--sdt-font); - transition: color 0.15s ease; } - .stack-devtool .sdt-ov-all-releases:hover { color: var(--sdt-accent); } /* Status badges (shared across tabs) */ .stack-devtool .sdt-badge { @@ -1019,9 +867,6 @@ export const devToolCSS = ` line-height: 1; } - .stack-devtool .sdt-pg-badge-handler { background: var(--sdt-info-muted); color: var(--sdt-info); } - .stack-devtool .sdt-pg-badge-hosted { background: var(--sdt-info-muted); color: var(--sdt-info); } - .stack-devtool .sdt-pg-badge-custom { background: var(--sdt-success-muted); color: var(--sdt-success); } .stack-devtool .sdt-pg-badge-outdated { background: var(--sdt-warning-muted); color: var(--sdt-warning); } /* --- Empty state --- */ @@ -1079,6 +924,7 @@ export const devToolCSS = ` display: flex; align-items: center; gap: 8px; + flex-wrap: wrap; } .stack-devtool .sdt-pg-title { @@ -1088,6 +934,22 @@ export const devToolCSS = ` color: var(--sdt-text); } + .stack-devtool .sdt-pg-title-url { + min-width: 0; + max-width: 280px; + color: var(--sdt-text-tertiary); + font-family: var(--sdt-font-mono); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-decoration: none; + } + + .stack-devtool .sdt-pg-title-url:hover { + color: var(--sdt-accent); + } + .stack-devtool .sdt-pg-subtitle { font-size: 12px; color: var(--sdt-text-secondary); @@ -1102,6 +964,8 @@ export const devToolCSS = ` } .stack-devtool .sdt-pg-code { + flex: 1; + min-width: 0; font-family: var(--sdt-font-mono); font-size: 12px; color: var(--sdt-accent); @@ -1111,36 +975,6 @@ export const devToolCSS = ` border: 1px solid var(--sdt-border-subtle); } - .stack-devtool .sdt-pg-url-row { - display: flex; - align-items: center; - gap: 8px; - } - - .stack-devtool .sdt-pg-url-label { - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.6px; - color: var(--sdt-text-tertiary); - flex-shrink: 0; - } - - .stack-devtool .sdt-pg-url { - font-family: var(--sdt-font-mono); - font-size: 11px; - color: var(--sdt-text-tertiary); - text-decoration: none; - transition: color 0.12s ease; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .stack-devtool .sdt-pg-url:hover { - color: var(--sdt-accent); - } - /* --- Copy button --- */ .stack-devtool .sdt-pg-copy-btn { height: 26px; @@ -1158,6 +992,20 @@ export const devToolCSS = ` white-space: nowrap; } + .stack-devtool .sdt-pg-open-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + height: 32px; + padding: 0 12px; + font-size: 12px; + } + + .stack-devtool .sdt-pg-open-btn svg { + flex-shrink: 0; + } + .stack-devtool .sdt-pg-copy-btn:hover { background: var(--sdt-bg-hover); color: var(--sdt-text); @@ -1233,11 +1081,9 @@ export const devToolCSS = ` } .stack-devtool .sdt-pg-section-label { - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.8px; - color: var(--sdt-text-tertiary); + font-size: 12px; + font-weight: 500; + color: var(--sdt-text-secondary); margin-bottom: 8px; } @@ -1344,20 +1190,63 @@ export const devToolCSS = ` color: var(--sdt-text-secondary); } - /* Iframe tabs (Docs, Dashboard) */ + /* Iframe tabs */ .stack-devtool .sdt-iframe-container { - height: calc(100% + 32px); - margin: -16px; + position: relative; + flex: 1; + min-height: 0; + width: 100%; + height: 100%; display: flex; flex-direction: column; } + .stack-devtool .sdt-iframe-toolbar { + position: absolute; + top: 8px; + right: 8px; + z-index: 1; + flex-shrink: 0; + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + padding: 0; + } + + .stack-devtool .sdt-panel-fullscreen .sdt-iframe-toolbar { + top: 60px; + right: 12px; + } + + .stack-devtool .sdt-iframe-open-link { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + background: var(--sdt-overlay-bg); + border: 1px solid var(--sdt-border); + border-radius: var(--sdt-radius-sm); + color: var(--sdt-accent-hover); + font-family: var(--sdt-font); + font-size: 12px; + font-weight: 500; + line-height: 1; + text-decoration: none; + } + + .stack-devtool .sdt-iframe-open-link:hover { + color: var(--sdt-text); + } + .stack-devtool .sdt-iframe-container iframe { flex: 1; + min-height: 0; width: 100%; + height: 100%; border: none; background: white; - border-radius: 0 0 var(--sdt-radius-lg) var(--sdt-radius-lg); + border-radius: 0; } .stack-devtool .sdt-iframe-loading { @@ -1403,6 +1292,72 @@ export const devToolCSS = ` } /* Console tab */ + .stack-devtool .sdt-console-panel { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + } + + .stack-devtool .sdt-console-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + flex-shrink: 0; + } + + .stack-devtool .sdt-console-title { + color: var(--sdt-text); + font-size: 13px; + font-weight: 600; + } + + .stack-devtool .sdt-console-actions { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; + } + + .stack-devtool .sdt-console-action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + height: 28px; + padding: 0 9px; + background: var(--sdt-bg-elevated); + border: 1px solid var(--sdt-border); + border-radius: var(--sdt-radius-sm); + color: var(--sdt-text-secondary); + cursor: pointer; + font-family: var(--sdt-font); + font-size: 12px; + font-weight: 500; + line-height: 1; + transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease; + white-space: nowrap; + } + + .stack-devtool .sdt-console-action-btn:hover { + color: var(--sdt-text); + background: var(--sdt-bg-hover); + border-color: var(--sdt-border); + } + + .stack-devtool .sdt-console-action-btn svg { + flex-shrink: 0; + } + + .stack-devtool .sdt-console-log-scroll { + flex: 1; + min-height: 0; + overflow: auto; + } + .stack-devtool .sdt-console-tabs { position: relative; display: flex; @@ -1456,6 +1411,14 @@ export const devToolCSS = ` gap: 4px; } + .stack-devtool .sdt-log-load-hint { + padding: 8px 10px; + color: var(--sdt-text-tertiary); + font-family: var(--sdt-font); + font-size: 12px; + text-align: center; + } + .stack-devtool .sdt-log-item { display: flex; align-items: flex-start; @@ -1701,45 +1664,12 @@ export const devToolCSS = ` margin: -16px; } - .stack-devtool .sdt-support-tab > .sdt-console-tabs { - margin: 12px 12px 0; - flex: none; - } - - .stack-devtool .sdt-support-content { - flex: 1; - min-height: 0; - position: relative; - } - - .stack-devtool .sdt-support-pane { - position: absolute; - inset: 0; - visibility: hidden; - pointer-events: none; - } - - .stack-devtool .sdt-tab-pane-active .sdt-support-pane-active { - visibility: visible; - pointer-events: auto; - animation: sdt-tab-fade-in 0.15s ease-out; - } - .stack-devtool .sdt-support-feedback-pane { padding: 20px; height: 100%; overflow-y: auto; } - .stack-devtool .sdt-support-iframe-pane { - height: 100%; - } - - .stack-devtool .sdt-support-iframe-pane .sdt-iframe-container { - height: 100%; - margin: 0; - } - /* Form layout */ .stack-devtool .sdt-support-form { display: flex; @@ -2025,6 +1955,7 @@ export const devToolCSS = ` --sdt-error-muted: rgba(220, 38, 38, 0.1); --sdt-info: #2563eb; --sdt-info-muted: rgba(37, 99, 235, 0.1); + --sdt-overlay-bg: rgba(255, 255, 255, 0.92); --sdt-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.06); --sdt-trigger-shadow: 0 4px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.06); } @@ -2789,6 +2720,7 @@ export const devToolCSS = ` --sdt-error-muted: rgba(220, 38, 38, 0.1); --sdt-info: #2563eb; --sdt-info-muted: rgba(37, 99, 235, 0.1); + --sdt-overlay-bg: rgba(255, 255, 255, 0.92); --sdt-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.06); --sdt-trigger-shadow: 0 4px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.06); } @@ -2815,6 +2747,7 @@ export const devToolCSS = ` --sdt-error-muted: rgba(239, 68, 68, 0.15); --sdt-info: #3b82f6; --sdt-info-muted: rgba(59, 130, 246, 0.15); + --sdt-overlay-bg: rgba(17, 17, 19, 0.92); --sdt-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05); --sdt-trigger-shadow: 0 4px 12px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.08); } diff --git a/packages/template/src/dev-tool/dev-tool-trigger-position.test.ts b/packages/template/src/dev-tool/dev-tool-trigger-position.test.ts index 8487d82e82..601935fe99 100644 --- a/packages/template/src/dev-tool/dev-tool-trigger-position.test.ts +++ b/packages/template/src/dev-tool/dev-tool-trigger-position.test.ts @@ -1,36 +1,109 @@ import { describe, expect, it } from "vitest"; -import { getSnappedTriggerPlacement, resolveTriggerPosition } from "./dev-tool-trigger-position"; +import { clampTriggerPosition, getSnappedTriggerPlacement, resolveTriggerPosition } from "./dev-tool-trigger-position"; -const triggerSize = { width: 76, height: 36 }; +const triggerSize = { width: 36, height: 36 }; const viewport = { width: 1000, height: 700 }; -describe("dev tool trigger snapping", () => { - it("snaps to the nearest vertical side", () => { - const placement = getSnappedTriggerPlacement({ left: 940, top: 250 }, triggerSize, viewport); +describe("corner snapping", () => { + it("snaps to bottom-right when trigger is in the bottom-right quadrant", () => { + const placement = getSnappedTriggerPlacement({ left: 800, top: 600 }, triggerSize, viewport); + expect(placement).toEqual({ corner: "bottom-right" }); + }); + + it("snaps to top-left when trigger is in the top-left quadrant", () => { + const placement = getSnappedTriggerPlacement({ left: 10, top: 20 }, triggerSize, viewport); + expect(placement).toEqual({ corner: "top-left" }); + }); + + it("snaps to top-right when trigger is in the top-right quadrant", () => { + const placement = getSnappedTriggerPlacement({ left: 900, top: 50 }, triggerSize, viewport); + expect(placement).toEqual({ corner: "top-right" }); + }); + + it("snaps to bottom-left when trigger is in the bottom-left quadrant", () => { + const placement = getSnappedTriggerPlacement({ left: 50, top: 650 }, triggerSize, viewport); + expect(placement).toEqual({ corner: "bottom-left" }); + }); +}); + +describe("corner position resolution", () => { + it("resolves bottom-right to margin from bottom and right edges", () => { + const pos = resolveTriggerPosition({ corner: "bottom-right" }, triggerSize, viewport); + expect(pos).toEqual({ left: 1000 - 36 - 16, top: 700 - 36 - 16 }); + }); + + it("resolves top-left to margin from top and left edges", () => { + const pos = resolveTriggerPosition({ corner: "top-left" }, triggerSize, viewport); + expect(pos).toEqual({ left: 16, top: 16 }); + }); - expect(placement).toEqual({ side: "right", offset: 250 }); - expect(resolveTriggerPosition(placement, triggerSize, viewport)).toEqual({ left: 908, top: 250 }); + it("resolves top-right to margin from top and right edges", () => { + const pos = resolveTriggerPosition({ corner: "top-right" }, triggerSize, viewport); + expect(pos).toEqual({ left: 1000 - 36 - 16, top: 16 }); }); - it("snaps to the nearest horizontal side", () => { - const placement = getSnappedTriggerPlacement({ left: 320, top: 10 }, triggerSize, viewport); + it("resolves bottom-left to margin from bottom and left edges", () => { + const pos = resolveTriggerPosition({ corner: "bottom-left" }, triggerSize, viewport); + expect(pos).toEqual({ left: 16, top: 700 - 36 - 16 }); + }); + + it("has equal left/top margin for top-left corner", () => { + const pos = resolveTriggerPosition({ corner: "top-left" }, triggerSize, viewport); + expect(pos.left).toBe(pos.top); + }); + + it("keeps the trigger on-screen when the viewport is smaller than the margin and trigger", () => { + const tinyViewport = { width: 40, height: 40 }; + const pos = resolveTriggerPosition({ corner: "bottom-right" }, triggerSize, tinyViewport); + expect(pos).toEqual({ left: 4, top: 4 }); + }); +}); - expect(placement).toEqual({ side: "top", offset: 320 }); - expect(resolveTriggerPosition(placement, triggerSize, viewport)).toEqual({ left: 320, top: 16 }); +describe("resize anchor regression", () => { + it("bottom-right corner tracks the bottom-right edge after viewport shrinks", () => { + const placement = { corner: "bottom-right" } as const; + const pos1 = resolveTriggerPosition(placement, triggerSize, { width: 800, height: 600 }); + expect(pos1).toEqual({ left: 800 - 36 - 16, top: 600 - 36 - 16 }); }); - it("prefers a side over corner docking when the position lands in a corner", () => { - const placement = getSnappedTriggerPlacement({ left: 924, top: 664 }, triggerSize, viewport); + it("bottom-right corner tracks the bottom-right edge after viewport grows", () => { + const placement = { corner: "bottom-right" } as const; + const pos2 = resolveTriggerPosition(placement, triggerSize, { width: 1440, height: 900 }); + expect(pos2).toEqual({ left: 1440 - 36 - 16, top: 900 - 36 - 16 }); + }); - expect(placement).toEqual({ side: "right", offset: 664 }); + it("top-right corner tracks the top-right edge across resize cycles", () => { + const placement = { corner: "top-right" } as const; + for (const vw of [600, 800, 1000, 1440]) { + const pos = resolveTriggerPosition(placement, triggerSize, { width: vw, height: 700 }); + expect(pos.left).toBe(vw - 36 - 16); + expect(pos.top).toBe(16); + } }); - it("keeps the trigger attached to the same side across viewport changes", () => { - const placement = { side: "right", offset: 664 } as const; + it("corner does not change on resize (placement is stable)", () => { + // The same corner placement always resolves without changing its corner. + const placement = getSnappedTriggerPlacement({ left: 950, top: 650 }, triggerSize, viewport); + expect(placement.corner).toBe("bottom-right"); + + // After resize, applying resolveTriggerPosition still produces bottom-right geometry. + const smallVp = resolveTriggerPosition(placement, triggerSize, { width: 400, height: 300 }); + expect(smallVp.left).toBe(400 - 36 - 16); + expect(smallVp.top).toBe(300 - 36 - 16); + }); +}); - expect(resolveTriggerPosition(placement, triggerSize, { width: 800, height: 500 })).toEqual({ - left: 708, - top: 464, +describe("clampTriggerPosition", () => { + it("clamps positions outside the viewport", () => { + expect(clampTriggerPosition({ left: -50, top: -20 }, triggerSize, viewport)).toEqual({ left: 0, top: 0 }); + expect(clampTriggerPosition({ left: 9999, top: 9999 }, triggerSize, viewport)).toEqual({ + left: viewport.width - triggerSize.width, + top: viewport.height - triggerSize.height, }); }); + + it("preserves positions already within bounds", () => { + const pos = { left: 200, top: 300 }; + expect(clampTriggerPosition(pos, triggerSize, viewport)).toEqual(pos); + }); }); diff --git a/packages/template/src/dev-tool/dev-tool-trigger-position.ts b/packages/template/src/dev-tool/dev-tool-trigger-position.ts index 72fec7ab46..52f5f8773a 100644 --- a/packages/template/src/dev-tool/dev-tool-trigger-position.ts +++ b/packages/template/src/dev-tool/dev-tool-trigger-position.ts @@ -1,15 +1,14 @@ -export type TriggerSide = 'left' | 'right' | 'top' | 'bottom'; +export type TriggerCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + +export type TriggerPlacement = { + corner: TriggerCorner; +}; export type TriggerPosition = { left: number; top: number; }; -export type TriggerPlacement = { - side: TriggerSide; - offset: number; -}; - export type TriggerSize = { width: number; height: number; @@ -22,121 +21,85 @@ export type TriggerViewport = { export const TRIGGER_EDGE_MARGIN = 16; -function clamp(value: number, min: number, max: number) { - return Math.max(min, Math.min(value, max)); -} - -function getBounds( +function getSnapBounds( triggerSize: TriggerSize, viewport: TriggerViewport, - edgeMargin: number, ) { const maxLeft = Math.max(0, viewport.width - triggerSize.width); const maxTop = Math.max(0, viewport.height - triggerSize.height); - const minSnapLeft = Math.min(edgeMargin, maxLeft); - const maxSnapLeft = Math.max(minSnapLeft, maxLeft - edgeMargin); - const minSnapTop = Math.min(edgeMargin, maxTop); - const maxSnapTop = Math.max(minSnapTop, maxTop - edgeMargin); - + const minLeft = Math.min(TRIGGER_EDGE_MARGIN, maxLeft); + const minTop = Math.min(TRIGGER_EDGE_MARGIN, maxTop); return { - maxLeft, - maxTop, - minSnapLeft, - maxSnapLeft, - minSnapTop, - maxSnapTop, + minLeft, + maxLeft: Math.max(minLeft, maxLeft - TRIGGER_EDGE_MARGIN), + minTop, + maxTop: Math.max(minTop, maxTop - TRIGGER_EDGE_MARGIN), }; } +/** + * Clamps a position so the trigger stays fully within the viewport. + * Used during drag to prevent the pill from leaving the screen. + */ export function clampTriggerPosition( position: TriggerPosition, triggerSize: TriggerSize, viewport: TriggerViewport, ): TriggerPosition { - const { maxLeft, maxTop } = getBounds(triggerSize, viewport, TRIGGER_EDGE_MARGIN); - + const maxLeft = Math.max(0, viewport.width - triggerSize.width); + const maxTop = Math.max(0, viewport.height - triggerSize.height); return { - left: clamp(position.left, 0, maxLeft), - top: clamp(position.top, 0, maxTop), + left: Math.max(0, Math.min(position.left, maxLeft)), + top: Math.max(0, Math.min(position.top, maxTop)), }; } -export function getSnappedTriggerPlacement( - position: TriggerPosition, +/** + * Returns the exact pixel position for a corner placement. + * The trigger is always `TRIGGER_EDGE_MARGIN` px from both adjacent edges. + */ +export function resolveTriggerPosition( + placement: TriggerPlacement, triggerSize: TriggerSize, viewport: TriggerViewport, -): TriggerPlacement { - const clamped = clampTriggerPosition(position, triggerSize, viewport); - const bounds = getBounds(triggerSize, viewport, TRIGGER_EDGE_MARGIN); - - const candidates: TriggerPlacement[] = [ - { side: 'left', offset: clamped.top }, - { side: 'right', offset: clamped.top }, - { side: 'top', offset: clamped.left }, - { side: 'bottom', offset: clamped.left }, - ]; - - function getDistance(placement: TriggerPlacement) { - switch (placement.side) { - case 'left': { - return Math.abs(clamped.left - bounds.minSnapLeft); +): TriggerPosition { + const bounds = getSnapBounds(triggerSize, viewport); + const position = (() => { + switch (placement.corner) { + case 'top-left': { + return { left: bounds.minLeft, top: bounds.minTop }; } - case 'right': { - return Math.abs(clamped.left - bounds.maxSnapLeft); + case 'top-right': { + return { left: bounds.maxLeft, top: bounds.minTop }; } - case 'top': { - return Math.abs(clamped.top - bounds.minSnapTop); + case 'bottom-left': { + return { left: bounds.minLeft, top: bounds.maxTop }; } - case 'bottom': { - return Math.abs(clamped.top - bounds.maxSnapTop); + case 'bottom-right': { + return { left: bounds.maxLeft, top: bounds.maxTop }; } } - } - - let nearest = candidates[0]; - let nearestDistance = getDistance(nearest); - for (const candidate of candidates.slice(1)) { - const distance = getDistance(candidate); - if (distance < nearestDistance) { - nearest = candidate; - nearestDistance = distance; - } - } + })(); - return nearest; + return clampTriggerPosition(position, triggerSize, viewport); } -export function resolveTriggerPosition( - placement: TriggerPlacement, +/** + * Snaps a free position to the nearest corner by checking which viewport + * quadrant the trigger center falls in. + */ +export function getSnappedTriggerPlacement( + position: TriggerPosition, triggerSize: TriggerSize, viewport: TriggerViewport, -): TriggerPosition { - const bounds = getBounds(triggerSize, viewport, TRIGGER_EDGE_MARGIN); +): TriggerPlacement { + const cx = position.left + triggerSize.width / 2; + const cy = position.top + triggerSize.height / 2; - switch (placement.side) { - case 'left': { - return { - left: bounds.minSnapLeft, - top: clamp(placement.offset, 0, bounds.maxTop), - }; - } - case 'right': { - return { - left: bounds.maxSnapLeft, - top: clamp(placement.offset, 0, bounds.maxTop), - }; - } - case 'top': { - return { - left: clamp(placement.offset, 0, bounds.maxLeft), - top: bounds.minSnapTop, - }; - } - case 'bottom': { - return { - left: clamp(placement.offset, 0, bounds.maxLeft), - top: bounds.maxSnapTop, - }; - } - } + const corner: TriggerCorner = + cy < viewport.height / 2 + ? cx < viewport.width / 2 ? 'top-left' : 'top-right' + : cx < viewport.width / 2 ? 'bottom-left' : 'bottom-right'; + + return { corner }; } diff --git a/packages/template/src/dev-tool/index.ts b/packages/template/src/dev-tool/index.ts index ad3d97bdca..26c1a0efa2 100644 --- a/packages/template/src/dev-tool/index.ts +++ b/packages/template/src/dev-tool/index.ts @@ -67,11 +67,16 @@ export function mountDevTool(app: StackClientApp): () => void { activeApp = app; tryMount(); + // Capture the cleanup created by THIS specific mount call so that React + // StrictMode's double-invoke doesn't let the first effect's cleanup tear + // down the second mount (which would cause the tool to disappear silently). + const myCleanup = activeCleanup; + return () => { activeApp = null; - if (activeCleanup) { - activeCleanup(); + if (activeCleanup === myCleanup && myCleanup != null) { activeCleanup = null; + myCleanup(); } }; } diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index e743fd2d39..c856ea38e3 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -586,7 +586,7 @@ export class _StackClientAppImplIncomplete, + /** + * Whether to show the Stack Auth dev tool indicator in browser-like development environments. + * + * Defaults to true. + */ + devTool?: boolean, + /** * By default, the Stack app will automatically prefetch some data from Stack's server when this app is first * constructed. This improves the performance of your app, but will create network requests that are unnecessary if