From b1dcb561325eb764fbf3b360a4a3d00050683f44 Mon Sep 17 00:00:00 2001 From: Benson Date: Tue, 14 Apr 2026 17:21:19 -0600 Subject: [PATCH 1/5] feat(cli): update billing copy to reflect subscription tiers Replace pay-as-you-go pricing copy with subscription tier messaging (Standard 20/week at $20/mo, Pro unlimited at $200/mo) in both the quota-reached error and the billing dashboard description. Co-Authored-By: Claude Sonnet 4.6 --- src/cli/cliUtils.ts | 15 +++++++-------- src/cli/commands.ts | 6 +++--- 2 files changed, 10 insertions(+), 11 deletions(-) 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); } From 1bb37d8ad2eb1186c165a06d6833874f38dc9397 Mon Sep 17 00:00:00 2001 From: Benson Date: Tue, 14 Apr 2026 17:21:25 -0600 Subject: [PATCH 2/5] feat(cdn): sync blink animation with React constants, add three-stage enter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Import BLINK_* constants from constants.ts instead of redefining locally; CDN and React now share identical timing/easing values. - Implement three-stage enter (enter-a instant → enter-b settle → steady) matching useBlinkMotionStage, using translate3d + will-change for GPU compositor hints. - Add cancelBlink() to guard against overlapping open/close sequences. - Call cancelBlink() in hidePopoverCleanup() to prevent orphaned timers. Co-Authored-By: Claude Sonnet 4.6 --- src/vanilla/runtime/cdn.ts | 84 ++++++++++++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/src/vanilla/runtime/cdn.ts b/src/vanilla/runtime/cdn.ts index b0d5dab0..79e1420e 100644 --- a/src/vanilla/runtime/cdn.ts +++ b/src/vanilla/runtime/cdn.ts @@ -13,6 +13,19 @@ import { isViewTransitioning } from "../../react/viewTransition.js"; import { canChildScrollVertically, findPageScrollEl } from "../../shared/scroll.js"; import type { Citation } from "../../types/citation.js"; import type { PageImage, Verification } from "../../types/verification.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 { resolveKeyMap } from "./cdn-keymap.js"; import { mapToCitation, mapToVerification } from "./cdn-mappers.js"; import { computePosition } from "./positioning.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 = 0; +let blinkSettleTimer: ReturnType | null = null; +let blinkFinalTimer: ReturnType | null = null; + // ── Drawer state ───────────────────────────────────────────────────── let drawerContainerEl: HTMLDivElement | null = null; const drawerTriggerEls = new Set(); @@ -467,19 +484,45 @@ 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 cancelBlink(): void { + cancelAnimationFrame(blinkRafId); + 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 = `opacity ${BLINK_SETTLE_MS}ms ${BLINK_ENTER_EASING}, transform ${BLINK_SETTLE_MS}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 = `opacity ${BLINK_FINAL_MS}ms ${BLINK_ENTER_EASING}, transform ${BLINK_FINAL_MS}ms ${BLINK_ENTER_EASING}`; + contentEl.style.opacity = "1"; + contentEl.style.transform = "translate3d(0, 0, 0) scale(1)"; + blinkFinalTimer = setTimeout(() => { + if (contentEl) contentEl.style.willChange = ""; + blinkFinalTimer = null; + }, BLINK_FINAL_MS); + blinkSettleTimer = null; + }, BLINK_SETTLE_MS); + }); } function animateClose(onDone: () => void): void { @@ -487,10 +530,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 = `opacity ${BLINK_EXIT_TOTAL_MS}ms ${BLINK_EXIT_EASING}, transform ${BLINK_EXIT_TOTAL_MS}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 +624,7 @@ function showPopoverFor(trigger: HTMLElement, data: VerificationData): void { } function hidePopoverCleanup(): void { + cancelBlink(); stopPositionTracking(); teardownScrollPassthrough(); if (wrapperEl) { From 180c3f37ffb5af346d376575d210fabd91b779a1 Mon Sep 17 00:00:00 2001 From: Benson Date: Tue, 14 Apr 2026 17:29:47 -0600 Subject: [PATCH 3/5] refactor(cdn): simplify blink animation helpers - Extract blinkTransition() to eliminate 3 duplicate template-literal transition strings in animateOpen/animateClose. - Type blinkRafId as number | null for consistency with settle/final timers. - Clear willChange in hidePopoverCleanup() for complete style reset. - Null blinkRafId in cancelBlink() after cancelling (mirrors timer pattern). Co-Authored-By: Claude Sonnet 4.6 --- src/vanilla/runtime/cdn.ts | 46 +++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/src/vanilla/runtime/cdn.ts b/src/vanilla/runtime/cdn.ts index 79e1420e..46bb8dc6 100644 --- a/src/vanilla/runtime/cdn.ts +++ b/src/vanilla/runtime/cdn.ts @@ -5,14 +5,6 @@ import type { CitationDrawerItem, SourceCitationGroup } from "../../react/Citati import { groupCitationsBySource } from "../../react/CitationDrawer.utils.js"; import { CitationDrawerTrigger } from "../../react/CitationDrawerTrigger.js"; import { getStatusFromVerification } from "../../react/citationStatus.js"; -import { DefaultPopoverContent } from "../../react/DefaultPopoverContent.js"; -import { usePopoverViewState } from "../../react/hooks/usePopoverViewState.js"; -import { usePrefersReducedMotion } from "../../react/hooks/usePrefersReducedMotion.js"; -import { sanitizeUrl } from "../../react/urlUtils.js"; -import { isViewTransitioning } from "../../react/viewTransition.js"; -import { canChildScrollVertically, findPageScrollEl } from "../../shared/scroll.js"; -import type { Citation } from "../../types/citation.js"; -import type { PageImage, Verification } from "../../types/verification.js"; import { BLINK_ENTER_EASING, BLINK_ENTER_OPACITY_A, @@ -26,6 +18,14 @@ import { 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"; +import { sanitizeUrl } from "../../react/urlUtils.js"; +import { isViewTransitioning } from "../../react/viewTransition.js"; +import { canChildScrollVertically, findPageScrollEl } from "../../shared/scroll.js"; +import type { Citation } from "../../types/citation.js"; +import type { PageImage, Verification } from "../../types/verification.js"; import { resolveKeyMap } from "./cdn-keymap.js"; import { mapToCitation, mapToVerification } from "./cdn-mappers.js"; import { computePosition } from "./positioning.js"; @@ -107,7 +107,7 @@ let coastRafId: number | null = null; const boundTriggers = new WeakSet(); // ── Blink animation state ───────────────────────────────────────────────── -let blinkRafId = 0; +let blinkRafId: number | null = null; let blinkSettleTimer: ReturnType | null = null; let blinkFinalTimer: ReturnType | null = null; @@ -488,10 +488,23 @@ function teardownScrollPassthrough(): void { // 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 { - cancelAnimationFrame(blinkRafId); - if (blinkSettleTimer !== null) { clearTimeout(blinkSettleTimer); blinkSettleTimer = null; } - if (blinkFinalTimer !== null) { clearTimeout(blinkFinalTimer); blinkFinalTimer = null; } + if (blinkRafId !== null) { + cancelAnimationFrame(blinkRafId); + blinkRafId = null; + } + if (blinkSettleTimer !== null) { + clearTimeout(blinkSettleTimer); + blinkSettleTimer = null; + } + if (blinkFinalTimer !== null) { + clearTimeout(blinkFinalTimer); + blinkFinalTimer = null; + } } function animateOpen(): void { @@ -507,20 +520,20 @@ function animateOpen(): void { // enter-b: BLINK_SETTLE_MS settle toward near-final values blinkRafId = requestAnimationFrame(() => { if (!contentEl) return; - contentEl.style.transition = `opacity ${BLINK_SETTLE_MS}ms ${BLINK_ENTER_EASING}, transform ${BLINK_SETTLE_MS}ms ${BLINK_ENTER_EASING}`; + 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 = `opacity ${BLINK_FINAL_MS}ms ${BLINK_ENTER_EASING}, transform ${BLINK_FINAL_MS}ms ${BLINK_ENTER_EASING}`; + 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); - blinkSettleTimer = null; }, BLINK_SETTLE_MS); }); } @@ -531,7 +544,7 @@ function animateClose(onDone: () => void): void { return; } cancelBlink(); - contentEl.style.transition = `opacity ${BLINK_EXIT_TOTAL_MS}ms ${BLINK_EXIT_EASING}, transform ${BLINK_EXIT_TOTAL_MS}ms ${BLINK_EXIT_EASING}`; + 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(() => { @@ -636,6 +649,7 @@ function hidePopoverCleanup(): void { contentEl.style.transition = "none"; contentEl.style.opacity = ""; contentEl.style.transform = ""; + contentEl.style.willChange = ""; } radixVPPositionCleanup?.(); radixVPPositionCleanup = null; From cd6ecf7f1dde1a50a2b05bf7f3e29b884a12f139 Mon Sep 17 00:00:00 2001 From: Benson Date: Tue, 14 Apr 2026 18:24:09 -0600 Subject: [PATCH 4/5] fix(tests): update billing assertions to match new subscription tier copy Co-Authored-By: Claude Sonnet 4.6 --- src/__tests__/cliUnit.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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)", () => { From 8735c5ce4e90bb8eca1399269c5cbb626f079770 Mon Sep 17 00:00:00 2001 From: Benson Date: Tue, 14 Apr 2026 18:32:33 -0600 Subject: [PATCH 5/5] fix(cdn): reposition wrapper on view-state change to fix ghost animation target When a page-expand or page-collapse ghost transition fires, isViewTransitioning() becomes true which defers CDN reposition. But waitForPage*Target polls for the keyhole/annotation rect before the CDN repositions, finding a stable rect at the stale (wrong) wrapper position. The ghost then animates toward the wrong target. Fix: useLayoutEffect in CdnPopoverWrapper calls reposition() synchronously on viewState.current changes. useLayoutEffect fires inside flushSync (after React DOM update, before flushSync returns), so the wrapper is at the correct position before the transition code reads any target rects. Co-Authored-By: Claude Sonnet 4.6 --- src/vanilla/runtime/cdn.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/vanilla/runtime/cdn.ts b/src/vanilla/runtime/cdn.ts index 46bb8dc6..0911e8ac 100644 --- a/src/vanilla/runtime/cdn.ts +++ b/src/vanilla/runtime/cdn.ts @@ -1,4 +1,4 @@ -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"; @@ -207,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