diff --git a/build-number.txt b/build-number.txt index 48082f7..b1bd38b 100644 --- a/build-number.txt +++ b/build-number.txt @@ -1 +1 @@ -12 +13 diff --git a/docs/break-buyer-research-2026-08-11.md b/docs/break-buyer-research-2026-08-11.md index dbc3168..c3863ff 100644 --- a/docs/break-buyer-research-2026-08-11.md +++ b/docs/break-buyer-research-2026-08-11.md @@ -263,6 +263,8 @@ Each proposal states the problem, the evidence, the change, acceptance criteria, **Change.** Where a sheet carries `balanceColors`, surface a named omission stating that per-colour counts on that sheet are not derivable, and treat the slot's card count as a range rather than a point value. Where a sheet does not carry the flag, per-card weights are usable directly and confidence stays higher. +**Superseded (2026-09).** The omission told the buyer about a gap they could do nothing about, while the simulator drew the sheet as if unbalanced — which understates every mono colour's floor, because a modelled opening could miss a colour that a real pack cannot. Sampling now applies the one guarantee the flag does carry: the sheet spends its first five picks on one card of each mono colour, and draws the remainder by its own printed weights. No stronger per-colour distribution is claimed, and nothing is excluded — colourless and land commons still come through the free picks. The omission now fires only when the resolved sheet has lost a whole colour and the guarantee therefore cannot be honoured. + **Acceptance.** No code path infers a colour split from an unbalanced-unknown sheet. The omission is named specifically enough for a reviewer to act on. **Constraint.** This is the CLAUDE.md rule applied literally: never silently infer sheet weights, emit a named omission and lower confidence. diff --git a/src/App.tsx b/src/App.tsx index 2d4b68e..363e31f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,7 +25,10 @@ export function App({ releaseContext = runtimeReleaseContext }: { releaseContext // Every mode - including "home" - gets its own hash, so a real (non-SPA) // navigation back to this URL resolves to the same mode instead of // falling through to the buyer-workspace default. See route-mode.ts. - history.replaceState(null, "", `${location.pathname}${location.search}${hashForMode(next)}`); + // Keep whatever the history entry already carries (the buyer workspace + // marks its own break URLs there) so leaving and returning does not turn + // this browser's own link into someone else's shared break. + history.replaceState(history.state, "", `${location.pathname}${location.search}${hashForMode(next)}`); window.scrollTo({ top: 0, left: 0, behavior: "auto" }); if (next === "home") window.setTimeout(() => document.querySelector("[data-home-focus]")?.focus({ preventScroll: true }), 220); }; diff --git a/src/analytics.ts b/src/analytics.ts index 0d15d93..625ba2f 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -1,7 +1,7 @@ export type AnalyticsEvent = | "persona_selected" | "builder_opened" | "product_selected" | "builder_abandoned" | "calculation_completed" | "decision_eligibility" | "draft_resumed" - | "buyer_setup_copied"; + | "buyer_setup_copied" | "price_refresh_requested" | "break_link_shared"; const ALLOWED: Record = { persona_selected: ["mode", "viewportClass"], builder_opened: ["mode"], @@ -9,6 +9,8 @@ const ALLOWED: Record = { calculation_completed: ["mode", "productCount", "status", "durationBucket"], decision_eligibility: ["mode", "eligibility"], draft_resumed: ["mode", "productCount"], buyer_setup_copied: ["mode", "productCount"], + price_refresh_requested: ["mode", "productCount"], + break_link_shared: ["mode", "productCount"], }; export function analyticsPayload( diff --git a/src/buyer-actionable-notices.test.ts b/src/buyer-actionable-notices.test.ts new file mode 100644 index 0000000..9bc5502 --- /dev/null +++ b/src/buyer-actionable-notices.test.ts @@ -0,0 +1,143 @@ +import { createElement } from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { BuyerView, ContributorRows, type PriceRefreshState } from "./features/buyer/BuyerDetails"; +import { useOutcomeSimulation } from "./features/buyer/BuyerVisuals"; +import { createAuction } from "./domain/auction"; +import { calculateBreak } from "./domain/valuation"; +import { DEFAULT_BUYER_COSTS } from "./domain/bid-ceiling"; +import type { BreakAnalysis } from "./data/evaluate"; +import type { DecisionEligibility, SlotValuation } from "./domain/types"; + +const valuation = calculateBreak({ + prices: [ + { id: "w", set: "TST", collectorNumber: "1", name: "White", slot: "W", nonfoil: 10, foil: null }, + { id: "u", set: "TST", collectorNumber: "2", name: "Blue", slot: "U", nonfoil: 20, foil: null }, + ], + draws: [ + { set: "TST", collectorNumber: "1", copies: 1, foil: false, source: "fixed" }, + { set: "TST", collectorNumber: "2", copies: 1, foil: false, source: "fixed" }, + ], + threshold: 2, +}); + +const analysis: BreakAnalysis = { + valuation, + outcomeModel: { complete: true, packs: [], fixed: [{ id: "w", slot: "W", value: 10 }] }, + outcomeOmissions: [], +}; + +const staleEligibility: DecisionEligibility = { + status: "stale", + blockerCount: 1, + affectedGroups: [], + observedAt: new Date(Date.now() - 9 * 60 * 60 * 1000).toISOString(), + ageMs: 9 * 60 * 60 * 1000, + freshnessThresholdMs: 6 * 60 * 60 * 1000, + resolvedOnlyAvailable: true, + reason: "stale-price-snapshot", +}; + +function Decision({ + eligibility, + priceRefresh = "idle", + onRefreshPrices, +}: { + eligibility?: DecisionEligibility; + priceRefresh?: PriceRefreshState; + onRefreshPrices?: () => void; +}) { + const auction = createAuction(); + const simulation = useOutcomeSimulation(analysis, auction.remaining, undefined); + return createElement(BuyerView, { + analysis, + eligibility, + auction, + selectedSlots: [], + costs: DEFAULT_BUYER_COSTS, + simulation, + priceRefresh, + onRefreshPrices, + }); +} + +describe("stale prices are an action, not an announcement", () => { + it("offers a refresh control instead of a read-only age notice", async () => { + const refresh = vi.fn(); + render(createElement(Decision, { eligibility: staleEligibility, onRefreshPrices: refresh })); + + const control = await screen.findByRole("button", { name: /Prices over 6 hours old/ }); + fireEvent.click(control); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it("reports each phase, then the real answer, in the picker's own words", async () => { + const { rerender } = render(createElement(Decision, { + eligibility: staleEligibility, onRefreshPrices: () => {}, priceRefresh: "searching", + })); + expect(await screen.findByRole("button", { name: /Searching/ })).toBeDisabled(); + + rerender(createElement(Decision, { + eligibility: staleEligibility, onRefreshPrices: () => {}, priceRefresh: "updating", + })); + expect(screen.getByRole("button", { name: /Updating/ })).toHaveAttribute("aria-busy", "true"); + + // Nothing newer exists is an answer, not a silence. + rerender(createElement(Decision, { + eligibility: staleEligibility, onRefreshPrices: () => {}, priceRefresh: "stale", + })); + await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("No newer prices are published yet")); + expect(screen.getByRole("button", { name: /No newer data/ })).toBeEnabled(); + + // A refused refresh keeps the estimate and offers the retry. + rerender(createElement(Decision, { + eligibility: staleEligibility, onRefreshPrices: () => {}, priceRefresh: "error", + })); + expect(screen.getByRole("status")).toHaveTextContent("keeps the prices it already had"); + expect(screen.getByRole("button", { name: /Retry/ })).toBeEnabled(); + }); + + it("keeps the control visible after a refresh makes the estimate fresh", async () => { + // The answer to "did that work?" must outlive the condition that prompted + // it; swapping straight back to a plain label loses the result. + render(createElement(Decision, { onRefreshPrices: () => {}, priceRefresh: "updated" })); + expect(await screen.findByRole("button", { name: /Updated/ })).toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent("Newer prices are in this estimate"); + }); + + it("keeps a plain label when the estimate is fresh", async () => { + render(createElement(Decision, { onRefreshPrices: () => {} })); + await waitFor(() => expect(screen.getByRole("region", { name: "Bid decision" })).toBeInTheDocument()); + expect(screen.queryByRole("button", { name: /Prices over 6 hours old/ })).not.toBeInTheDocument(); + }); +}); + +describe("ranked card column help", () => { + const slot = { + ...valuation.slots.find((row) => row.id === "W")!, + } as SlotValuation; + + it("explains each column as its own paragraph, with the term set apart", () => { + render(createElement(ContributorRows, { slot, onInspect: () => {} })); + + fireEvent.click(screen.getByRole("button", { name: "What Chance and Adds mean" })); + const paragraphs = screen.getByRole("tooltip").querySelectorAll(".tip-paragraph"); + + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0].querySelector("b")).toHaveTextContent("Chance"); + expect(paragraphs[1].querySelector("b")).toHaveTextContent("Adds"); + expect(paragraphs[1]).toHaveTextContent("average number of copies opened"); + }); + + it("gives every ranked row a chance cell and an adds cell", () => { + const { container } = render(createElement(ContributorRows, { slot, onInspect: () => {} })); + const row = container.querySelector(".contributor-card")!; + + // Four cells, in the order the four column headers name them. A row with + // fewer wraps its last cell onto its own line and reads as an empty + // Chance column. + expect(row.children).toHaveLength(4); + expect(row.querySelector(".pull-odds")).not.toBeEmptyDOMElement(); + expect(row.querySelector(".ev-contribution")).not.toBeEmptyDOMElement(); + }); +}); diff --git a/src/buyer-share-link.test.ts b/src/buyer-share-link.test.ts new file mode 100644 index 0000000..c7b3bc2 --- /dev/null +++ b/src/buyer-share-link.test.ts @@ -0,0 +1,72 @@ +import { createElement } from "react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { BuyerWorkspace } from "./features/buyer/BuyerWorkspace"; +import { decodeBuyerShare } from "./domain/share-url"; +import { decodeLegacySearch } from "./domain/legacy"; + +const SHARED = "/ColorBreak/?b=MH2.collector-pack.1~EOE.play-pack.2&m=random&s=&r=WUBRGMCL&f=1&t=2#buyer"; + +// The workspace's data layer is not under test here; a link must carry the +// break whether or not the price snapshot answers. +beforeEach(() => { vi.stubGlobal("fetch", () => Promise.reject(new Error("offline"))); }); + +function mount() { + return render(createElement(BuyerWorkspace, { exit: () => {}, startFresh: false, startReady: false })); +} + +describe("a break travels in its own link", () => { + it("keeps the break in the address bar so the visible URL is the shareable one", async () => { + history.replaceState(null, "", SHARED); + mount(); + + // Historically the query was stripped on arrival, so the address bar a + // recipient forwarded carried no break at all. + await waitFor(() => expect(decodeLegacySearch(location.search)).toHaveLength(2)); + expect(decodeBuyerShare(location.search).assignmentMode).toBe("random"); + expect(location.hash).toBe("#buyer"); + }); + + it("marks its own URL so a reload is not announced as someone else's break", async () => { + history.replaceState(null, "", SHARED); + mount(); + await waitFor(() => expect(history.state).toMatchObject({ colorbreakOwn: true })); + + expect(screen.getByLabelText("Shared calculation details")).toBeInTheDocument(); + + // Reload: same URL, but this browser wrote it, so it is this buyer's own + // working break rather than an incoming shared calculation. + cleanup(); + mount(); + await waitFor(() => expect(screen.queryByLabelText("Shared calculation details")).not.toBeInTheDocument()); + }); + + it("shares through the platform sheet when one exists", async () => { + history.replaceState(null, "", SHARED); + const share = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "share", { configurable: true, value: share }); + mount(); + + await act(async () => { fireEvent.click(screen.getByLabelText("Share this break")); }); + + expect(share).toHaveBeenCalledTimes(1); + expect(decodeLegacySearch(new URL(share.mock.calls[0][0].url).search)).toHaveLength(2); + Reflect.deleteProperty(navigator, "share"); + }); + + it("falls back to the clipboard, and to a readable link when that is refused", async () => { + history.replaceState(null, "", SHARED); + Reflect.deleteProperty(navigator, "share"); + let copied = ""; + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: async (value: string) => { copied = value; } }, + }); + mount(); + + await act(async () => { fireEvent.click(screen.getByLabelText("Share this break")); }); + + expect(decodeLegacySearch(new URL(copied).search)).toHaveLength(2); + expect(screen.getByLabelText("Break link")).toHaveValue(copied); + }); +}); diff --git a/src/data/scryfall.ts b/src/data/scryfall.ts index ed73efe..72ce581 100644 --- a/src/data/scryfall.ts +++ b/src/data/scryfall.ts @@ -298,7 +298,7 @@ export async function loadPrices(request: PriceLoadRequest): Promise = { + idle: "Prices over 6 hours old · Refresh", + searching: "Searching…", + updating: "Updating…", + updated: "Updated", + current: "Up to date", + stale: "No newer data", + partial: "Partial update", + error: "Retry", +}; + +const PRICE_REFRESH_DETAIL: Record = { + idle: "Check the latest published prices and update this estimate.", + searching: "Checking the price publication.", + updating: "Loading newer prices.", + updated: "Newer prices loaded. Tap to check again.", + current: "This estimate already uses the latest publication. Tap to check again.", + stale: "Checked the latest publication; newer prices are not available yet. Tap to check again.", + partial: "Some new prices could not load. Existing prices fill those gaps. Tap to retry.", + error: "Refresh failed. Your existing estimates are kept. Tap to retry.", +}; + +const PRICE_REFRESH_ANSWER: Record = { + idle: "", searching: "", updating: "", + updated: "Newer prices are in this estimate.", + current: "This estimate already used the latest published prices.", + stale: "No newer prices are published yet. The estimate still uses the latest published snapshot.", + partial: "Some prices could not be refreshed. The estimate keeps the prices it already had for those.", + error: "The price publication could not be checked. The estimate keeps the prices it already had.", +}; + export function BuyerView({ analysis, eligibility: assessedEligibility, @@ -516,6 +560,8 @@ export function BuyerView({ breakLabel, costs, simulation, + priceRefresh = "idle", + onRefreshPrices, }: { analysis: BreakAnalysis; eligibility?: DecisionEligibility; @@ -526,6 +572,8 @@ export function BuyerView({ simulation: OutcomeSimulation; onChooseReady?: () => void; onUseManualCap?: () => void; + priceRefresh?: PriceRefreshState; + onRefreshPrices?: () => void; }) { const result = analysis.valuation; const eligibility = assessedEligibility ?? decisionEligibility(result); @@ -552,7 +600,22 @@ export function BuyerView({
{decisionKicker} - {eligibility.status === "eligible" ? "Fresh estimate" : eligibility.status === "stale" ? "Prices over 6 hours old" : result.status} + {(eligibility.status === "stale" || priceRefresh !== "idle") && onRefreshPrices + // Stale prices are something the buyer can act on, so the label is + // the control that acts on it rather than a notice they can only + // read. It stays put after the check so the answer is legible. + ? + : {eligibility.status === "eligible" ? "Fresh estimate" : eligibility.status === "stale" ? "Prices over 6 hours old" : result.status}}
@@ -568,6 +631,7 @@ export function BuyerView({ My {selectedSlots.length === 1 ? "slot" : "slots"}: {selectedSlots.map((id) => SLOT_NAMES[id]).join(", ")}

} + {PRICE_REFRESH_ANSWER[priceRefresh] &&

{PRICE_REFRESH_ANSWER[priceRefresh]}

} {simulation.busy &&

Checking more possible openings…

} {simulation.error &&

{simulation.error}

} diff --git a/src/features/buyer/BuyerWorkspace.tsx b/src/features/buyer/BuyerWorkspace.tsx index 18aa002..4ecb853 100644 --- a/src/features/buyer/BuyerWorkspace.tsx +++ b/src/features/buyer/BuyerWorkspace.tsx @@ -2,12 +2,13 @@ import { bestAvailableAnalysis } from "../../data/answer-cache"; import { answerFactors } from "../../domain/answer-quality"; import { AnswerProvider } from "../shared/Answer"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { BarChart3, Copy, Lock, Sparkles } from "lucide-react"; +import { BarChart3, Lock, Share2, Sparkles } from "lucide-react"; import { productsForSet } from "../../data/catalog"; import type { BreakAnalysis } from "../../data/evaluate"; import { assessBuyerDecision, type BuyerDecisionAssessment, type PreparedProductSelection } from "../../domain/decision-evidence"; import { canonicalCompositionFingerprint } from "../../domain/canonical-composition"; import { sealedMarketPrice } from "../../data/sealed-prices"; +import { refreshPublishedPrices } from "../../data/scryfall"; import { createAuction } from "../../domain/auction"; import type { AuctionState } from "../../domain/auction"; import { decodeLegacySearch } from "../../domain/legacy"; @@ -28,7 +29,7 @@ import { DEFAULT_BUYER_COSTS, type BuyerCosts } from "../../domain/bid-ceiling"; import { Builder, ManualBudgetCap } from "../shared/ProductBuilder"; import { CompactWarning, useOutcomeSimulation } from "./BuyerVisuals"; import { BuyerSetup } from "./BuyerSetup"; -import { BuyerView, LargeBreakView } from "./BuyerDetails"; +import { BuyerView, LargeBreakView, type PriceRefreshState } from "./BuyerDetails"; /** Owns only buyer decision state; seller planning has its own controller. */ export function BuyerWorkspace({ @@ -43,7 +44,11 @@ export function BuyerWorkspace({ const mode = "buyer" as const; const legacy = useMemo(() => decodeLegacySearch(location.search), []); const sharedBuyer = useMemo(() => decodeBuyerShare(location.search), []); - const isSharedBreak = legacy.length > 0; + // The workspace keeps the break in the address bar, so a reload lands on a + // URL this browser wrote itself. The history entry says which it is; without + // that marker your own reload would be announced as someone else's link. + const ownUrl = useMemo(() => (history.state as { colorbreakOwn?: boolean } | null)?.colorbreakOwn === true, []); + const isSharedBreak = legacy.length > 0 && !ownUrl; const initialBuyerRecord = useMemo(() => readBuyerDecisionRecord(), []); const firstResultTracked = useRef(false); const [legacyNotice, setLegacyNotice] = useState(false); @@ -79,7 +84,11 @@ export function BuyerWorkspace({ // checked until they say so. [selectedSlots, setSelectedSlots] = useState(() => sharedBuyer.selectedSlots ?? []), [busy, setBusy] = useState(false), + // The buyer asked, so the buyer gets told what happened: the phase while + // it runs, and the real answer after, including "nothing newer exists". + [priceRefresh, setPriceRefresh] = useState("idle"), [calculationGeneration, setCalculationGeneration] = useState(0); + const refreshRequest = useRef(0); const [manualCapOpen, setManualCapOpen] = useState(false); const [manualTarget, setManualTarget] = useState(); const [manualShipping, setManualShipping] = useState(); @@ -128,9 +137,17 @@ export function BuyerWorkspace({ bulkThreshold, largeSpots, }); + // A link only propagates a break if the address bar carries one. Stripping + // the query on arrival meant the only shareable URL lived behind the share + // control, and a recipient who forwarded what they saw sent an empty break. useEffect(() => { - if (location.search) history.replaceState(null, "", `${location.pathname}#buyer`); - }, []); + const target = lines.length ? new URL(sharedHref) : null; + history.replaceState( + { ...(history.state as object | null), colorbreakOwn: true }, + "", + target ? `${target.pathname}${target.search}${target.hash}` : `${location.pathname}#buyer`, + ); + }, [sharedHref, lines.length]); useLayoutEffect(() => { if (!lines.length) { setAnalysis(undefined); @@ -182,6 +199,26 @@ export function BuyerWorkspace({ if (request === analysisRequest.current) setBusy(false); }); }, [lines, threshold, calculationGeneration]); + // A new break asks its own question; last refresh's answer no longer applies. + useEffect(() => { setPriceRefresh("idle"); }, [lines, threshold]); + const refreshPrices = async () => { + if (priceRefresh === "searching" || priceRefresh === "updating") return; + const request = ++refreshRequest.current; + setPriceRefresh("searching"); + track("price_refresh_requested", { mode, productCount: lines.length }); + try { + // Same publication check the product picker runs: usable prices survive a + // failure, so a refused refresh never costs the buyer their estimate. + const result = await refreshPublishedPrices((phase) => { + if (request === refreshRequest.current) setPriceRefresh(phase); + }); + if (request !== refreshRequest.current) return; + setPriceRefresh(result); + setCalculationGeneration((generation) => generation + 1); + } catch { + if (request === refreshRequest.current) setPriceRefresh("error"); + } + }; useEffect(() => { if (buyerRecoveryReady || !initialBuyerRecord || !analysis || analysis.valuation.dataVersion.startsWith("preview:") || recoveryRecord) return; const recovered = readBuyerDecisionRecord({ @@ -235,7 +272,9 @@ export function BuyerWorkspace({ ...(line.marketCost == null && row.price != null ? { marketCost: row.price } : {}), }; })); - }); + // A catalog or sealed-price fetch that fails leaves the break lines as the + // buyer entered them; it must not surface as an unhandled rejection. + }).catch(() => undefined); return () => { cancelled = true; }; }, [lines.map((line) => `${line.id}:${line.productKey}:${line.tcgId ?? ""}`).join("|")]); const [shareStatus, setShareStatus] = useState(); @@ -243,8 +282,20 @@ export function BuyerWorkspace({ // decision's outcome range are two views of the same modeled openings. const simulation = useOutcomeSimulation(analysis, auction.remaining, undefined); const share = async () => { - try { await navigator.clipboard.writeText(sharedHref); setShareStatus("Buyer setup link copied"); } - catch { setShareStatus("Clipboard unavailable — copy the displayed buyer setup URL."); } + track("break_link_shared", { mode, productCount: lines.length }); + // Mobile touch is the primary input, so the platform share sheet comes + // first; the clipboard is the desktop path and the readable URL is the + // fallback when neither is permitted. + if (typeof navigator.share === "function") { + try { + await navigator.share({ title: "ColorBreak break", url: sharedHref }); + return; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") return; + } + } + try { await navigator.clipboard.writeText(sharedHref); setShareStatus("Break link copied"); } + catch { setShareStatus("Clipboard unavailable — copy the break link below."); } track("buyer_setup_copied", { mode, productCount: lines.length }); }; return ( @@ -258,12 +309,13 @@ export function BuyerWorkspace({
{lines.length > 0 && }
@@ -308,7 +360,7 @@ export function BuyerWorkspace({ }}>Start clean
} - {shareStatus &&

{shareStatus}

} + {shareStatus &&

{shareStatus} event.currentTarget.select()} />

}
@@ -372,6 +424,8 @@ export function BuyerWorkspace({ simulation={simulation} onChooseReady={() => setLines([readyExampleLine()])} onUseManualCap={() => setManualCapOpen(true)} + priceRefresh={priceRefresh} + onRefreshPrices={refreshPrices} /> ))}
diff --git a/src/features/shared/Primitives.tsx b/src/features/shared/Primitives.tsx index 191b0af..939025e 100644 --- a/src/features/shared/Primitives.tsx +++ b/src/features/shared/Primitives.tsx @@ -311,6 +311,23 @@ export function NumberField({ ); } +/** + * A tooltip that explains two terms is two explanations, not one paragraph. + * Blank lines separate them, and a leading "Term:" is set apart so the reader + * can find the term they tapped without reading the sentence first. + */ +function tipParagraphs(text: string) { + return text.split(/\n\s*\n/).map((paragraph) => { + const trimmed = paragraph.trim(); + const lead = /^([A-Z][A-Za-z' -]{0,24}):\s+(.*)$/s.exec(trimmed); + return ( + + {lead ? <>{lead[1]} {lead[2]} : trimmed} + + ); + }); +} + export function EstimateTip({ text, label = "What affects this estimate" }: { text: string; label?: string }) { return ; } @@ -434,7 +451,7 @@ export function Tip({ role="tooltip" style={position} > - {text} + {tipParagraphs(text)} , document.body, )} diff --git a/src/future.css b/src/future.css index b0b4d66..4568ce5 100644 --- a/src/future.css +++ b/src/future.css @@ -1448,6 +1448,65 @@ img.card-thumbnail { object-fit: cover; background: #171b23; } .keyboard-open .set-browser-tools .set-sort-tabs, .keyboard-open .break-input-actions { display: none; } +/* The ranked-card row carries four cells (thumbnail, card, chance, adds). A + * legacy `.contributors .card-row` rule outranked the contributor grid at + * 720px and up and declared three, so the Adds cell wrapped onto its own line + * and Chance landed under the Adds header, reading as an empty column. */ +@media (min-width: 720px) { + .contributors .contributor-columns, + .large-break-slot-cards .contributor-columns { + /* The header's first cell also spans the row's thumbnail column, and both + * grids share one gap, so Chance and Adds sit over their own values. */ + grid-template-columns: minmax(0, 1fr) 72px 104px; + gap: 10px; + } + .contributors .card-row.contributor-card, + .large-break-slot-cards .card-row.contributor-card { + grid-template-columns: 38px minmax(0, 1fr) 72px 104px; + } +} + +/* A tooltip that defines more than one term reads as separate definitions. */ +.tip-popover { display: grid; gap: 8px; } +.tip-paragraph { display: block; } +.tip-paragraph b { color: var(--ink); } + +/* Stale prices are actionable, so the notice is the control that acts. */ +.decision-evidence.refresh-prices { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 32px; + padding: 4px 10px; + border: 1px solid currentcolor; + border-radius: 999px; + background: transparent; + font: inherit; + cursor: pointer; +} +.decision-evidence.refresh-prices:hover:not(:disabled) { background: rgba(255, 255, 255, .06); } +.decision-evidence.refresh-prices:disabled { cursor: progress; opacity: .75; } +.decision-evidence.refresh-prices svg { width: 14px; height: 14px; } +.decision-evidence.refresh-prices svg.spinning { animation: spin 1s linear infinite; } +@media (prefers-reduced-motion: reduce) { + .decision-evidence.refresh-prices svg.spinning { animation: none; } +} +.price-refresh-answer { margin: 0; padding: 0 18px 12px; color: var(--muted); font-size: 12px; line-height: 1.4; } + +/* Sharing a break is a primary action, not a glyph to guess at. */ +.icon-button.share-break { width: auto; gap: 7px; padding: 0 13px; } +.icon-button.share-break span { font-size: 12px; font-weight: 700; letter-spacing: .04em; } +.share-status { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 10px max(18px, calc((100vw - 1180px) / 2)); color: var(--muted); font-size: 12px; } +.share-status input { + flex: 1 1 240px; + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 9px; + color: var(--ink); + background: #0d1016; + font-size: 12px; +} /* Final viewport bounds win over historical vh/dvh sheet and page rules. * VisualViewport includes browser-bar changes as well as keyboard changes. */ .scrim .sheet { max-height: calc(var(--visual-viewport-height, 100dvh) - 12px); }