Skip to content

Commit 5ee4273

Browse files
sktbrdclaude
andauthored
feat(stake): count toward the next milestone, not just the 100k ladder (#252)
* feat(stake): count toward the next milestone, not just the 100k ladder The campaign tells people to "track progress toward the 10,000 MOR milestone", but the only progress on the page measured against SUBNET_GOAL_MOR (100,000). At 7,453 MOR staked it answered 7% to a reader who arrived expecting 74% — a tenth of what the announcement had promised, from a page that was not wrong, just answering a different question. Lead with the active tier: "7,453 / 10,000 MOR — 74%" plus what it unlocks. The 100k bar stays underneath as macro context, demoted to micro type and a neutral fill so green marks the figure that matters now. Derived, never pinned to 10k. `nextMilestone()` returns the lowest tier the subnet has not reached, so the day 10k lands the page starts counting toward 15k on its own; once every tier is behind us it returns null and the block says so instead of dividing by a milestone that no longer exists. `floor` rather than `round` on both percentages: at 9,999 of 10,000 rounding prints "100%" next to a milestone that has not been reached, which is the same kind of misreport this change exists to remove. Same on-chain figure as before — one read, two framings, no second source. useGnarsSubnet now reports loading/ready/error because `totalStaked` starts at 0, and rendering that 0 as fact claims "nobody has staked" while the RPC is in flight. A failed refetch after a good read keeps the last good figure. `milestones.<id>` now holds only the deliverable, with `milestoneRow` composing "{n} MOR staked: {what}" for the ladder, so the indicator and the checklist share one copy source instead of duplicating twelve strings across two locales. Drops `goal` and `morStaked`, dead once the old header went. EN and pt-BR stay at 287 keys each, same order. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(stake): keep the tier figure and its percentage on one line at 390px At 390px the pair needed 252px of a 254px column — two pixels short — so the percentage wrapped onto its own line, left-aligned under the figure, where it read as a separate statistic rather than the share OF the number above it, and pushed the progress bar down. One step down the type scale at base, full size from `sm`. The pair now needs 217px, leaving 37px of headroom — which also covers the wider labels the ladder produces later ("10,000 / 15,000 MOR" is longer than today's). Desktop is untouched: still 24px, still one line. No structural change — three utility classes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 740ffcd commit 5ee4273

6 files changed

Lines changed: 216 additions & 58 deletions

File tree

messages/en/stake.json

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -278,22 +278,29 @@
278278
"subnet": {
279279
"title": "Co-sponsor the Gnars Builder subnet",
280280
"desc": "This one pays you nothing. Staked MOR powers the Gnars Builder subnet, which funds what the DAO ships. Your MOR stays yours and unlocks 7 days after you stake.",
281-
"morStaked": "MOR staked",
282-
"goal": "goal {n, number}",
283281
"cta": "Back the subnet",
284282
"milestonesTitle": "Amplification milestones",
285283
"milestonesNote": "As staked MOR grows, Gnars amplifies Morpheus — Gnars's own commitment, building on Morpheus's open protocol.",
284+
"milestoneRow": "{n, number} MOR staked: {what}",
285+
"nextMilestone": {
286+
"label": "Next milestone",
287+
"unlocks": "Unlocks: {what}",
288+
"loading": "Reading the subnet…",
289+
"error": "Can't reach the subnet right now",
290+
"allDone": "Every milestone reached",
291+
"macro": "{staked, number} of {target, number} MOR toward the full ladder"
292+
},
286293
"firmness": {
287294
"committed": "committed",
288295
"proposed": "proposed"
289296
},
290297
"milestones": {
291-
"10k": "{n, number} MOR staked: Morpheus ad on Gnars TV",
292-
"15k": "{n, number} MOR staked: Morpheus on the Gnars banner + a pinned post",
293-
"25k": "{n, number} MOR staked: Morpheus featured in a Gnars edit",
294-
"30k": "{n, number} MOR staked: Morpheus swag in the Skater Vaults",
295-
"50k": "{n, number} MOR staked: Morpheus at an IRL Gnars activation",
296-
"100k": "{n, number} MOR staked: Morpheus flagship spotlight"
298+
"10k": "Morpheus ad on Gnars TV",
299+
"15k": "Morpheus on the Gnars banner + a pinned post",
300+
"25k": "Morpheus featured in a Gnars edit",
301+
"30k": "Morpheus swag in the Skater Vaults",
302+
"50k": "Morpheus at an IRL Gnars activation",
303+
"100k": "Morpheus flagship spotlight"
297304
},
298305
"roadmap": {
299306
"title": "What comes next",

messages/pt-br/stake.json

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -278,22 +278,29 @@
278278
"subnet": {
279279
"title": "Co-patrocine a subnet Gnars Builder",
280280
"desc": "Esse aqui não te paga nada. O MOR em stake sustenta a subnet Gnars Builder, que financia o que a DAO entrega. Seu MOR continua seu e libera 7 dias depois do stake.",
281-
"morStaked": "MOR em stake",
282-
"goal": "meta {n, number}",
283281
"cta": "Apoiar a subnet",
284282
"milestonesTitle": "Metas de amplificação",
285283
"milestonesNote": "Conforme o MOR em stake cresce, a Gnars amplifica a Morpheus — compromisso unilateral da Gnars, construindo sobre o protocolo aberto da Morpheus.",
284+
"milestoneRow": "{n, number} MOR em stake: {what}",
285+
"nextMilestone": {
286+
"label": "Próximo marco",
287+
"unlocks": "Destrava: {what}",
288+
"loading": "Lendo a subnet…",
289+
"error": "Não dá pra ler a subnet agora",
290+
"allDone": "Todos os marcos atingidos",
291+
"macro": "{staked, number} de {target, number} MOR rumo à escada completa"
292+
},
286293
"firmness": {
287294
"committed": "confirmado",
288295
"proposed": "proposto"
289296
},
290297
"milestones": {
291-
"10k": "{n, number} MOR em stake: anúncio da Morpheus na Gnars TV",
292-
"15k": "{n, number} MOR em stake: Morpheus no banner da Gnars + post fixado",
293-
"25k": "{n, number} MOR em stake: Morpheus em destaque num edit da Gnars",
294-
"30k": "{n, number} MOR em stake: swag da Morpheus nos Skater Vaults",
295-
"50k": "{n, number} MOR em stake: Morpheus numa ativação IRL da Gnars",
296-
"100k": "{n, number} MOR em stake: spotlight principal da Morpheus"
298+
"10k": "anúncio da Morpheus na Gnars TV",
299+
"15k": "Morpheus no banner da Gnars + post fixado",
300+
"25k": "Morpheus em destaque num edit da Gnars",
301+
"30k": "swag da Morpheus nos Skater Vaults",
302+
"50k": "Morpheus numa ativação IRL da Gnars",
303+
"100k": "spotlight principal da Morpheus"
297304
},
298305
"roadmap": {
299306
"title": "O que vem depois",

src/components/stake/GnarsStakeDialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ export function GnarsStakeDialog({ open, onOpenChange }: Props) {
221221
done ? "font-semibold text-foreground" : "text-muted-foreground",
222222
)}
223223
>
224-
{t("milestones." + m.id, { n: m.amountMor })}{" "}
224+
{t("milestoneRow", { n: m.amountMor, what: t("milestones." + m.id) })}{" "}
225225
{/* Same committed/proposed label the page ladder carries.
226226
Dropping it here would make the dialog read as six firm
227227
promises while the section behind it says five are not. */}

src/components/stake/SubnetSection.tsx

Lines changed: 109 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,15 @@
44
// and into a section at the end of the page.
55
//
66
// useGnarsSubnet resolves totalStaked BEFORE it bails on a missing wallet, so the
7-
// goal bar paints for a disconnected visitor — this section works cold.
7+
// progress paints for a disconnected visitor — this section works cold.
8+
//
9+
// TWO PROGRESS READINGS, ONE FIGURE. The lead indicator counts toward the next
10+
// unreached tier; the thin bar under it counts toward the whole 100k ladder.
11+
// They exist because the campaign tells people to "track progress toward the
12+
// 10,000 MOR milestone" while the only bar here used to measure against 100,000
13+
// — so at 7,453 MOR the page answered 7% to a reader expecting 74%, a tenth of
14+
// what the announcement had promised. The lead figure is derived from
15+
// `nextMilestone()`, never pinned to 10k, so it walks the ladder on its own.
816
//
917
// Colour rule from the spec: this is the ONLY section allowed to use Morpheus
1018
// green, and only on the progress fill and the MOR total. The CTA is gold like
@@ -44,7 +52,12 @@ import {
4452
import { Button } from "@/components/ui/button";
4553
import { useGnarsSubnet } from "@/hooks/use-gnars-subnet";
4654
import { useUserAddress } from "@/hooks/use-user-address";
47-
import { isMilestoneDone, SUBNET_GOAL_MOR, SUBNET_MILESTONES } from "@/lib/stake-milestones";
55+
import {
56+
isMilestoneDone,
57+
nextMilestone,
58+
SUBNET_GOAL_MOR,
59+
SUBNET_MILESTONES,
60+
} from "@/lib/stake-milestones";
4861
import { cn } from "@/lib/utils";
4962

5063
const fmtMor = (n: number, locale: string) =>
@@ -54,10 +67,20 @@ export function SubnetSection() {
5467
const t = useTranslations("stake.page.subnet");
5568
const locale = useLocale();
5669
const { address: you } = useUserAddress();
57-
const { totalStaked } = useGnarsSubnet(you);
70+
const { totalStaked, status } = useGnarsSubnet(you);
5871
const [open, setOpen] = useState(false);
5972

60-
const pct = Math.min(100, (totalStaked / SUBNET_GOAL_MOR) * 100);
73+
// Two readings of the same on-chain figure, never a second source: `next` is
74+
// the tier the campaign tells people to watch, `macro` is the whole ladder.
75+
// Both percentages measure from zero, which is what makes "7,453 / 10,000 —
76+
// 74%" arithmetic a visitor can check rather than a number they must trust.
77+
//
78+
// `floor`, not `round`: at 9,999 of 10,000 rounding prints "100%" beside a
79+
// milestone that has not been reached, which is the same kind of lie this
80+
// indicator exists to remove. Flooring says 99% until it is actually true.
81+
const next = nextMilestone(totalStaked);
82+
const tierPct = next ? Math.min(100, Math.floor((totalStaked / next.amountMor) * 100)) : 100;
83+
const macroPct = Math.min(100, Math.floor((totalStaked / SUBNET_GOAL_MOR) * 100));
6184

6285
return (
6386
// The section IS its own card (no wrapping island, no inner panel): header,
@@ -70,29 +93,62 @@ export function SubnetSection() {
7093
<RevealItem delay={50}>
7194
<div className="flex items-start justify-between gap-4">
7295
<div className="min-w-0 flex-1">
73-
<div className="mb-2 flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
74-
<span className="font-mono text-lg font-bold tabular-nums">
75-
<span className={MOR_TEXT}>{fmtMor(totalStaked, locale)}</span>{" "}
76-
<span className={cn("text-xs font-normal", MUTED)}>{t("morStaked")}</span>
77-
</span>
78-
<span className={cn("font-mono text-xs tabular-nums", MICRO)}>
79-
{t("goal", { n: SUBNET_GOAL_MOR })}
80-
</span>
81-
</div>
82-
{/* The fill is full width and scaled, not a growing `width`: animating
83-
width lays out and paints on every frame, a transform does neither.
84-
500ms is over the UI budget on purpose — this is a value arriving
85-
from chain, and it should be legible as "it filled to here". */}
86-
<div className="h-1.5 w-full overflow-hidden rounded-full bg-foreground/[0.10]">
87-
<div
88-
// ease token: EASE_OUT in src/lib/motion.ts.
89-
className={cn(
90-
"h-full w-full origin-left rounded-full transition-transform duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
91-
MOR_FILL,
92-
)}
93-
style={{ transform: `scaleX(${pct / 100})` }}
94-
/>
95-
</div>
96+
<p className={cn("font-mono text-[11px] tracking-[0.08em] uppercase", MICRO)}>
97+
{t("nextMilestone.label")}
98+
</p>
99+
100+
{/* Loading and error are their own states, never a rendered 0. The
101+
hook starts `totalStaked` at 0, and painting that as a figure
102+
would read as "nobody has staked" mid-flight. */}
103+
{status !== "ready" ? (
104+
<p className={cn("mt-1.5 text-sm", MUTED)}>
105+
{t(status === "loading" ? "nextMilestone.loading" : "nextMilestone.error")}
106+
</p>
107+
) : next ? (
108+
<>
109+
{/* One step down at base, full size from sm. At 390px the pair
110+
needed 252px of a 254px column — two pixels short — so the
111+
percentage wrapped onto its own line and read as a separate
112+
statistic instead of the share OF the figure above it. The
113+
margin also shrinks as the ladder climbs ("10,000 / 15,000
114+
MOR" is wider than today's label), so this is not just about
115+
the current number. */}
116+
<div className="mt-1 flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
117+
<span className="font-mono text-xl font-bold tabular-nums sm:text-2xl">
118+
<span className={MOR_TEXT}>{fmtMor(totalStaked, locale)}</span>
119+
<span className={cn("text-sm font-normal sm:text-base", MUTED)}>
120+
{" / "}
121+
{fmtMor(next.amountMor, locale)} MOR
122+
</span>
123+
</span>
124+
<span className="font-mono text-xl font-bold tabular-nums sm:text-2xl">
125+
{tierPct}%
126+
</span>
127+
</div>
128+
129+
{/* The fill is full width and scaled, not a growing `width`:
130+
animating width lays out and paints on every frame, a
131+
transform does neither. 500ms is over the UI budget on
132+
purpose — this is a value arriving from chain, and it should
133+
be legible as "it filled to here". */}
134+
<div className="mt-2 h-2.5 w-full overflow-hidden rounded-full bg-foreground/[0.10]">
135+
<div
136+
// ease token: EASE_OUT in src/lib/motion.ts.
137+
className={cn(
138+
"h-full w-full origin-left rounded-full transition-transform duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
139+
MOR_FILL,
140+
)}
141+
style={{ transform: `scaleX(${tierPct / 100})` }}
142+
/>
143+
</div>
144+
145+
<p className="mt-2 text-sm">
146+
{t("nextMilestone.unlocks", { what: t(`milestones.${next.id}`) })}
147+
</p>
148+
</>
149+
) : (
150+
<p className="mt-1.5 text-sm font-semibold">{t("nextMilestone.allDone")}</p>
151+
)}
96152
</div>
97153
<Image
98154
src="/logos/morpheus.webp"
@@ -103,6 +159,31 @@ export function SubnetSection() {
103159
/>
104160
</div>
105161

162+
{/* The 100k ladder, demoted to context. It answers "how far through the
163+
whole programme", which is a different question from the one above —
164+
and the one the campaign does NOT ask people to track. Micro type and
165+
a neutral 1px fill: green stays on the figure that matters now, so
166+
two bars never compete for the same attention. */}
167+
{status === "ready" ? (
168+
<div className="mt-5">
169+
<div className="flex items-baseline justify-between gap-3">
170+
<span className={cn("font-mono text-xs tabular-nums", MICRO)}>
171+
{t("nextMilestone.macro", {
172+
staked: Math.round(totalStaked),
173+
target: SUBNET_GOAL_MOR,
174+
})}
175+
</span>
176+
<span className={cn("font-mono text-xs tabular-nums", MICRO)}>{macroPct}%</span>
177+
</div>
178+
<div className="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-foreground/[0.08]">
179+
<div
180+
className="h-full w-full origin-left rounded-full bg-foreground/30 transition-transform duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none"
181+
style={{ transform: `scaleX(${macroPct / 100})` }}
182+
/>
183+
</div>
184+
</div>
185+
) : null}
186+
106187
{/* The ladder needs naming and framing before it is read: without these
107188
two lines it looks like a reward schedule, which is the other axis
108189
entirely and the one the stake dialog's disclaimer contradicts. */}
@@ -125,7 +206,7 @@ export function SubnetSection() {
125206
)}
126207
/>
127208
<span className={done ? "" : MUTED}>
128-
{t(`milestones.${m.id}`, { n: m.amountMor })}{" "}
209+
{t("milestoneRow", { n: m.amountMor, what: t(`milestones.${m.id}`) })}{" "}
129210
{/* Inline rather than a right-aligned column: these labels are
130211
long enough to wrap on a phone, and a pinned column would
131212
squeeze them to two words a line. Not a PILL either — a pill

src/hooks/use-gnars-subnet.ts

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,42 +3,83 @@
33
// Read-only view of the Gnars Builder subnet on Base: the wallet's staked MOR +
44
// unlock time + spendable MOR balance, and the subnet's total staked. Nothing
55
// here moves money.
6-
76
import { useEffect, useState } from "react";
8-
import { createPublicClient, http, fallback, formatUnits, type Address } from "viem";
7+
import { createPublicClient, fallback, formatUnits, http, type Address } from "viem";
98
import { base } from "viem/chains";
109
import {
11-
BUILDERS, GNARS_SUBNET_ID, MOR_BASE, MOR_DECIMALS, SUBNET_WITHDRAW_LOCK_SECONDS,
12-
BASE_RPCS, buildersAbi, erc20AbiMin, type GnarsSubnetPosition,
10+
BASE_RPCS,
11+
BUILDERS,
12+
buildersAbi,
13+
erc20AbiMin,
14+
GNARS_SUBNET_ID,
15+
MOR_BASE,
16+
MOR_DECIMALS,
17+
SUBNET_WITHDRAW_LOCK_SECONDS,
18+
type GnarsSubnetPosition,
1319
} from "@/lib/morpheus-builder";
1420

15-
const client = createPublicClient({ chain: base, transport: fallback(BASE_RPCS.map((u) => http(u))) });
21+
const client = createPublicClient({
22+
chain: base,
23+
transport: fallback(BASE_RPCS.map((u) => http(u))),
24+
});
25+
26+
/**
27+
* `loading` until the subnet read lands, then `ready`; `error` only if it never
28+
* landed at all. Callers need this because `totalStaked` starts at 0, and a UI
29+
* that renders that 0 as a real figure states "nobody has staked" while the RPC
30+
* is still in flight — a confident wrong number, which is worse than a spinner.
31+
*
32+
* Once `ready`, a later failed refetch keeps `ready` and the last good figure
33+
* rather than blanking a number the visitor already read.
34+
*/
35+
export type GnarsSubnetStatus = "loading" | "ready" | "error";
1636

1737
export type GnarsSubnetState = {
1838
position: GnarsSubnetPosition | null;
1939
/** Total MOR staked across all backers of the subnet. */
2040
totalStaked: number;
41+
status: GnarsSubnetStatus;
2142
};
2243

2344
export function useGnarsSubnet(wallet?: string, nonce = 0): GnarsSubnetState {
24-
const [state, setState] = useState<GnarsSubnetState>({ position: null, totalStaked: 0 });
45+
const [state, setState] = useState<GnarsSubnetState>({
46+
position: null,
47+
totalStaked: 0,
48+
status: "loading",
49+
});
2550

2651
useEffect(() => {
2752
let cancelled = false;
2853

2954
(async () => {
3055
try {
3156
const subnetData = await client.readContract({
32-
address: BUILDERS, abi: buildersAbi, functionName: "subnetsData", args: [GNARS_SUBNET_ID],
57+
address: BUILDERS,
58+
abi: buildersAbi,
59+
functionName: "subnetsData",
60+
args: [GNARS_SUBNET_ID],
3361
});
3462
const totalStaked = Number(formatUnits(subnetData[1], MOR_DECIMALS));
35-
if (!cancelled) setState((s) => ({ ...s, totalStaked }));
63+
if (!cancelled) setState((s) => ({ ...s, totalStaked, status: "ready" }));
3664

37-
if (!wallet) { if (!cancelled) setState((s) => ({ ...s, position: null })); return; }
65+
if (!wallet) {
66+
if (!cancelled) setState((s) => ({ ...s, position: null }));
67+
return;
68+
}
3869

3970
const [user, bal] = await Promise.all([
40-
client.readContract({ address: BUILDERS, abi: buildersAbi, functionName: "usersData", args: [wallet as Address, GNARS_SUBNET_ID] }),
41-
client.readContract({ address: MOR_BASE, abi: erc20AbiMin, functionName: "balanceOf", args: [wallet as Address] }),
71+
client.readContract({
72+
address: BUILDERS,
73+
abi: buildersAbi,
74+
functionName: "usersData",
75+
args: [wallet as Address, GNARS_SUBNET_ID],
76+
}),
77+
client.readContract({
78+
address: MOR_BASE,
79+
abi: erc20AbiMin,
80+
functionName: "balanceOf",
81+
args: [wallet as Address],
82+
}),
4283
]);
4384
const lastDeposit = Number(user[0]);
4485
const deposited = user[2];
@@ -49,11 +90,20 @@ export function useGnarsSubnet(wallet?: string, nonce = 0): GnarsSubnetState {
4990
};
5091
if (!cancelled) setState((s) => ({ ...s, position }));
5192
} catch {
52-
if (!cancelled) setState((s) => ({ ...s, position: null }));
93+
// Only downgrade to `error` if we never had a good read. A wallet-scoped
94+
// failure after the subnet total already landed must not blank it.
95+
if (!cancelled)
96+
setState((s) => ({
97+
...s,
98+
position: null,
99+
status: s.status === "ready" ? "ready" : "error",
100+
}));
53101
}
54102
})();
55103

56-
return () => { cancelled = true; };
104+
return () => {
105+
cancelled = true;
106+
};
57107
}, [wallet, nonce]);
58108

59109
return state;

0 commit comments

Comments
 (0)