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