diff --git a/custom.css b/custom.css index 21e5541..f6be775 100644 --- a/custom.css +++ b/custom.css @@ -177,30 +177,47 @@ } /* ── FlowExplainer ──────────────────────────────────────────────────────── - The palette is self-contained and dark in both site themes: this card owns - its look rather than inheriting the page theme. The extra resets marked - "docs:" undo Mintlify prose styling the diagram never expects. */ + The card follows the page theme. Light values sit on .fx and the dark ones + override them under Mintlify's .dark class, the same flip .rf uses above — + so every rule below reads a token and none of them know which theme is on. + The extra resets marked "docs:" undo Mintlify prose styling the diagram + never expects. + + Two things are not symmetrical between the themes, both on purpose: + borders and the group fills. A light-mode hairline is alpha black so it + recedes into the surface; the same trick in dark mode glows, so the dark + values are alpha white at a lower weight. */ .fx { - --fx-ink: #0c0f17; - --fx-surface: #151926; - --fx-line: rgba(255, 255, 255, 0.11); - --fx-text: rgba(255, 255, 255, 0.93); - --fx-dim: rgba(255, 255, 255, 0.46); - --fx-faint: rgba(255, 255, 255, 0.24); - --fx-accent: #5aa9ff; + --fx-ink: #f7f9fc; + --fx-surface: #ffffff; + --fx-line: rgba(16, 24, 40, 0.1); + --fx-text: #101828; + --fx-dim: #5b6478; + --fx-faint: #98a2b3; + --fx-muted: #697182; + --fx-accent: #3d5beb; + --fx-glow: rgba(61, 91, 235, 0.34); + --fx-group-fill: rgba(16, 24, 40, 0.018); + --fx-group-stroke: rgba(16, 24, 40, 0.14); + --fx-tint-a: rgba(69, 103, 243, 0.07); + --fx-tint-b: rgba(124, 79, 196, 0.05); /* One hue per party, all far enough apart to stay separable when two light up at once. The accent belongs to motion — edges, chips, packets — so no - node borrows it. */ - --fx-wallet: #7fb2ff; - --fx-eth: #f2a54c; - --fx-gw: #4fc98a; - --fx-chain: #3fd3c6; - --fx-api: #bb8ef7; + node borrows it. Light-mode tones are darkened, not just reused: the dark + palette's tones sit around 75% lightness, which disappears on white. */ + --fx-wallet: #3b6fd4; + --fx-eth: #a86713; + --fx-gw: #137954; + --fx-chain: #0e756f; + --fx-api: #7343bd; --fx-ease-out: cubic-bezier(0.23, 1, 0.32, 1); --fx-ease-io: cubic-bezier(0.77, 0, 0.175, 1); + /* The packet's landing. Travel itself is `linear` — a request in flight is + progress-as-time, and easing both ends made it look like it hesitated. */ + --fx-ease-land: cubic-bezier(0.165, 0.84, 0.44, 1); /* Never set container-type here. Style containment on this element makes Chrome resolve every `fill: var(--fx-*)` inside the SVG to black — the @@ -210,8 +227,8 @@ border: 1px solid var(--fx-line); border-radius: 20px; background: - radial-gradient(90% 70% at 85% 0%, rgba(90, 169, 255, 0.1), transparent 62%), - radial-gradient(80% 70% at 5% 100%, rgba(187, 142, 247, 0.07), transparent 60%), + radial-gradient(90% 70% at 85% 0%, var(--fx-tint-a), transparent 62%), + radial-gradient(80% 70% at 5% 100%, var(--fx-tint-b), transparent 60%), var(--fx-ink); color: var(--fx-text); font-family: inherit; @@ -219,61 +236,30 @@ width: 100%; } -/* Toggles: the parent name on the left, where the records live on the right — - the two ends of the diagram they each control. Neither changes the path, - which is the point the card is making. */ -.fx-controls { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - gap: 16px 24px; - margin-bottom: 18px; -} -.fx-toggle { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 7px; -} -.fx-toggle[data-align="end"] { align-items: flex-end; text-align: right; } -.fx-toggle-label { - font-size: 10px; - font-weight: 600; - letter-spacing: 0.09em; - text-transform: uppercase; - color: var(--fx-faint); -} -.fx-toggle-row { - display: flex; - padding: 3px; - border-radius: 999px; - background: rgba(255, 255, 255, 0.04); -} -.fx-btn { - padding: 5px 13px; - border: 0; - border-radius: 999px; - background: transparent; - color: var(--fx-dim); - font-family: inherit; - font-size: 12px; - font-weight: 600; - line-height: 1.4; - cursor: pointer; - transition: color 200ms var(--fx-ease-out), background 200ms var(--fx-ease-out); -} -.fx-btn[data-on="true"] { - background: color-mix(in srgb, var(--fx-accent) 18%, transparent); - color: var(--fx-accent); -} -.fx-btn:focus-visible { outline: 2px solid var(--fx-accent); outline-offset: 3px; } -@media (hover: hover) and (pointer: fine) { - .fx-btn[data-on="false"]:hover { color: var(--fx-text); } -} -.fx-toggle-note { - font-size: 11px; - line-height: 1.4; - color: var(--fx-dim); +/* Dark theme. Same token names, flipped values — matching how .rf switches, + and how Mintlify marks the theme (.dark, with the data-theme form kept as + the fallback the sibling component already relies on). */ +.dark .fx, +html[data-theme="dark"] .fx { + --fx-ink: #0c0f17; + --fx-surface: #151926; + --fx-line: rgba(255, 255, 255, 0.11); + --fx-text: rgba(255, 255, 255, 0.93); + --fx-dim: rgba(255, 255, 255, 0.46); + --fx-faint: rgba(255, 255, 255, 0.24); + --fx-muted: #8b93a5; + --fx-accent: #5aa9ff; + --fx-glow: rgba(90, 169, 255, 0.55); + --fx-group-fill: rgba(255, 255, 255, 0.018); + --fx-group-stroke: rgba(255, 255, 255, 0.16); + --fx-tint-a: rgba(90, 169, 255, 0.1); + --fx-tint-b: rgba(187, 142, 247, 0.07); + + --fx-wallet: #7fb2ff; + --fx-eth: #f2a54c; + --fx-gw: #4fc98a; + --fx-chain: #3fd3c6; + --fx-api: #bb8ef7; } .fx-scroll { overflow-x: auto; overscroll-behavior-x: contain; } @@ -287,13 +273,13 @@ /* Layer groups */ .fx-group rect { - fill: rgba(255, 255, 255, 0.018); - stroke: rgba(255, 255, 255, 0.16); + fill: var(--fx-group-fill); + stroke: var(--fx-group-stroke); stroke-width: 1; stroke-dasharray: 4 5; } .fx-group text { - fill: var(--fx-faint); + fill: var(--fx-muted); font-size: 10px; font-weight: 600; letter-spacing: 0.09em; @@ -304,6 +290,9 @@ .fx-node { transform-box: fill-box; transform-origin: center; + /* Holds the lit node on the GPU across the scale, which kills the ~1px + shift the CPU handoff put on it at each end. */ + will-change: transform; transition: transform 220ms var(--fx-ease-out); } .fx-node rect { @@ -316,9 +305,6 @@ transform: scale(1.03); filter: drop-shadow(0 4px 16px color-mix(in srgb, var(--tone) 26%, transparent)); } -/* The store this lookup does not use. Present, so the either/or is visible; - dimmed, so it never reads as a second request. */ -.fx-node[data-muted="true"] { opacity: 0.3; } .fx-node[data-on="true"] rect { stroke: var(--tone); fill: color-mix(in srgb, var(--tone) 15%, var(--fx-surface)); @@ -349,27 +335,43 @@ transition: stroke 220ms var(--fx-ease-out); } .fx-edge[data-on="true"] { stroke: var(--fx-accent); } +/* Already traversed. Holding the trail instead of resetting it to faint is + what makes one full lookup legible as a unit rather than six unrelated + flashes; [data-settling] wipes the whole trail at the loop boundary. */ +.fx-edge[data-done="true"] { stroke: color-mix(in srgb, var(--fx-accent) 30%, transparent); } .fx-arrow { fill: var(--fx-faint); } .fx-arrow-on { fill: var(--fx-accent); } +.fx-arrow-done { fill: color-mix(in srgb, var(--fx-accent) 30%, transparent); } +.fx[data-settling="true"] .fx-edge[data-done="true"] { + stroke: var(--fx-faint); + transition: stroke 400ms var(--fx-ease-out); +} +.fx[data-settling="true"] .fx-edge-num[data-done="true"] { + fill: var(--fx-muted); + transition: fill 400ms var(--fx-ease-out); +} .fx-edge-num { - fill: var(--fx-faint); + fill: var(--fx-muted); font-size: 11px; font-weight: 600; text-anchor: middle; transition: fill 220ms var(--fx-ease-out); } .fx-edge-num[data-on="true"] { fill: var(--fx-accent); } +.fx-edge-num[data-done="true"] { fill: color-mix(in srgb, var(--fx-accent) 30%, transparent); } /* Payload chips — one per active edge, remounted with the beat. */ -.fx-chip { animation: fx-chip 320ms var(--fx-ease-out) both; } +/* 200ms, and the component delays it to just before the packet arrives — + the payload label used to appear before the request had left. */ +.fx-chip { animation: fx-chip 200ms var(--fx-ease-out) both; } .fx-chip rect { fill: color-mix(in srgb, var(--fx-accent) 14%, var(--fx-ink)); stroke: color-mix(in srgb, var(--fx-accent) 38%, transparent); stroke-width: 1; } .fx-chip text { - fill: color-mix(in srgb, var(--fx-accent) 62%, #ffffff); + fill: color-mix(in srgb, var(--fx-accent) 62%, var(--fx-text)); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; letter-spacing: -0.01em; @@ -377,19 +379,71 @@ } @keyframes fx-chip { from { opacity: 0; transform: translateY(3px); } } -/* Travelling packet — remounted each beat, so it always starts clean. */ +/* Travelling packet — remounted each beat, so it always starts clean. + 1080ms = TRAVEL_MS 900 + LAND_MS 180 in flow-explainer.jsx, and the 83.33% + stop is that split. Changing one means changing the other. + + `scale` is the independent transform property on purpose: offset-path drives + the element through the transform chain, so animating `transform` here would + throw the packet back to the origin. Per spec, scale composes ahead of the + offset transform and leaves the path intact. */ .fx-packet { fill: var(--fx-accent); - filter: drop-shadow(0 0 6px var(--fx-accent)); + filter: drop-shadow(0 0 6px var(--fx-glow)); offset-distance: 0%; opacity: 0; - animation: fx-flow 1500ms var(--fx-ease-io) forwards; + will-change: offset-distance, opacity; + animation: fx-flow 1080ms linear both; } @keyframes fx-flow { + 0% { offset-distance: 0%; opacity: 0; scale: 1; + animation-timing-function: linear; } + 6% { opacity: 1; } + 83.33% { offset-distance: 100%; opacity: 1; scale: 1; + animation-timing-function: var(--fx-ease-land); } + 100% { offset-distance: 100%; opacity: 0; scale: 0.2; } +} + +/* Beat 2, and only beat 2. HybridResolverV2 does not answer: it reverts with + OffchainLookup. So the packet reaches the resolver, holds, and comes back in + the resolver's own hue instead of travelling on. It is the one beat whose + path is a refusal, and drawing it like the others was why readers bounced + off it. 900ms = RECOIL_MS, and the 45% stop is RECOIL_ARRIVE, both in + flow-explainer.jsx. */ +.fx-packet[data-kind="recoil"] { + fill: var(--fx-eth); + filter: drop-shadow(0 0 6px var(--fx-eth)); + animation: fx-recoil 900ms var(--fx-ease-io) both; +} +@keyframes fx-recoil { 0% { offset-distance: 0%; opacity: 0; } 10% { opacity: 1; } - 78% { offset-distance: 100%; opacity: 1; } - 100% { offset-distance: 100%; opacity: 0; } + 45% { offset-distance: 100%; opacity: 1; } + 55% { offset-distance: 100%; opacity: 1; } + 92% { offset-distance: 0%; opacity: 1; } + 100% { offset-distance: 0%; opacity: 0; } +} + +/* Arrival ring on the receiving node. A sibling one-shot rather than an + animation on the node itself, which would clobber the transform and colour + transitions data-on already drives. */ +.fx-land { + fill: none; + stroke: var(--tone); + stroke-width: 1.25; + transform-box: fill-box; + transform-origin: center; + opacity: 0; + animation: fx-land 180ms var(--fx-ease-land) both; +} +.fx-land[data-tone="wallet"] { --tone: var(--fx-wallet); } +.fx-land[data-tone="eth"] { --tone: var(--fx-eth); } +.fx-land[data-tone="gw"] { --tone: var(--fx-gw); } +.fx-land[data-tone="chain"] { --tone: var(--fx-chain); } +.fx-land[data-tone="api"] { --tone: var(--fx-api); } +@keyframes fx-land { + from { opacity: 0.75; scale: 1; } + to { opacity: 0; scale: 1.07; } } /* Caption + rail */ @@ -414,6 +468,9 @@ animation: fx-caption 260ms var(--fx-ease-out); } @keyframes fx-caption { from { opacity: 0; transform: translateY(4px); } } +/* Stepped here on purpose: no fade. A reader clicking through at their own + pace is fighting a cross-fade on every press. */ +.fx-caption[data-manual="true"] { animation: none; } .fx-step { flex: 0 0 auto; @@ -460,8 +517,22 @@ color: var(--fx-accent); } } -.fx-navbtn:active { transform: scale(0.92); } +.fx-navbtn:active { transform: scale(0.96); } .fx-navbtn:focus-visible { outline: 2px solid var(--fx-accent); outline-offset: 3px; } +/* Hover-to-pause had no acknowledgement of any kind, so the interaction read + as dead. */ +.fx-pause { + flex: 0 0 auto; + width: 14px; + margin-right: 2px; + font-size: 9px; + letter-spacing: 1px; + color: var(--fx-accent); + opacity: 0; + transition: opacity 150ms var(--fx-ease-out); +} +.fx-pause[data-on="true"] { opacity: 1; } + .fx-count { min-width: 40px; text-align: center; @@ -470,8 +541,17 @@ color: var(--fx-dim); } +/* The component also stops advancing on its own under this query — hiding the + packet while the timer kept running was the worst of both, mutating the + caption under the reader with the cue that explained it removed. The step + arrows are then the only control. */ @media (prefers-reduced-motion: reduce) { - .fx-packet { display: none; } - .fx-chip, .fx-node, .fx-caption { transition: none; animation: none; transform: none; } + .fx-packet, .fx-land { display: none; } + .fx-chip, .fx-caption, .fx-node, .fx-node rect, .fx-node-label, + .fx-edge, .fx-edge-num, .fx-navbtn, .fx-pause { + transition: none; + animation: none; + } .fx-node[data-on="true"] { transform: none; } + .fx-navbtn:active { transform: none; } } diff --git a/overview/universal-usernames.mdx b/overview/universal-usernames.mdx index d42b4ba..1a7b9f7 100644 --- a/overview/universal-usernames.mdx +++ b/overview/universal-usernames.mdx @@ -17,68 +17,79 @@ Almost every product that ships usernames picks one of two approaches. Both leav ## A better way with ENS -Every username you've already issued gets a matching ENS name. One rule, applied across your whole user base: +Every username you've already issued gets a matching ENS name under a `.eth` name you own. One rule, applied across your whole user base: ``` -Your username: happy.brand -ENS name: happy.brand.eth - or happy.brand.id, using a domain you already own +Your username: happy +Your .eth name: brand.eth +ENS name: happy.brand.eth ``` Your system stays the source of truth and no data moves. The username just gains a form that ENS-aware wallets already know how to read. -This does not turn `.brand` into a new ENS top-level domain. Each user gets an ENS name for the identity they already have. +This does not create a new ENS top-level domain. Each user gets an ENS name under your `.eth` name for the identity they already have. -## ENS isn't limited to .eth or Ethereum +## How it works -Two (wrong) assumptions people usually make. +You run a small HTTPS endpoint that returns a JSON profile for one username: display name, avatar, other text records, and addresses. You connect `brand.eth` to that endpoint in the Namespace App, then point `brand.eth` at Namespace's HybridResolverV2 resolver. -**You don't need a `.eth` name.** +From then on, a lookup for `happy.brand.eth` works like this: -`happy.brand.eth` and `happy.brand.id` resolve identically. The first means registering `brand.eth`. The second means taking a domain you already own, turning on DNSSEC, and [importing it into ENS](https://docs.ens.domains/learn/dns/). Same result, so pick on branding. +1. The wallet asks ENS. HybridResolverV2 has no record stored for `happy`, so it answers with a CCIP-Read lookup that sends the wallet to the Namespace gateway. +2. The gateway calls your endpoint for `happy`, checks the response, and signs the record the wallet asked for. +3. HybridResolverV2 checks the signature and its expiry on Ethereum before the wallet uses the answer. -**ENS isn't only for Ethereum.** +Nobody claims, mints, or registers anything per user, and nothing is written onchain per user. If a username exists in your system, it resolves. If you rename, suspend, or reassign it there, Namespace serves the change within 60 seconds. Wallets and apps can cache answers for longer. -One name holds a separate address record for every chain, and they don't have to be EVM chains. Bitcoin, Solana, Base, Arbitrum, and Ethereum sit side by side on the same name, along with profile fields like display name and avatar. Your user has one name and it works everywhere they hold funds. +One name can hold an address for every chain your users hold funds on, including non-EVM chains such as Bitcoin and Solana, next to profile fields like display name and avatar. Your endpoint returns all of them in one response. ## Follow one lookup -The diagram is a single name being looked up, start to finish. Its two toggles are the decisions above: the parent name, and where records are read from. Neither changes the path. - -It loops on its own. Hover to pause, or use the dots to step through it. +The diagram is one lookup for `happy.brand.eth`, start to finish. It starts when it scrolls into view and loops on its own. Hover to pause, or use the arrows to step through it. -Nothing there asked you to move your users, usernames, or records. The wallet asked a question, your system answered, the wallet showed the result. +Nothing there asked you to move your users, usernames, or records. The wallet asked a question, your system answered, and Ethereum checked the signature. ## Your records stay where they are -Records can live in the database you run today, on an L2, or on another chain. No second registry on Ethereum, nothing to keep in sync. Whatever answers the question inside your product answers it here too. +Your endpoint reads from the database or service you run today. Namespace doesn't copy your user list or store profiles. The gateway keeps each response for at most 60 seconds, so a burst of lookups for one user costs your endpoint one request. + +## Who controls what + +Your system decides which usernames exist, what their records say, and who each name points to. Users don't get independent ownership of their name, so they can't transfer it or edit it outside your product. + +Only the wallet that owns `brand.eth` can connect it, replace the endpoint, pause it, or disconnect it. Each change needs a signature from that wallet but no transaction. + +Everything that resolves is public. Anyone can look up `happy.brand.eth` and read its records, even when your endpoint requires a bearer token from Namespace. ## Decide before you build -1. **The naming rule.** A parent name and a predictable pattern, such as `happy.brand` becoming `happy.brand.eth`. Nobody should have to learn a second identity. -2. **Where the answer comes from.** Your database, a contract in your existing registry, or a proof Ethereum can verify. -3. **Who runs it.** Someone holds the parent name, keeps the lookup path online, and fixes it when it breaks. +1. **The parent name.** A `.eth` name you own directly below `.eth`, such as `brand.eth`. Each username becomes one label under it. +2. **The endpoint.** The service that answers `GET /{username}` with a profile, and whether it requires a bearer token. +3. **Who runs it.** Someone holds the parent name, keeps the endpoint online, and fixes it when it breaks. ## What this does not do -- Your existing TLD does not become part of ENS. Users gain an ENS name; `.brand` stays yours. +- The parent has to be a `.eth` name directly below `.eth`. DNS names imported into ENS and deeper names such as `team.brand.eth` can't be connected. +- Each username is a single label. `happy.brand.eth` works; `team.happy.brand.eth` doesn't. +- Records come from your endpoint over HTTPS. They aren't read from a contract, an L2, or another chain. +- Reverse resolution isn't supported, so an address can't show `happy.brand.eth` as its primary name. - Support varies. Most major wallets resolve these names, but not every app does, so test the ones your users actually use. -- Anything served this way is public. Addresses, avatars, and profile fields are readable by anyone who asks. + + +Universal usernames is rolling out to selected brands first. [Talk to us](/user-guide/custom-solutions) to connect your parent name. + ## Next steps - + - Compare offchain and onchain names before you pick how to issue them. - - - Create names and manage their records with the Offchain Manager SDK. + See how universal usernames compare with offchain and onchain subnames. - Bring your existing naming service and we will map it onto ENS with you. + Plan your parent name, endpoint, and rollout with the Namespace team. diff --git a/snippets/flow-explainer.jsx b/snippets/flow-explainer.jsx index 71da840..0590b39 100644 --- a/snippets/flow-explainer.jsx +++ b/snippets/flow-explainer.jsx @@ -5,20 +5,43 @@ * import { FlowExplainer } from '/snippets/flow-explainer.jsx' * * - * Boxed, looping explainer of one lookup, laid out to match the protocol - * diagram: wallet -> ENS resolver -> gateway -> records -> signed response -> - * wallet. Motion is CSS keyframes remounted per beat, so it runs off the main - * thread. + * Boxed, looping explainer of one Universal Usernames lookup, start to finish: + * wallet -> ENS Universal Resolver -> HybridResolverV2 reverts with + * OffchainLookup -> Namespace gateway -> brand endpoint -> signed answer -> + * verified callback. Motion is CSS keyframes remounted per beat, so it runs off + * the main thread. * - * Two toggles, and neither one changes the path: + * The client, not the resolver, makes the gateway request. Steps 3 and 6 run + * on arcs that go around the Ethereum group, straight between the wallet and + * the gateway, so the diagram never implies a contract fetching anything. + * Step 7 passes under the Universal Resolver node on its way to + * HybridResolverV2, which is the callback route the client actually takes. * - * - Parent name. A `.eth` name and a DNS domain imported into ENS resolve - * identically, so only the string being resolved moves. - * - Records. Step 3 reads from ONE store, never both. On-chain means an RPC - * call to a registry contract; off-chain means an HTTPS call to a system you - * already run. Nothing is fanned out and nothing is merged — the unselected - * store stays on the canvas, muted, so the choice is visible without drawing - * a path nobody takes. + * MOTION BUDGET. Two kinds of motion live here and they follow different rules. + * + * - Narrative motion — the packet crossing a wire, the beat loop. This is + * content: the duration IS the explanation, so it is deliberately far above + * the sub-300ms ceiling that governs interface feedback. Travel is `linear`, + * because a request in flight is progress-as-time; easing it at both ends + * made the request look like it hesitated before leaving and again before + * arriving. + * - Interface motion — step arrows, the pause cue, chip and caption + * enters. This is feedback and stays under 300ms, transform/opacity only, + * transitions for anything reversible and keyframes only for one-shots that + * always run to completion. + * + * Three beats-worth of timing detail, since none of it is guessable: + * + * 1. Each beat is travel + land + dwell, and the dwell scales with caption + * length. A flat beat left ~900ms of dead air after short captions and cut + * the long ones off mid-read. + * 2. Beat 2 is the only beat whose path is a refusal. HybridResolverV2 does + * not answer; it reverts with OffchainLookup. Its packet reaches the + * resolver and comes straight back in the resolver's own hue instead of + * travelling on, because "the resolver stores no answer" is the sentence + * readers bounce off. + * 3. Traversed edges hold at a dim accent instead of resetting, then wipe + * together at the loop boundary, so one full lookup is visible as a unit. * * Platform constraints, all forced by Mintlify: * @@ -30,140 +53,126 @@ * 3. CSS modules are unavailable, so the styles live as `.fx-*` classes in * /custom.css. Any .css file in the content dir is auto-loaded site-wide. * 4. Named export only; hooks are pre-injected, so there is no import line and - * no `React` global. + * no `React` global. Only useState and useEffect are relied on — the + * element handle for the viewport observer is a callback ref held in state + * rather than useRef, which is not guaranteed to be injected. * 5. Plain
s, never
. Mintlify wraps a
in * react-medium-image-zoom, which forced two bugs: clicking the diagram - * lifted the bare into a modal, losing the caption and dots, and the + * lifted the bare into a modal, losing the caption and step arrows, and the * zoom wrapper is a shrink-to-fit that inherited the svg's min-width * and pushed the whole page into horizontal scroll on mobile. The * keeps role="img" and its aria-label, which is what actually carries the * diagram to a screen reader. * - * The palette is deliberately self-contained and dark in both site themes, so - * the card reads identically wherever it is dropped. + * The palette follows the page theme. Light values sit on `.fx` and the dark + * ones override them under Mintlify's `.dark` class, matching how the older + * `.rf` diagram switches. Every rule reads a token, so nothing in here knows + * which theme is on. */ export const FlowExplainer = () => { - const BEAT_MS = 2400 + /* Narrative timing. TRAVEL and LAND together are one packet animation; + custom.css splits fx-flow at TRAVEL / (TRAVEL + LAND). RECOIL is the whole + fx-recoil animation, which reaches the resolver at RECOIL_ARRIVE of its + run. Changing any of these means changing custom.css too. */ + const TRAVEL_MS = 900 + const LAND_MS = 180 + const RECOIL_MS = 900 + const RECOIL_ARRIVE = 0.45 + const SETTLE_MS = 400 - const ROUTES = [ - { - id: 'ens', - label: '.eth name', - name: 'happy.brand.eth', - note: 'brand.eth registered in ENS', - }, - { - id: 'dns', - label: 'DNS name', - name: 'happy.brand.id', - note: 'brand.id imported into ENS with DNSSEC', - }, - ] - - /* Geometry lives on the store: step 3 draws one path out and step 4 draws - one path back, to whichever store is selected. */ - const STORES = [ - { - id: 'onchain', - label: 'Onchain', - node: 'chain', - note: 'one RPC call to your registry contract', - lookup: 'The gateway makes one RPC call and reads the record from your registry contract.', - back: 'The contract returns the record. Nothing else is queried.', - out: { - d: 'M662,144 C700,140 712,73 750,73', - nx: 706, - ny: 100, - chip: { text: 'eth_call · recordOf(node)', x: 590, y: 112 }, - }, - ret: { - d: 'M750,92 C712,92 700,160 664,164', - nx: 718, - ny: 134, - /* Response lands in the same slot the request left from. */ - chip: { text: 'addr(60) 0x1a9C…4b2f · avatar', x: 600, y: 112 }, - }, - }, - { - id: 'offchain', - label: 'Offchain', - node: 'api', - note: 'one HTTPS request to your API', - lookup: 'The gateway makes one HTTPS request and reads the record from the system you already run.', - back: 'Your API returns the record. Nothing else is queried.', - out: { - d: 'M662,172 C700,176 712,243 750,243', - nx: 706, - ny: 218, - chip: { text: 'GET /records/happy', x: 590, y: 206 }, - }, - ret: { - d: 'M750,224 C712,224 700,180 664,176', - nx: 718, - ny: 196, - chip: { text: 'addr(60) 0x1a9C…4b2f · avatar', x: 600, y: 206 }, - }, - }, - ] + const NAME = 'happy.brand.eth' const [beat, setBeat] = useState(0) const [paused, setPaused] = useState(false) - const [routeId, setRouteId] = useState('ens') - const [storeId, setStoreId] = useState('onchain') - - const route = ROUTES.find((r) => r.id === routeId) - const store = STORES.find((s) => s.id === storeId) - const NAME = route.name + /* True while the reader stepped here themselves. A deliberate step gets no + caption fade — someone clicking through at their own pace is fighting a + 260ms cross-fade on every press. */ + const [manual, setManual] = useState(false) + /* The 400ms at the end of the loop where the traversed trail wipes. */ + const [settling, setSettling] = useState(false) + const [reduce, setReduce] = useState(false) + /* Nothing autoplays until the card is actually on screen: a loop that starts + at mount is animating page-load resting state, and burns frames above the + fold where nobody is looking. */ + const [visible, setVisible] = useState(false) + const [rootEl, setRootEl] = useState(null) const NODES = [ - { id: 'wallet', tone: 'wallet', x: 24, y: 130, w: 116, h: 56, label: 'Wallet / App' }, - { id: 'resolver', tone: 'eth', x: 250, y: 130, w: 160, h: 56, label: 'ENS Resolver', sub: 'CCIP-Read' }, - { id: 'gateway', tone: 'gw', x: 526, y: 130, w: 136, h: 56, label: 'CCIP Gateway' }, - { id: 'chain', tone: 'chain', x: 754, y: 46, w: 176, h: 54, label: 'Onchain records', sub: 'your registry contract' }, - { id: 'api', tone: 'api', x: 754, y: 216, w: 176, h: 54, label: 'Offchain records', sub: 'your API or database' }, + { id: 'wallet', tone: 'wallet', x: 16, y: 130, w: 112, h: 56, label: 'Wallet / App' }, + { id: 'universal', tone: 'chain', x: 184, y: 130, w: 152, h: 56, label: 'Universal Resolver', sub: 'ENS' }, + { id: 'resolver', tone: 'eth', x: 392, y: 130, w: 160, h: 56, label: 'HybridResolverV2', sub: 'brand.eth resolver' }, + { id: 'gateway', tone: 'gw', x: 624, y: 130, w: 148, h: 56, label: 'Namespace gateway', sub: 'CCIP-Read' }, + { id: 'endpoint', tone: 'api', x: 816, y: 130, w: 136, h: 56, label: 'Your endpoint', sub: 'api.brand.example' }, ] - /* `beat` is the step an edge carries its packet on; `delay` staggers edges - that share one. `chip` is the payload shown while that beat plays. */ + /* `beat` is the step an edge carries its packet on. `chip` is the payload + shown while that beat plays. Requests run on the upper lane (y 148) and + answers on the lower one (y 170). */ const EDGES = [ { id: 'query', beat: 0, num: '1', - nx: 193, + nx: 156, ny: 140, - d: 'M140,148 L246,148', - chip: { text: `resolve("${NAME}")`, x: 193, y: 66 }, + d: 'M128,148 L180,148', + chip: { text: `resolve("${NAME}", addr)`, x: 156, y: 76 }, }, { - id: 'ccip', + id: 'lookup', beat: 1, num: '2', - nx: 466, + kind: 'recoil', + nx: 364, ny: 140, - d: 'M410,148 L522,148', - chip: { text: 'GET /lookup/{sender}/{data}.json', x: 466, y: 84 }, + d: 'M336,148 L388,148', + chip: { text: 'revert OffchainLookup(…)', x: 364, y: 76 }, }, - { id: 'lookup', beat: 2, num: '3', ...store.out }, - { id: 'return', beat: 3, num: '4', ...store.ret }, { - id: 'signed', + id: 'ccip', + beat: 2, + num: '3', + nx: 110, + ny: 36, + d: 'M72,130 L72,60 Q72,44 88,44 L682,44 Q698,44 698,60 L698,126', + chip: { text: 'GET /offchain-resolver/…/{data}.json', x: 400, y: 24 }, + }, + { + id: 'fetch', + beat: 3, + num: '4', + nx: 794, + ny: 140, + d: 'M772,148 L812,148', + chip: { text: 'GET /ens/users/happy', x: 794, y: 76 }, + }, + { + id: 'profile', beat: 4, num: '5', - nx: 466, + nx: 794, ny: 186, - d: 'M522,170 L412,170', - chip: { text: '200 OK · sig 0x1c8f…9d02', x: 466, y: 216 }, + d: 'M816,170 L776,170', + chip: { text: '200 · {"version":1,…}', x: 794, y: 228 }, }, { - id: 'records', + id: 'signed', beat: 5, num: '6', - nx: 194, + nx: 110, + ny: 288, + d: 'M698,186 L698,256 Q698,272 682,272 L88,272 Q72,272 72,256 L72,190', + chip: { text: 'addr 0x1111…1111 · expires · sig', x: 400, y: 296 }, + }, + { + id: 'callback', + beat: 6, + num: '7', + nx: 156, ny: 186, - d: 'M246,170 L142,170', - chip: { text: 'addr(60) 0x1a9C…4b2f', x: 194, y: 216 }, + d: 'M128,170 L388,170', + chip: { text: 'resolveWithProof(…)', x: 262, y: 228 }, }, ] @@ -171,97 +180,127 @@ export const FlowExplainer = () => { const CHIP_H = 19 const chipW = (text) => text.length * 5.75 + 16 + /* `active` is source then target; the arrival ring lands on the second + entry. Step 7 also lights the Universal Resolver it passes through. */ const BEATS = [ { - active: ['wallet', 'resolver'], - caption: `A wallet asks Ethereum to resolve ${NAME}.`, + active: ['wallet', 'universal'], + caption: `A wallet asks the ENS Universal Resolver for the address of ${NAME}.`, + }, + { + active: ['universal', 'resolver'], + caption: + "brand.eth uses Namespace's HybridResolverV2. It has no stored record for happy, so it reverts with OffchainLookup.", }, { - active: ['resolver', 'gateway'], + active: ['wallet', 'gateway'], caption: - 'The resolver stores no answer. It reverts with a gateway URL — that is CCIP-Read.', + "Contracts can't make HTTP calls, so the wallet's ENS library sends the lookup to the Namespace gateway itself.", }, { - active: ['gateway', store.node], - caption: store.lookup, + active: ['gateway', 'endpoint'], + caption: 'The gateway finds the endpoint connected to brand.eth and requests happy from it.', }, { - active: [store.node, 'gateway'], - caption: store.back, + active: ['endpoint', 'gateway'], + caption: "Your endpoint returns happy's profile as JSON, or 404 if there is no such user.", }, { - active: ['gateway', 'resolver'], - caption: 'The gateway signs the response so nothing can be swapped in transit.', + active: ['gateway', 'wallet'], + caption: + 'The gateway validates the profile, encodes the address and signs it. The signature expires within 60 seconds.', }, { - active: ['resolver', 'wallet'], + active: ['wallet', 'resolver', 'universal'], caption: - 'The resolver verifies that signature on Ethereum, and the wallet gets its records.', + 'The wallet passes the signed answer back. HybridResolverV2 checks the signer and expiry, then returns the address.', }, ] + const step = BEATS[beat] + const recoil = EDGES.some((e) => e.beat === beat && e.kind === 'recoil') + /* When the packet reaches the receiving node, and when its animation ends. + The chip and the arrival ring key off the first, the beat clock off the + second. */ + const arriveMs = recoil ? RECOIL_MS * RECOIL_ARRIVE : TRAVEL_MS + const packetMs = recoil ? RECOIL_MS : TRAVEL_MS + LAND_MS + /* Dwell scales with how much there is to read, clamped so a one-line caption + still holds long enough to land and a long one never stalls the loop. */ + const dwellMs = Math.min(1600, Math.max(700, step.caption.length * 22)) + const beatMs = packetMs + dwellMs + + useEffect(() => { + const mq = window.matchMedia('(prefers-reduced-motion: reduce)') + const sync = () => setReduce(mq.matches) + sync() + mq.addEventListener('change', sync) + return () => mq.removeEventListener('change', sync) + }, []) + + useEffect(() => { + if (!rootEl || typeof IntersectionObserver === 'undefined') return + const io = new IntersectionObserver( + ([entry]) => setVisible(entry.isIntersecting), + { threshold: 0.35 }, + ) + io.observe(rootEl) + return () => io.disconnect() + }, [rootEl]) + + /* Fail open. MDX compiles every tag through its own component table, so the + callback ref above is not guaranteed to reach a DOM node — and a diagram + that silently never plays is far worse than one that plays off screen. + Refs attach before effects flush, so a null here means forwarding really + did fail rather than that we looked too early. */ + useEffect(() => { + if (rootEl && typeof IntersectionObserver !== 'undefined') return + setVisible(true) + }, [rootEl]) + /* Keyed on `beat` as well as `paused`, so stepping with the arrows restarts the clock instead of leaving a half-spent interval to fire under the - reader — the whole point of a manual step is getting a full beat to look. */ + reader — the whole point of a manual step is getting a full beat to look. + Under reduced motion nothing advances on its own at all: hiding the packet + but leaving the timer running is the worst of both, mutating the caption + under the reader with the cue that explained it removed. The arrows are + then the only control, which is what they are there for. */ useEffect(() => { - if (paused) return - const id = setTimeout(() => setBeat((b) => (b + 1) % BEATS.length), BEAT_MS) - return () => clearTimeout(id) - }, [paused, beat]) + if (paused || reduce || !visible) return + const last = beat === BEATS.length - 1 + const wipe = setTimeout(() => { + if (last) setSettling(true) + }, beatMs) + const next = setTimeout(() => { + setSettling(false) + setManual(false) + setBeat((b) => (b + 1) % BEATS.length) + }, beatMs + (last ? SETTLE_MS : 0)) + return () => { + clearTimeout(wipe) + clearTimeout(next) + } + }, [paused, beat, reduce, visible, beatMs]) - const go = (delta) => setBeat((b) => (b + delta + BEATS.length) % BEATS.length) + const go = (delta) => { + setManual(true) + setSettling(false) + setBeat((b) => (b + delta + BEATS.length) % BEATS.length) + } - const step = BEATS[beat] const isOn = (id) => step.active.includes(id) + const arriving = step.active[1] return (
setPaused(true)} onMouseLeave={() => setPaused(false)} + ref={setRootEl} > -
-
- Parent name -
- {ROUTES.map((r) => ( - - ))} -
- {route.note} -
-
- Records -
- {STORES.map((s) => ( - - ))} -
- {store.note} -
-
-
{ + + + {/* Every group label sits 8px above its own box, and every box keeps - 26px of air around what it holds. */} + at least 8px of air around the nodes it holds. */} - - Client interface - - Naming layer - - Resolution infrastructure + + Client + + Ethereum + + Offchain - {EDGES.map((e) => ( - - ))} + {EDGES.map((e) => { + const on = e.beat === beat + const done = !on && e.beat < beat + return ( + + ) + })} {EDGES.filter((e) => e.num).map((e) => ( { {EDGES.filter((e) => e.beat === beat && e.chip).map((e) => { const w = chipW(e.chip.text) return ( - + { ) })} + {/* A recoil edge carries the resolver's refusal instead of a request + that travels on: out to the resolver and straight back, in the + resolver's own hue. */} {EDGES.filter((e) => e.beat === beat).map((e) => ( ))} {NODES.map((n) => ( { ) : null} ))} + + {/* Arrival ring. A sibling one-shot rather than an animation on the + node itself, which would clobber the transform and colour + transitions data-on is already driving. */} + {NODES.filter((n) => n.id === arriving).map((n) => ( + + ))}
-

+

{beat + 1} {step.caption}

+