From 58ee008e643cf8b827fa80f86507f5bbbae443ca Mon Sep 17 00:00:00 2001 From: G Date: Fri, 31 Jul 2026 18:26:43 +0100 Subject: [PATCH 1/8] fix(faq): make accordion dividers and chevrons background-aware Faq hardcoded divide-white/20, and Accordion hardcoded text-zinc-200 on the chevron and text-zinc-300 on the body. All three assume a dark background, which held while the homepage was the only consumer since it mounts the FAQ on a dark section. On a light or gray section the dividers and chevrons are effectively invisible. They now use --color-border-base, --color-text-base and --color-paragraph-text, which flip off the .light-section / .dark-section class that Section already sets, so no isDark branching is needed. Faq keeps useBackground purely for prose-invert. Moving the colour onto the item rather than the parent also fixes the first divider. divide-* targets every child after the first, so item one's border was falling back to currentColor and rendering at full brightness on dark. The homepage FAQ shifts slightly as a result: chevron zinc-200 to zinc-50, body zinc-300 to zinc-400, both now matching the tokens used everywhere else. That page is worth a look alongside this one. Review: @filipagr Co-Authored-By: Claude Opus 5 (1M context) --- src/components/Accordion.tsx | 6 +++--- src/components/Faq.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Accordion.tsx b/src/components/Accordion.tsx index 43cabcff..731fccf1 100644 --- a/src/components/Accordion.tsx +++ b/src/components/Accordion.tsx @@ -22,7 +22,7 @@ function AccordionItem({ {children} - + @@ -62,7 +62,7 @@ function AccordionContent({ return (
{children}
diff --git a/src/components/Faq.tsx b/src/components/Faq.tsx index 1e08609a..d91ca49a 100644 --- a/src/components/Faq.tsx +++ b/src/components/Faq.tsx @@ -21,7 +21,7 @@ export function Faq({ questions }: FaqProps) { return (
- + {questions.map(({ question, answer }) => { return ( From 8b19c3c1076347f6913b47351687fe394f8e3610 Mon Sep 17 00:00:00 2001 From: G Date: Fri, 31 Jul 2026 18:35:34 +0100 Subject: [PATCH 2/8] fix(ipfs2filecoin): correct layout and type hierarchy from design review Follow-ups from a walkthrough of the page with Filipa Ribeiro. Alignment and spacing - "Estimate your deposit" and the hero sat 60px inset from their siblings, because PageSection already wraps children in a Container and page.tsx nested a second one, doubling the horizontal padding. Removed both, plus the now-unused import. - The Unit and Funded-for selects had the native arrow jammed against the border with a cavern between it and the value. A local SelectField goes appearance-none with a Phosphor caret inset to match the field's text, and the grid drops from three equal columns to 2fr/1fr/2fr so Unit is no longer a full-width field holding three characters. - "What this does not do" is constrained to max-w-5xl; lines ran past 15 words at desktop. Type - Both tables move off text-sm to text-base, with service names at text-lg. - Monospace figures dropped for the body font with tabular-nums. The reported problem was numbers appearing larger than the names beside them, which was a typeface illusion rather than a size difference, so matching the typeface removes it at the root while tabular-nums keeps the columns aligned. Flagging this one as the most debatable call in the batch. - The highlighted Filecoin row carries weight instead of a marginally darker grey, which read as a mistake rather than emphasis. Visual interest - How it works renders 2x2 rather than 3-up with an orphaned fourth, and step numbers sit above each title in the accent colour. This needs a new StepList component because the shared Card types title as a plain string, so the number could only ever be inlined as "01. Hand over the list". - Reason cards take icons, following how agents/ and warm-storage-service/ already pass Phosphor icons to Card. - The agent column takes two thirds and "Talk to us" becomes a one third call-out, giving the path with more to say more room. The external-link arrow beside Filebase and Pinata is tightened with a local override. It is sized for button text inside ExternalTextLink in ui-filecoin, so the real fix belongs upstream; this is a commented stopgap. Still open from the review and deliberately not attempted here: table whitespace now that the type sizes are settled, the hero background treatment, and the optional illustration beside the agent heading. Review: @filipagr Co-Authored-By: Claude Opus 5 (1M context) --- .../components/ComparisonTable.tsx | 18 ++- .../components/CostEstimator.tsx | 122 +++++++++++------- src/app/ipfs2filecoin/components/StepList.tsx | 34 +++++ src/app/ipfs2filecoin/data/reasons.ts | 11 ++ src/app/ipfs2filecoin/page.tsx | 76 +++++------ 5 files changed, 169 insertions(+), 92 deletions(-) create mode 100644 src/app/ipfs2filecoin/components/StepList.tsx diff --git a/src/app/ipfs2filecoin/components/ComparisonTable.tsx b/src/app/ipfs2filecoin/components/ComparisonTable.tsx index 9b7a73b7..1acf0eed 100644 --- a/src/app/ipfs2filecoin/components/ComparisonTable.tsx +++ b/src/app/ipfs2filecoin/components/ComparisonTable.tsx @@ -5,9 +5,9 @@ import { pricingComparison } from '../data/pricing-comparison' export function ComparisonTable() { return (
- +
- + @@ -26,10 +26,15 @@ export function ComparisonTable() { key={service} className="border-(--color-border-muted) border-b last:border-b-0" > + {/* + Service names carry the same typeface as the figures on purpose. + They were previously monospace, which read as a size jump and + made the names look smaller than the numbers beside them. + */} - diff --git a/src/app/ipfs2filecoin/components/CostEstimator.tsx b/src/app/ipfs2filecoin/components/CostEstimator.tsx index f8476e95..39da69ad 100644 --- a/src/app/ipfs2filecoin/components/CostEstimator.tsx +++ b/src/app/ipfs2filecoin/components/CostEstimator.tsx @@ -1,9 +1,12 @@ 'use client' import { Button } from '@filecoin-foundation/ui-filecoin/Button' +import { Icon } from '@filecoin-foundation/ui-filecoin/Icon' import { Field, Input, Label, Select } from '@headlessui/react' +import { CaretDownIcon } from '@phosphor-icons/react/dist/ssr' +import { clsx } from 'clsx' import { usePlausible } from 'next-plausible' -import { useId, useState } from 'react' +import { type ChangeEvent, type ReactNode, useId, useState } from 'react' import { BUFFER_DAYS, @@ -29,10 +32,11 @@ const DURATION_OPTIONS = [ const fieldClassName = 'focus:brand-outline block w-full rounded-lg border border-(--input-border-color) p-3 text-(--color-text-base) placeholder:text-(--input-placeholder-color)' +const labelClassName = + 'mb-1 inline-block font-medium text-(--color-text-base) text-sm' + export function CostEstimator() { const volumeId = useId() - const unitId = useId() - const durationId = useId() const plausible = usePlausible() const [volume, setVolume] = useState('1') @@ -67,12 +71,9 @@ export function CostEstimator() { return (
-
+
- - - - - + setUnit(event.target.value as VolumeUnit)} + > + + + +

+ Total across everything you are storing, not per CID. 1 TiB is 1,024 + GiB. +

- - - - + setDays(Number(event.target.value))} + > + {DURATION_OPTIONS.map(({ label, days: value }) => ( + + ))} +
Where the data lives @@ -37,13 +42,14 @@ export function ComparisonTable() { {storagePerTbMonth} + {egress}
+
- + @@ -198,13 +222,13 @@ function EstimateBreakdown({ estimate }: { estimate: CostEstimate }) { {rows.map(({ label, amount, note }) => ( - + ))} -
Line
{label}{amount}{amount} {note}
Deposit + {formatUsd(estimate.deposit)} diff --git a/src/app/ipfs2filecoin/components/StepList.tsx b/src/app/ipfs2filecoin/components/StepList.tsx new file mode 100644 index 00000000..ba4308b2 --- /dev/null +++ b/src/app/ipfs2filecoin/components/StepList.tsx @@ -0,0 +1,34 @@ +import { Heading } from '@filecoin-foundation/ui-filecoin/Heading' + +import type { steps } from '../data/steps' + +type StepListProps = { + steps: typeof steps +} + +/** + * Not the shared `Card`: its `title` is a plain string, so the step number could + * only ever be inlined as "01. Hand over the list". Lifting the number above the + * title and giving it the accent colour is what makes the section read as a + * sequence rather than four unrelated cards. + */ +export function StepList({ steps }: StepListProps) { + return ( +
    + {steps.map(({ number, title, description }) => ( +
  1. + + + {title} + +

    {description}

    +
  2. + ))} +
+ ) +} diff --git a/src/app/ipfs2filecoin/data/reasons.ts b/src/app/ipfs2filecoin/data/reasons.ts index 8eefd413..d78810fa 100644 --- a/src/app/ipfs2filecoin/data/reasons.ts +++ b/src/app/ipfs2filecoin/data/reasons.ts @@ -1,22 +1,33 @@ +import { + CurrencyDollarIcon, + FingerprintIcon, + SealCheckIcon, + WalletIcon, +} from '@phosphor-icons/react/dist/ssr' + export const reasons = [ { title: 'Your CIDs do not change', description: 'Nothing is re-chunked. Every CID stays byte-identical and keeps resolving from any public IPFS gateway, so existing links, IPNS names, and NFT metadata keep working exactly as they do today.', + icon: FingerprintIcon, }, { title: 'A fraction of pinning-service pricing', description: '$2.50 per TiB per month per copy, two copies by default. You pay storage providers directly, streamed per epoch, with no plan tiers and no minimum commitment.', + icon: CurrencyDollarIcon, }, { title: 'Proof, not a dashboard', description: 'Storage providers prove possession onchain on a schedule. You get a receipt with transaction links, and you can verify any piece yourself at any time without asking anyone.', + icon: SealCheckIcon, }, { title: 'Your wallet owns it, not an account', description: 'No signup and no vendor account to lose. The data set belongs to your wallet address and payment streams from your wallet straight to the providers. Reading your data never depends on your key, so the content stays reachable either way.', + icon: WalletIcon, }, ] as const diff --git a/src/app/ipfs2filecoin/page.tsx b/src/app/ipfs2filecoin/page.tsx index 439d7521..da5b9d57 100644 --- a/src/app/ipfs2filecoin/page.tsx +++ b/src/app/ipfs2filecoin/page.tsx @@ -1,7 +1,6 @@ import { Button } from '@filecoin-foundation/ui-filecoin/Button' import { Card } from '@filecoin-foundation/ui-filecoin/Card' import { CardGrid } from '@filecoin-foundation/ui-filecoin/CardGrid' -import { Container } from '@filecoin-foundation/ui-filecoin/Container' import { Heading } from '@filecoin-foundation/ui-filecoin/Heading' import { PageHeader } from '@filecoin-foundation/ui-filecoin/PageHeader' import { PageSection } from '@filecoin-foundation/ui-filecoin/PageSection' @@ -22,6 +21,7 @@ import { AgentPrompt } from './components/AgentPrompt' import { CidListChecker } from './components/CidListChecker' import { ComparisonTable } from './components/ComparisonTable' import { CostEstimator } from './components/CostEstimator' +import { StepList } from './components/StepList' import { COORDINATION_VOLUME_LABEL, RUNBOOK_PATH } from './constants/migration' import { IPFS2FILECOIN_SEO } from './constants/seo' import { faqs } from './data/faqs' @@ -33,6 +33,14 @@ import { generateStructuredData } from './utils/generate-structured-data' const ghostOnDark = '!border-zinc-50/40 !bg-transparent hover:!border-zinc-50 hover:!bg-zinc-50/5' +/** + * `ExternalTextLink` sizes its arrow for button-style links: a fixed 16px glyph + * set `ml-1` off the label. Inside a 14px paragraph that reads as oversized and + * detached, so pull it in and scale it to the text. Fix belongs upstream in + * ui-filecoin; this keeps the footnote legible until then. + */ +const inlineExternalLink = '[&>span]:ml-0.5 [&_svg]:size-3.5' + export default function IpfsToFilecoin() { return ( <> @@ -49,11 +57,9 @@ export default function IpfsToFilecoin() { title="Move your pinned IPFS data to Filecoin" description="Same CIDs, a fraction of what pinning services charge, and an onchain receipt you can verify yourself." /> - -
- -
-
+
+ +
@@ -63,12 +69,13 @@ export default function IpfsToFilecoin() { description="Your data keeps working the way it does today. What changes is the price, the proof, and who owns it." > - {reasons.map(({ title, description }) => ( + {reasons.map(({ title, description, icon }) => ( ))} @@ -88,11 +95,17 @@ export default function IpfsToFilecoin() { Filecoin Warm Storage is $2.50 per TiB per month per copy at two copies. Other rates are published list overage rates as of 27 July 2026, from{' '} - + Filebase {' '} and{' '} - + Pinata . Filecoin includes two replicas; the other services do not @@ -103,20 +116,18 @@ export default function IpfsToFilecoin() { - -
- - Estimate your deposit - -

- Storage is charged continuously while your data sits there, so you - deposit for a period rather than paying a monthly bill. Enter - roughly how much you are holding and how long you want to be - covered. This runs in your browser and nothing is sent anywhere. -

- -
-
+
+ + Estimate your deposit + +

+ Storage is charged continuously while your data sits there, so you + deposit for a period rather than paying a monthly bill. Enter + roughly how much you are holding and how long you want to be + covered. This runs in your browser and nothing is sent anywhere. +

+ +
@@ -125,16 +136,7 @@ export default function IpfsToFilecoin() { title="How it works" description="Four steps, and the first two are free." > - - {steps.map(({ number, title, description }) => ( - - ))} - + @@ -144,8 +146,8 @@ export default function IpfsToFilecoin() { title="Nobody should migrate an archive by clicking" description="Real migrations run unattended, so that is the path this is built around. Check a list for free, get your account funded in one pass, then hand the work to your agent or to us." > -
-
+
+
Ask your agent to do it @@ -167,7 +169,7 @@ export default function IpfsToFilecoin() {
-
+
Talk to us @@ -195,7 +197,7 @@ export default function IpfsToFilecoin() { title="What this does not do" description="The things worth knowing up front, so nothing surprises you halfway through a run." > -
+
{limits.map(({ title, description }) => (
Date: Fri, 31 Jul 2026 18:35:48 +0100 Subject: [PATCH 3/8] feat(ipfs2filecoin): turn a checked CID list into something actionable parseCidList already validated, deduped and stripped gateway prefixes, but the verdict box reported only a count. The successful path gave you nothing to act on, so the top of the page had no purpose: you still assembled cids.txt by hand. The prompt was in fact offered only on the over-cap branch, so the one outcome that succeeded was the one outcome with no output. A checked list now produces the agent prompt with your own CIDs inlined, via buildAgentPrompt, plus a cids.txt download of the cleaned and deduped list. AGENT_PROMPT stays as the fallback for the empty and over-cap cases, where pointing at a file is the right shape rather than inlining hundreds of lines. The instruction to "estimate what they cost to store below" is replaced. A check reads the CIDs and not the bytes behind them, so it cannot price anything on its own, and implying otherwise was the most confusing part of the flow. Resolving a CID to its actual size needs backend capability and is a separate conversation. Hero polish in the same component: "one per line" sits beside the label in the accent colour rather than surfacing only after a failed check, the button is centred, the empty textarea is shorter, and the free-check facts read as a scannable list instead of fine print. Copy prompt demotes from a full-width button to an icon in the code block's corner. Verified against deliberately messy input: a bare CID, a gateway URL, a duplicate and a junk line resolve to three unique CIDs, with the URL prefix stripped and both the skipped line and the duplicate reported. Review: @filipagr Co-Authored-By: Claude Opus 5 (1M context) --- .../ipfs2filecoin/components/AgentPrompt.tsx | 38 +++++++--- .../components/CidListChecker.tsx | 72 ++++++++++++++----- src/app/ipfs2filecoin/constants/migration.ts | 14 ++++ 3 files changed, 97 insertions(+), 27 deletions(-) diff --git a/src/app/ipfs2filecoin/components/AgentPrompt.tsx b/src/app/ipfs2filecoin/components/AgentPrompt.tsx index 1569d9f6..c83bd1a9 100644 --- a/src/app/ipfs2filecoin/components/AgentPrompt.tsx +++ b/src/app/ipfs2filecoin/components/AgentPrompt.tsx @@ -1,38 +1,54 @@ 'use client' -import { Button } from '@filecoin-foundation/ui-filecoin/Button' +import { Icon } from '@filecoin-foundation/ui-filecoin/Icon' +import { CheckIcon, CopyIcon } from '@phosphor-icons/react/dist/ssr' import { usePlausible } from 'next-plausible' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' -import { AGENT_PROMPT, PLAUSIBLE_EVENTS } from '../constants/migration' +import { buildAgentPrompt, PLAUSIBLE_EVENTS } from '../constants/migration' type AgentPromptProps = { /** Where on the page the prompt was copied from, so the two spots stay distinguishable. */ source: 'verdict' | 'agent-door' + /** + * The user's checked list. When present it is inlined into the prompt so the + * copied line carries the actual CIDs instead of pointing at a cids.txt the + * user would have to assemble themselves. + */ + cids?: ReadonlyArray } -export function AgentPrompt({ source }: AgentPromptProps) { +export function AgentPrompt({ source, cids }: AgentPromptProps) { const { copy, isCopied } = useCopyToClipboard() const plausible = usePlausible() + const prompt = buildAgentPrompt(cids) + async function handleCopy() { - const copied = await copy(AGENT_PROMPT) + const copied = await copy(prompt) if (copied) { - plausible(PLAUSIBLE_EVENTS.promptCopied, { props: { source } }) + plausible(PLAUSIBLE_EVENTS.promptCopied, { + props: { source, cidCount: cids?.length ?? 0 }, + }) } } return ( -
- - {AGENT_PROMPT} +
+ + {prompt} - + {isCopied ? 'Prompt copied to clipboard' : ''} diff --git a/src/app/ipfs2filecoin/components/CidListChecker.tsx b/src/app/ipfs2filecoin/components/CidListChecker.tsx index 14e25136..487daaa3 100644 --- a/src/app/ipfs2filecoin/components/CidListChecker.tsx +++ b/src/app/ipfs2filecoin/components/CidListChecker.tsx @@ -22,6 +22,15 @@ const PLACEHOLDER = [ 'QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG', ].join('\n') +/** The free-check facts, broken out so they scan as features rather than fine print. */ +const CHECK_FACTS = [ + 'Free, no wallet', + `Up to ${BROWSER_CHECK_ITEM_CAP.toLocaleString()} items`, + `${MAX_ITEM_SIZE_LABEL} per item`, + 'Runs in your browser', + 'Your list is never sent anywhere', +] + type Verdict = | { kind: 'empty' } | { kind: 'unreadable'; summary: CidListSummary } @@ -90,22 +99,23 @@ export function CidListChecker() {