Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/__tests__/cliUnit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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)", () => {
Expand Down
15 changes: 7 additions & 8 deletions src/cli/cliUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
111 changes: 92 additions & 19 deletions src/vanilla/runtime/cdn.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────────

Expand Down Expand Up @@ -94,6 +106,11 @@ let radixVPPositionCleanup: (() => void) | null = null;
let coastRafId: number | null = null;
const boundTriggers = new WeakSet<HTMLElement>();

// ── Blink animation state ─────────────────────────────────────────────────
let blinkRafId: number | null = null;
let blinkSettleTimer: ReturnType<typeof setTimeout> | null = null;
let blinkFinalTimer: ReturnType<typeof setTimeout> | null = null;

// ── Drawer state ─────────────────────────────────────────────────────
let drawerContainerEl: HTMLDivElement | null = null;
const drawerTriggerEls = new Set<HTMLElement>();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -467,30 +495,73 @@ 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 {
if (!contentEl || prefersReducedMotion) {
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 ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -577,6 +648,7 @@ function showPopoverFor(trigger: HTMLElement, data: VerificationData): void {
}

function hidePopoverCleanup(): void {
cancelBlink();
stopPositionTracking();
teardownScrollPassthrough();
if (wrapperEl) {
Expand All @@ -588,6 +660,7 @@ function hidePopoverCleanup(): void {
contentEl.style.transition = "none";
contentEl.style.opacity = "";
contentEl.style.transform = "";
contentEl.style.willChange = "";
}
radixVPPositionCleanup?.();
radixVPPositionCleanup = null;
Expand Down
Loading