From 1edbdcc3f57a5b1d5b22a6d57e594c5264e0ff46 Mon Sep 17 00:00:00 2001 From: Harpreet Singh Date: Thu, 1 Oct 2026 16:31:07 +0530 Subject: [PATCH 1/3] docs: refine FlowExplainer motion and dark theme Split the beat timeline into travel/land/recoil constants, add a scroll-into-view start via IntersectionObserver, the resolve-recoil packet beat on beat 2, arrival rings, arrow-based step nav to replace anonymous dots, and hover-pause feedback. Dark mode token flips match the ResolveFlow component and reduced-motion handling is complete. Co-Authored-By: Claude Code (cherry picked from commit 578fe1199fdad0026db4c39ce5e056583d6bb9a1) --- custom.css | 216 +++++++++++++++++++++++++++++------ snippets/flow-explainer.jsx | 217 +++++++++++++++++++++++++++++++----- 2 files changed, 370 insertions(+), 63 deletions(-) diff --git a/custom.css b/custom.css index 21e5541..0f5da1c 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,6 +236,32 @@ width: 100%; } +/* 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; +} + /* 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. */ @@ -241,7 +284,7 @@ font-weight: 600; letter-spacing: 0.09em; text-transform: uppercase; - color: var(--fx-faint); + color: var(--fx-muted); } .fx-toggle-row { display: flex; @@ -260,8 +303,13 @@ font-weight: 600; line-height: 1.4; cursor: pointer; - transition: color 200ms var(--fx-ease-out), background 200ms var(--fx-ease-out); + transition: color 200ms var(--fx-ease-out), background 200ms var(--fx-ease-out), + scale 150ms var(--fx-ease-out); } +/* The toggles are the main thing to touch here and had no press feedback at + all. `scale` rather than `transform` so it never fights a transform the + button picks up elsewhere; 0.96 because anything under 0.95 reads theatrical. */ +.fx-btn:active { scale: 0.96; } .fx-btn[data-on="true"] { background: color-mix(in srgb, var(--fx-accent) 18%, transparent); color: var(--fx-accent); @@ -287,13 +335,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 +352,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 { @@ -349,27 +400,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 +444,69 @@ } @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; } - 10% { opacity: 1; } - 78% { offset-distance: 100%; opacity: 1; } - 100% { offset-distance: 100%; opacity: 0; } + 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. The resolver does not answer — it reverts with a + gateway URL — so the packet leaves, stalls, and comes back in the resolver's + own hue before the real request runs. It is the one beat whose path is a + refusal, and drawing it like the other five was why readers bounced off it. */ +.fx-packet[data-kind="recoil"] { + fill: var(--fx-eth); + filter: drop-shadow(0 0 6px var(--fx-eth)); + animation: fx-recoil 560ms var(--fx-ease-io) both; +} +@keyframes fx-recoil { + 0% { offset-distance: 0%; opacity: 0; } + 12% { opacity: 1; } + 46% { offset-distance: 26%; opacity: 1; } + 54% { offset-distance: 26%; 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 +531,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 +580,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 +604,18 @@ 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-btn, .fx-navbtn, .fx-pause { + transition: none; + animation: none; + } .fx-node[data-on="true"] { transform: none; } + .fx-btn:active { scale: none; } + .fx-navbtn:active { transform: none; } } diff --git a/snippets/flow-explainer.jsx b/snippets/flow-explainer.jsx index 71da840..062ca5c 100644 --- a/snippets/flow-explainer.jsx +++ b/snippets/flow-explainer.jsx @@ -20,6 +20,31 @@ * 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 — toggles, 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 rejection — the resolver does not + * answer, it reverts with a gateway URL. It gets a recoil in the resolver's + * own hue before the real request runs, 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: * * 1. Everything lives INSIDE the exported function. Mintlify compiles snippets @@ -30,7 +55,9 @@ * 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 @@ -39,12 +66,20 @@ * 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). Changing either + number here means changing that split too. */ + const TRAVEL_MS = 900 + const LAND_MS = 180 + const RECOIL_MS = 560 + const SETTLE_MS = 400 const ROUTES = [ { @@ -111,6 +146,18 @@ export const FlowExplainer = () => { const [paused, setPaused] = useState(false) const [routeId, setRouteId] = useState('ens') const [storeId, setStoreId] = useState('onchain') + /* 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 route = ROUTES.find((r) => r.id === routeId) const store = STORES.find((s) => s.id === storeId) @@ -124,8 +171,8 @@ export const FlowExplainer = () => { { id: 'api', tone: 'api', x: 754, y: 216, w: 176, h: 54, label: 'Offchain records', sub: 'your API or database' }, ] - /* `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. */ const EDGES = [ { id: 'query', @@ -200,25 +247,94 @@ export const FlowExplainer = () => { }, ] + const step = BEATS[beat] + /* Beat 2 spends RECOIL_MS bouncing off the resolver before the real request + leaves, so everything timed off the packet shifts by the same lead. */ + const lead = beat === 1 ? RECOIL_MS : 0 + /* 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 = lead + TRAVEL_MS + LAND_MS + 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) + } + + /* A toggle changes what is being asked for, so the run restarts. Flipping + store at beat 4 otherwise left a payload on screen for a request that was + never made. */ + const retarget = (fn) => (value) => { + fn(value) + setManual(true) + setSettling(false) + setBeat(0) + } - const step = BEATS[beat] const isOn = (id) => step.active.includes(id) + /* Source then target, in every beat — the ring lands on the receiver. */ + const arriving = step.active[1] return (
setPaused(true)} onMouseLeave={() => setPaused(false)} + ref={setRootEl} >
@@ -230,7 +346,7 @@ export const FlowExplainer = () => { className="fx-btn" data-on={r.id === routeId} key={r.id} - onClick={() => setRouteId(r.id)} + onClick={() => retarget(setRouteId)(r.id)} type="button" > {r.label} @@ -248,7 +364,7 @@ export const FlowExplainer = () => { className="fx-btn" data-on={s.id === storeId} key={s.id} - onClick={() => setStoreId(s.id)} + onClick={() => retarget(setStoreId)(s.id)} type="button" > {s.label} @@ -273,6 +389,9 @@ export const FlowExplainer = () => { + + + {/* Every group label sits 8px above its own box, and every box keeps @@ -286,19 +405,25 @@ export const FlowExplainer = () => { Resolution infrastructure - {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 ( - + { ) })} + {/* The resolver's refusal, in the resolver's own hue: out a quarter of + the way and straight back, before the real request runs. */} + {EDGES.filter((e) => e.beat === beat && beat === 1).map((e) => ( + + ))} + {EDGES.filter((e) => e.beat === beat).map((e) => ( ))} @@ -356,15 +499,35 @@ export const FlowExplainer = () => { ) : 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}

+ - ))} -
- {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) => { @@ -441,7 +364,7 @@ export const FlowExplainer = () => { key={`${beat}-c-${e.id}`} /* Lands with the packet rather than ahead of it: the payload label used to appear before the request had left. */ - style={{ animationDelay: `${lead + TRAVEL_MS * 0.55}ms` }} + style={{ animationDelay: `${arriveMs * 0.55}ms` }} > { ) })} - {/* The resolver's refusal, in the resolver's own hue: out a quarter of - the way and straight back, before the real request runs. */} - {EDGES.filter((e) => e.beat === beat && beat === 1).map((e) => ( - - ))} - + {/* 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) => ( { height={n.h} key={`${beat}-l-${n.id}`} rx="12" - style={{ animationDelay: `${lead + TRAVEL_MS}ms` }} + style={{ animationDelay: `${arriveMs}ms` }} width={n.w} x={n.x} y={n.y} @@ -520,7 +434,7 @@ export const FlowExplainer = () => {
-

+

{beat + 1} {step.caption}

From 6314baf16f0dd9c96b3c94cae20fd5a586202438 Mon Sep 17 00:00:00 2001 From: Harpreet Singh Date: Fri, 30 Oct 2026 17:54:54 +0530 Subject: [PATCH 3/3] docs(universal-usernames): narrow the overview to v1 scope The page promised DNS-imported parents such as happy.brand.id and records read from an L2 or another chain. v1 supports a second-level .eth parent, one username label, and records served from the brand's HTTPS endpoint through HybridResolverV2's offchain route. The page now says that, who controls what (the brand is authoritative, only the exact parent owner configures, records are public), and what v1 does not do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019oGqmTa3TRAEt8JZnPPL6p --- overview/universal-usernames.mdx | 65 +++++++++++++++++++------------- 1 file changed, 38 insertions(+), 27 deletions(-) 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.