diff --git a/src/__tests__/cliUnit.test.ts b/src/__tests__/cliUnit.test.ts index 5a0a234e..3af9d70f 100644 --- a/src/__tests__/cliUnit.test.ts +++ b/src/__tests__/cliUnit.test.ts @@ -105,10 +105,10 @@ describe("formatNetworkError", () => { it("formats PaymentRequiredError with billing instructions", () => { const err = new PaymentRequiredError("Free tier exhausted", "billing_quota_exceeded"); const result = formatNetworkError(err, BASE_URL); - expect(result).toContain("Payment required"); + expect(result).toContain("Quota reached"); expect(result).toContain("npx deepcitation billing"); expect(result).toContain(`${BASE_URL}/billing`); - expect(result).toContain("$0.05/doc"); + expect(result).toContain("Standard"); }); it("formats ENOTFOUND without proxy env as network hint", () => { @@ -198,8 +198,8 @@ describe("formatNetworkError", () => { const err = new PaymentRequiredError("Free tier exhausted", "billing_quota_exceeded"); const result = formatNetworkError(err, "https://deepcitation.com"); expect(result).toContain("npx deepcitation billing"); - expect(result).toContain("Pay-as-you-go"); - expect(result).toContain("spend cap"); + expect(result).toContain("Standard"); + expect(result).toContain("Pro"); }); it("proxy hint differs based on HTTP_PROXY (not just HTTPS_PROXY)", () => { diff --git a/src/cli/cliUtils.ts b/src/cli/cliUtils.ts index 54ac38b2..80c2dbe6 100644 --- a/src/cli/cliUtils.ts +++ b/src/cli/cliUtils.ts @@ -95,16 +95,15 @@ export function formatNetworkError(err: unknown, baseUrl: string): string { } if (err instanceof PaymentRequiredError) { return [ - `\nPayment required: ${sanitizeForLog(err.message)}`, + `\nQuota reached: ${sanitizeForLog(err.message)}`, ``, - ` To add a credit card and unlock usage beyond the free tier:`, - ` npx deepcitation billing`, - ` Or visit: ${baseUrl}/billing`, + ` Free tier includes 1 verification per 7-day trial window.`, + ` Upgrade to continue:`, + ` • Standard — 20 verifications/week ($20/mo)`, + ` • Pro — Unlimited verifications ($200/mo)`, ``, - ` Benefits of adding a card:`, - ` • Continue using DeepCitation without interruption`, - ` • Pay-as-you-go: $0.05/doc, $0.01/verification`, - ` • Set a custom monthly spend cap for cost control`, + ` Upgrade at: ${baseUrl}/billing`, + ` npx deepcitation billing`, ].join("\n"); } const msg = err instanceof Error ? err.message : String(err); diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 7d2519a2..7ea53621 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -1652,9 +1652,9 @@ export function status() { export async function openBillingDashboard(billingUrl: string) { console.error(`Opening billing dashboard: ${billingUrl}`); console.error(`\nHere you can:`); - console.error(` • Add a credit card to unlock usage beyond the free tier`); - console.error(` • Set a custom monthly spend cap for cost control`); - console.error(` • View your usage breakdown and billing history`); + console.error(` • Upgrade to Standard (20/week) or Pro (unlimited)`); + console.error(` • View your usage and subscription status`); + console.error(` • Manage your subscription or cancel anytime`); if (!process.env.DC_NO_BROWSER) await openBrowser(billingUrl); } diff --git a/src/vanilla/runtime/cdn.ts b/src/vanilla/runtime/cdn.ts index b0d5dab0..0911e8ac 100644 --- a/src/vanilla/runtime/cdn.ts +++ b/src/vanilla/runtime/cdn.ts @@ -1,10 +1,23 @@ -import { createElement, useCallback, useRef, useState } from "react"; +import { createElement, useCallback, useLayoutEffect, useRef, useState } from "react"; import { render, unmountComponentAtNode } from "react-dom"; import { CitationDrawer } from "../../react/CitationDrawer.js"; import type { CitationDrawerItem, SourceCitationGroup } from "../../react/CitationDrawer.types.js"; import { groupCitationsBySource } from "../../react/CitationDrawer.utils.js"; import { CitationDrawerTrigger } from "../../react/CitationDrawerTrigger.js"; import { getStatusFromVerification } from "../../react/citationStatus.js"; +import { + BLINK_ENTER_EASING, + BLINK_ENTER_OPACITY_A, + BLINK_ENTER_OPACITY_B, + BLINK_ENTER_SCALE_A, + BLINK_ENTER_SCALE_B, + BLINK_ENTER_STEP_MS, + BLINK_ENTER_TOTAL_MS, + BLINK_EXIT_EASING, + BLINK_EXIT_OPACITY, + BLINK_EXIT_SCALE, + BLINK_EXIT_TOTAL_MS, +} from "../../react/constants.js"; import { DefaultPopoverContent } from "../../react/DefaultPopoverContent.js"; import { usePopoverViewState } from "../../react/hooks/usePopoverViewState.js"; import { usePrefersReducedMotion } from "../../react/hooks/usePrefersReducedMotion.js"; @@ -40,12 +53,11 @@ const SIDE_OFFSET = 8; // ── Scroll passthrough helpers — imported from shared/scroll.ts ────────── -// ── Blink animation constants ───────────────────────────────────────────── - -const BLINK_ENTER_DURATION_MS = 180; -const BLINK_ENTER_EASING = "cubic-bezier(0.16, 1, 0.3, 1)"; -const BLINK_EXIT_DURATION_MS = 120; -const BLINK_EXIT_EASING = "cubic-bezier(0.4, 0, 1, 1)"; +// ── Blink animation constants — imported from constants.ts, NOT redefined here. +// CDN and React must use identical timing/easing; see animation-transition-rules.md. +// BLINK_ENTER_STEP_MS = 60ms (enter-b settle), BLINK_ENTER_TOTAL_MS = 120ms (full enter) +const BLINK_SETTLE_MS = Math.max(16, Math.min(BLINK_ENTER_STEP_MS, BLINK_ENTER_TOTAL_MS)); +const BLINK_FINAL_MS = Math.max(16, BLINK_ENTER_TOTAL_MS - BLINK_ENTER_STEP_MS); // ── Types & globals ─────────────────────────────────────────────────────── @@ -94,6 +106,11 @@ let radixVPPositionCleanup: (() => void) | null = null; let coastRafId: number | null = null; const boundTriggers = new WeakSet(); +// ── Blink animation state ───────────────────────────────────────────────── +let blinkRafId: number | null = null; +let blinkSettleTimer: ReturnType | null = null; +let blinkFinalTimer: ReturnType | null = null; + // ── Drawer state ───────────────────────────────────────────────────── let drawerContainerEl: HTMLDivElement | null = null; const drawerTriggerEls = new Set(); @@ -190,6 +207,17 @@ function CdnPopoverWrapper(props: { onDismiss: props.onDismiss, }); + // Synchronously reposition the CDN wrapper when view state changes. + // This fires inside flushSync (before it returns), so page-expand/collapse ghost + // transitions read correct target rects — the wrapper is already at the new + // position before waitForPage*Target polls for the first stable rect. + // Without this, isViewTransitioning()=true defers CDN reposition until after + // the ghost animation, causing it to target the wrong (stale) position. + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — reposition on view state change only + useLayoutEffect(() => { + reposition(); + }, [viewState.current]); + // Convert downloadUrl string to DownloadInfo object expected by DefaultPopoverContent. // Only construct `download` when the URL passes sanitizeUrl (http/https only) — otherwise // the popover will render the download button against an invalid/missing URL and clicks @@ -467,19 +495,58 @@ function teardownScrollPassthrough(): void { } // ── Blink animation helpers ─────────────────────────────────────────────── +// Imperative equivalent of useBlinkMotionStage + getBlinkContainerMotionStyle. +// Three-stage enter: enter-a (instant) → enter-b (BLINK_SETTLE_MS) → steady (BLINK_FINAL_MS). +// All timing/easing values imported from constants.ts — do not redefine locally. + +function blinkTransition(durationMs: number, easing: string): string { + return `opacity ${durationMs}ms ${easing}, transform ${durationMs}ms ${easing}`; +} + +function cancelBlink(): void { + if (blinkRafId !== null) { + cancelAnimationFrame(blinkRafId); + blinkRafId = null; + } + if (blinkSettleTimer !== null) { + clearTimeout(blinkSettleTimer); + blinkSettleTimer = null; + } + if (blinkFinalTimer !== null) { + clearTimeout(blinkFinalTimer); + blinkFinalTimer = null; + } +} function animateOpen(): void { if (!contentEl || prefersReducedMotion) return; - // Start state: slightly scaled down + transparent - contentEl.style.opacity = "0"; - contentEl.style.transform = "translateY(4px) scale(0.96)"; + cancelBlink(); + // enter-a: instant (no transition) — commit start opacity/scale before paint contentEl.style.transition = "none"; - // Force reflow so the start state is committed before the transition + contentEl.style.opacity = String(BLINK_ENTER_OPACITY_A); + contentEl.style.transform = `translate3d(0, 0, 0) scale(${BLINK_ENTER_SCALE_A})`; + contentEl.style.willChange = "transform, opacity"; + // Force reflow so enter-a is painted before transition starts contentEl.offsetHeight; // eslint-disable-line @typescript-eslint/no-unused-expressions - // End state: fully visible - contentEl.style.transition = `opacity ${BLINK_ENTER_DURATION_MS}ms ${BLINK_ENTER_EASING}, transform ${BLINK_ENTER_DURATION_MS}ms ${BLINK_ENTER_EASING}`; - contentEl.style.opacity = "1"; - contentEl.style.transform = "translateY(0) scale(1)"; + // enter-b: BLINK_SETTLE_MS settle toward near-final values + blinkRafId = requestAnimationFrame(() => { + if (!contentEl) return; + contentEl.style.transition = blinkTransition(BLINK_SETTLE_MS, BLINK_ENTER_EASING); + contentEl.style.opacity = String(BLINK_ENTER_OPACITY_B); + contentEl.style.transform = `translate3d(0, 0, 0) scale(${BLINK_ENTER_SCALE_B})`; + // steady: BLINK_FINAL_MS settle to fully visible + blinkSettleTimer = setTimeout(() => { + if (!contentEl) return; + contentEl.style.transition = blinkTransition(BLINK_FINAL_MS, BLINK_ENTER_EASING); + contentEl.style.opacity = "1"; + contentEl.style.transform = "translate3d(0, 0, 0) scale(1)"; + blinkSettleTimer = null; + blinkFinalTimer = setTimeout(() => { + if (contentEl) contentEl.style.willChange = ""; + blinkFinalTimer = null; + }, BLINK_FINAL_MS); + }, BLINK_SETTLE_MS); + }); } function animateClose(onDone: () => void): void { @@ -487,10 +554,14 @@ function animateClose(onDone: () => void): void { onDone(); return; } - contentEl.style.transition = `opacity ${BLINK_EXIT_DURATION_MS}ms ${BLINK_EXIT_EASING}, transform ${BLINK_EXIT_DURATION_MS}ms ${BLINK_EXIT_EASING}`; - contentEl.style.opacity = "0"; - contentEl.style.transform = "translateY(4px) scale(0.98)"; - setTimeout(onDone, BLINK_EXIT_DURATION_MS); + cancelBlink(); + contentEl.style.transition = blinkTransition(BLINK_EXIT_TOTAL_MS, BLINK_EXIT_EASING); + contentEl.style.opacity = String(BLINK_EXIT_OPACITY); + contentEl.style.transform = `translate3d(0, 0, 0) scale(${BLINK_EXIT_SCALE})`; + blinkFinalTimer = setTimeout(() => { + onDone(); + blinkFinalTimer = null; + }, BLINK_EXIT_TOTAL_MS); } // ── Show / Hide ─────────────────────────────────────────────────────────── @@ -577,6 +648,7 @@ function showPopoverFor(trigger: HTMLElement, data: VerificationData): void { } function hidePopoverCleanup(): void { + cancelBlink(); stopPositionTracking(); teardownScrollPassthrough(); if (wrapperEl) { @@ -588,6 +660,7 @@ function hidePopoverCleanup(): void { contentEl.style.transition = "none"; contentEl.style.opacity = ""; contentEl.style.transform = ""; + contentEl.style.willChange = ""; } radixVPPositionCleanup?.(); radixVPPositionCleanup = null;