2026-08-10 — verex
Source docs: docs/runbooks/uma-adapter.md, docs/runbooks/uma-local-demo.md, docs/runbooks/local-testing.md — this day restructures the UMA runbooks by environment.
Split the UMA runbook by environment: uma-local-demo.md extracted
- Cause: jay, testing UMA locally, found uma-adapter.md confusing — staging/prod content and local-server content interleaved in one file. On inspection it was worse: the file mixed three environments (real Sepolia §1–4b, local mock §4c, Sepolia fork §5), and §5 was titled "Testing locally" while actually being the fork harness — so the reader looking for "test on my local server" landed on the wrong section.
- Reasoning: the earlier "no new file needed, §4c covers it" call answered coverage, not findability. Two distinct audiences (staging operator vs. someone walking the demo locally) shared one scroll. Splitting by audience beats a labeling pass because the local demo is fully self-contained (own prerequisites, URLs, bond arithmetic).
- Change: §4c moved to a new
docs/runbooks/uma-local-demo.md(with an added local-vs-staging comparison table and "why a mock exists" framing); uma-adapter.md keeps a one-paragraph §4c pointer, gains a "which document do you need?" note up top, and §5 was renamed to "Testing against a Sepolia fork" with its false premise ("there is no mock deployment to point at") rewritten — the mock exists now; the fork's job is UMA conformance, not adapter mechanics. Code comments inUmaOraclePanel.tsxanduma-demo.tsrepointed. Stale test count fixed (53 → 63, verified viaforge test). - Result: each runbook now serves one environment; cross-links both ways. Branch
claude/uma-docs-split, uncommitted.
local-testing.md §5 had gone stale — the mock oracle inverted its claims
- Cause: while answering "do we need a separate file?", discovered that
local-testing.md §5 still described the pre-mock world: UMA card "greyed out — no
adapter exists on this chain",
/configexpectedumaAvailable: false, and §5.4's API test expected a 400 that now returns success. - Reasoning: the mock-oracle work (2026-08-07) made local seeds deploy a real adapter, flipping every one of those assertions; a runbook asserting the opposite of reality actively misleads. The binary-only rejection, previously masked by the missing-adapter check, is now the observable disabled state — so the section's test value survives, just inverted.
- Change: §5.1–5.2 rewritten around the enabled card and the new chain of state
(
DeployMockOracle→ChainConfig→umaAvailable: true, umaOracleMock: true), with a re-seed note for DBs predating the mock; §5.3 binary-only is now a visual check; §5.4 fires the two remaining 400s on anvil and expects valid UMA creation to succeed; §5.6 positive case points at uma-local-demo.md (mock) vs. uma-adapter.md §5 (real-oracle conformance); §8's "not testable on anvil" row narrowed to real-oracle behaviour only. - Result: the runbook's expectations match what a freshly seeded anvil actually
does (verified against today's live
/config:umaAvailable: true,umaOracleMock: true). Same branch, uncommitted.
/create: "Binary market" checkbox auto-fills Yes/No
- Cause: jay, creating a UMA test market, hit the greyed-out UMA card because the outcome labels weren't literally "Yes"/"No" — typing them by hand is both tedious and easy to get wrong, and UMA requires the labels verbatim.
- Reasoning: the failure mode isn't ignorance (the card's message explains itself)
but friction:
isBinarystring-matches lowercased sorted labels, so any typo or synonym silently disables UMA. A checkbox that sets the canonical labels removes the typing instead of validating it. Unchecking restores whatever was typed before (stashed in a ref) so exploration isn't punished. No Checkbox component exists in the UI kit — a styled native<input type="checkbox">avoids adding one for a single use. - Change:
CreateClient.tsx—binaryCheckedstate +toggleBinary; checked fixesoutcomesto["Yes","No"]and swaps the input list for a one-line note ("the labels the UMA oracle option requires"); unchecked restores the stashed list. ExistingisBinary/UMA-card logic untouched — the checkbox only feeds it the right labels. - Result:
tsc --noEmitclean on@verex/web. One check now yields a UMA-eligible binary market; multi-outcome flow unchanged. Branchclaude/uma-docs-split, uncommitted (separable from the docs changes at commit time — different files).
Finding: closesAt is never enforced — markets stay tradable past their end time
- Cause: jay asked what the creation-form end time means if a proposal opens the dispute window whenever it happens. Tracing the field to answer precisely turned up a gap.
- Reasoning: order placement gates only on
market.status !== "OPEN"(book.ts:255), and nothing ever flips status by clock: no scheduled job readsclosesAt, no order-time comparison against it exists. After creation-time validation ("must be in the future"), the API never reads the field again; the web only renders "Closes" labels. - Change: none — assessment only, flagged to jay. Candidate fix if wanted: check
closesAtat order placement (cheapest, no job needed) and have the UI show "closed" past the date. - Result: documented that today trading genuinely ends only at resolution;
closesAtis informational. Real platforms halt trading at the event time, so this is a divergence worth a deliberate decision rather than an accident. Closed the same day — next entry.
closesAt enforced + the oracle panel explains premature proposals
- Cause: jay confirmed both halves of the confusion: the dispute-window countdown renders with no reference to the end time ("unintuitive"), and the page doesn't block trading after the deadline. Chose enforcement over his first instinct (removing the date): a displayed-but-unenforced value should be made true, not hidden — the date is also the question's reference point.
- Reasoning: one check at order placement covers every entry path (
/ordersand/tradeboth funnel throughplaceOrder), needing no scheduled job; markets become closed-but-unresolved until resolution, which is the correct intermediate state. The oracle panel note turns the two-independent-clocks surprise into an explanation shown exactly where the confusion arises, and only while it can arise (pre-cutoff, Requested/Proposed states). Demo walkability kept: propose stays enabled early — the note frames it as the premature-proposal setup for the dispute scenarios. - Change:
book.tsplaceOrderrejects with 400market closed for trading at <iso>whenclosesAthas passed;TradePanel.tsxdisables the button ("Trading closed") with a positions-locked note;UmaOraclePanel.tsxgains aclosesAtprop (passed from the market page) and a pre-cutoff note naming the date and the word "premature". Verified live: backdating a seeded market'sclosesAt→ trade 400s; restoring → trades again. First test run silently passed stale code —tsx watchhadn't restarted (same gotcha as 2026-08-07);touch index.tsbefore trusting a negative result. - Result:
tsc --noEmitclean on both packages; the displayed close date now means what it says. Branchclaude/uma-docs-split, uncommitted.
Telegram notifications muted on local anvil
- Cause: jay: local demo clicks (propose/dispute/finalize/trades) were pinging
Telegram — the notify hook only checked for token/chat-id presence, and the local
packages/api/.envcarries both. - Reasoning: the sandbox chain id is the reliable "this is local" signal (31337
is already the code's local default everywhere); an env kill-switch would be one
more thing to remember per machine. Kept an explicit opt-in
(
TELEGRAM_NOTIFY_LOCAL=1) for testing the hook itself locally. - Change:
telegram-notify.tsreturns early whenCHAIN_ID === 31337unless the opt-in is set. - Result:
tscclean; staging/prod behaviour unchanged (their chain id is 11155111). Branchclaude/uma-docs-split, uncommitted.
.gitignore missed .env.staging — real keys were one git add -A away
- Cause: staging the session's work surfaced
packages/contracts/.env.stagingas untracked, not ignored — a 3.4 KB twin of.envholding the operator private key and the Alchemy URL. - Reasoning: the ignore list enumerated variants (
.env,.env.local,.env.prod, …), so every new suffix silently defaults to committable — deny-by-default with explicit allows is the only version of this rule that can't rot. Templates must stay tracked, hence the!re-allows. - Change:
.gitignore—.env+.env.*(and the same forscripts/deploy.env*), with!.env.example/!.env.*.example/!scripts/deploy.env.example. - Result: verified
check-ignore:.env.staging,.env,.env.prodignored; the four committed*.examplefiles still tracked. Nothing secret was ever committed — the file was untracked the whole time.
The closesAt cutoff broke the seed — and exposed that the seed's dates rot
- Cause: the first staging deploy after merging failed at
[6] posting MM ladders:seed failed: market closed for trading at 2026-07-10T00:00:00.000Z. The newplaceOrdercutoff rejected the operator's own opening quotes on markets whose hardcoded seed dates are now in the past. Staging was left half-seeded (markets, no books) on the previous revision — the deploy aborted before building the image. - Reasoning: two distinct problems. (1)
postLaddersguardedstatus !== "OPEN"but not the cutoff, so it quoted a market that cannot accept orders — a closed market having no book is a state, not an error, so the guard belongs next to the status check rather than as a try/catch at the call site. (2) The seed's dates are absolute literals written months ago, so real time walks past them: today 9 of 32 markets are closed (kr-world-cup-quarterfinals-2026,ai-imo-gold-2026, and all 7 members ofmlb-home-run-derby-2026). Left as-is that ships a demo where a quarter of the markets are inert. Not silently "fixed" by rewriting the questions' dates — a World Cup market closing in December is nonsense, so the real fix is relative-to-run-time dates, which is jay's call. - Change:
mm.tspostLaddersreturns early pastclosesAt;seed.tsstep 6 skips those markets and prints which ones, so an inert market is never a silent omission. - Result: local
db:resetgreen again, listing the 9 skipped slugs. My process failure worth naming: I enforced the cutoff and deployed without re-running the seed — the one command that exercises order placement across every seeded market.
Oracle panel acts as the selected wallet — one screen stops being five jurors
- Cause: jay: "voting should be done only by the selected wallet; the summary information is good." The panel let one screen propose, dispute, and cast all five jury votes — convenient, but it broke the app's own identity model (every other surface acts as the header's selected wallet) and demoed a verdict as one person clicking five buttons.
- Reasoning: UMA's whole point is that proposer, disputer, and each juror are separate parties with separate stakes; a god-mode panel teaches the opposite of what it demonstrates. Split read from write: everyone SEES the full tally (jay's "summary is good"), everyone ACTS only as themselves. The wallet-switching friction is the lesson, so no auto-cast shortcut was added. Kept self-dispute allowed — on real UMA it is the only way to retract your own wrong proposal — with a note instead of a guard. Blocked one thing on purpose: the operator may propose and dispute (claims anyone can make) but cannot vote, since the venue judging its own markets is the conflict of interest UMA removes.
- Change: API —
umaPropose/umaFinalizenow takeaccountIndex(propose was hardcoded to the operator), sharedrequireAccountvalidates 0..9 with ajuryOnlyflag rejecting index 0 for votes; routes pass it through. Web —UmaOraclePanelreadsuseWallet(): propose/dispute/finalize act as the selected wallet with the wallet named on the button, the five dispute buttons collapse to one, jury rows become a read-only tally where only your own row offers Vote buttons (highlighted "(you)"), plus a line explaining one-vote-per-address and the operator's exclusion.api.tshelpers gained the parameter. Runbookuma-local-demo.mdrewrote scenario 1 as a be-this-wallet/do-this table and added the curl equivalents. - Result: verified end to end on local anvil with a fresh throwaway market:
proposed as wallet #2 (previously impossible — propose was operator-only),
disputed as #1, operator vote correctly refused 400 "the operator does not sit on
the jury", jury 4–1, finalized as #3 (permissionless), resolved 200; bonds
moved exactly −10 (disputer #1) / +10 (proposer #2).
tsc --noEmitclean on both packages.
i18n reaches the app's four main pages (home, market, portfolio, create)
- Cause: jay asked for the language toggle to actually work on Main, Detail, Portfolio and Creation. Until now only the nav, welcome overlay, home cards and docs were translated — every other surface was English regardless of the toggle, so switching to Korean produced a half-Korean site.
- Reasoning: the existing i18n needed no redesign — flat dotted keys,
getT()on the server,useLocale()on the client, andko: Record<MessageKey, string>making a missing Korean value a compile error. The work was inventory-and-convert across ~2,500 lines, so it went to five parallel agents on disjoint files, each with its own key namespace (home.market.portfolio.create.uma./group.) and forbidden from editinglib/i18n.ts— one owner for the dictionary meant no write conflicts, and the type system caught anything they got wrong on merge. Two rules they were held to: never translate values that cross the wire (category filter values, the literalYes/Nooutcome labels theisBinarycheck string-matches, oracle state enums), and route displayed enums through a lookup instead. - Change: ~200 key pairs added to
lib/i18n.ts; 14 components/pages converted, including hardcodedtoLocaleDateString("en-US")calls now taking the locale-awareintl.GroupViewwas included though not requested — the group page is one click from home and a half-translated path is worse than either extreme. Also fixed a pre-existing inconsistency the audit exposed: the market page renderedmarket.categoryraw while the home page ran it throughcategoryLabel(). - Result:
tsc --noEmitandnext buildclean; verified against the production build on a spare port — 13 Korean and 6 English assertions across all four pages plus the group page. A leftover-English sweep found only what should stay English: market titles, seeded rules copy ("Settled by UMA's Optimistic Oracle…"), andCondition(CTF protocol term, deliberate). - Self-inflicted damage worth recording: running
pnpm run buildinsidepackages/weboverwrites the same.nextthe dev server is using, which 500s the running dev server; clearing.nextthen left it 404ing until restart. Verify againstnext starton a spare port only when no dev server is running against the same directory — or accept that the dev server needs a restart afterwards.
Portfolio was slow for a backend reason, and confusing for a frontend one
- Cause: jay: "the Positions part is so late and confusing when changing the Demo
Wallet or going from the trade page" — and asked for a loading indicator. Measuring
first showed the complaint was two separate defects, only one of them cosmetic:
/wallet/Ntook 5.0s on staging, and during those 5s the page displayed the previous wallet's balance and positions. - Reasoning: the latency was
walletSummarycallingbalanceOfonce per outcome per market — ~64 sequential RPC round-trips before the page could render anything. The CT is ERC-1155, which hasbalanceOfBatch, but the SDK's hand-rolled ERC-1155 ABI only declared the single-item call, so nobody could batch. Adding it makes the whole portfolio one round-trip. The confusion was separate and worse than staleness: the provider kept the old summary while fetching, so a wallet switch showed another account's money attributed to the account you just selected. Storing the summary with the wallet it describes makes that structurally impossible; a rising request id stops a slow response for an abandoned wallet from overwriting a newer one. Chose skeletons over jay's suggested hourglass — same signal, no layout jump, and unlike a spinner they never imply the number underneath is still valid. - Change: SDK
ct.balanceOfBatch1155+CTClient.balanceOfBatch;walletSummaryflattens market×outcome into one batched read;WalletProvidergains{index, summary}pairing, a request id, and aloadingflag; newui/skeleton.tsx; portfolio stat cards, position rows and activity rows render skeletons while busy, with a spinner beside the section titles for refresh-in-place; the nav balance too. - Result: local
/wallet/15.0s → 0.09s, verified correct after a real trade (111.11 tokens, cost $50, P&L +$17.70). Build renders 22 skeleton blocks and 2 spinners; the "no positions yet" empty state no longer flashes before positions arrive — the most alarming part of the old behaviour.tscclean on api/sdk/web, SDK tests pass. Does not fix the settlement-lag inconsistency reported earlier (chain balance vs DB cost basis); that one is still awaiting a decision.
The two portfolio-truth fixes: pending fills netted in, ghost positions eliminated
- Cause: jay approved both fixes from the day's diagnosis: (1) settlement lag — right after a buy the portfolio showed the pre-trade balance with the post-trade cost basis ("bought $100, worth $7"); (2) ghost positions — staging wallet #1 held 434.78 Dodgers tokens with zero trades and +$95.65 P&L out of thin air, because a re-seed wipes the DB but not the chain, and question keys were a pure function of the slug, so the fresh markets recomputed the SAME token ids and old balances reattached.
- Reasoning: (1) is the same read-your-own-writes race as the Aug-7 MM-ladder fix, so it gets the same shape: net PENDING fills into what the chain reports — tokens per outcome and USDC both, clamped at zero; CONFIRMED is already in balanceOf and FAILED moved nothing (FAILED rows now also excluded from cost basis). The include-test runs on the adjusted balance so a brand-new position that exists only as a pending buy appears. (2) fold a nonce into the question key. The nonce must be stable across retries of one creation attempt — resume relies on catching "already prepared"/"AlreadyInitialized" with the same key — so a timestamp-per-call would orphan inventory on retry: app path uses the persisted job id, seed uses one per-run base-36 timestamp. DB-only seed path (chain 0, placeholder ids) deliberately untouched.
- Change:
trade.tswalletSummary (pending netting, FAILED exclusion);group-create.ts×2questionKey: verex:<slug>:<job.id>;seed.tsrun nonce + key;market-create.tsquestionKey doc rewritten to say why the nonce exists. - Result: verified live, both. (1) buy $30 as wallet #2 → IMMEDIATE read: usdc 970, 107.14 tokens, cost 30 — identical after settlement confirms (the invariant, as with the ladder). (2) reproduced staging's exact conditions locally via the env-backbone path: wallet #3 bought 88.89 tokens (CONFIRMED on chain), re-seeded against the SAME CTF — token id for the same slug rotated (3124… → 7224…) and /wallet/3 came back EMPTY where the old code would have shown 88.89 ghost tokens. UMA wiring intact after the env-reuse seed. Note: ghost USDC (e.g. wallet #2's 1019.41) is NOT covered — MockUSDC balances still persist; cosmetic, no fake P&L.
Prod deploy (code-only) + staging stopped
- Cause: jay: deploy to the prod server; then "you can stop the staging server because they are used for testing online."
- Reasoning: prod already existed (verex-*-prod services, verex-db-prod, prod
secrets) but ran pre-UMA code —
/configdidn't even exist. A re-deploy per the wrapper's own rule (SKIP_SEED=1: code + migrations, keep data) brings everything from PRs #15–#23 without touching prod's seeded markets. Stopping staging: the Cloud Run services cost ~nothing idle; the 24/7 cost is theverex-dbCloud SQL instance, so that is what got stopped — reversibly (--activation-policy NEVER), not deleted. - Change:
SKIP_SEED=1 ./scripts/deploy-prod.sh(exit 0); thengcloud sql instances patch verex-db --activation-policy NEVER→ state STOPPED. - Result: prod verified on new code:
/configlive (umaAvailable: false— correct, no adapter recorded for prod), 10 markets kept,/wallet/10.57s, Korean + English render, https://verex.jaylabs.xyz still 200. Staging API now fails (DB stopped, expected); prod unaffected (separate instance). Restart staging:gcloud sql instances patch verex-db --activation-policy ALWAYS, then re-deploy or just wait — services are still in place. Note for later: prod shows the same 9 past-close-date markets as "Trading closed" now that the cutoff is enforced; the stale-seed-dates decision applies to prod too.
The language toggle didn't survive the custom domain — Firebase eats cookies
- Cause: jay: "why the docs isn't changed by the language selection?" Docs are the most server-rendered surface, which made them the visible symptom.
- Reasoning: the app was correct — the direct *.run.app URL rendered Korean with
the cookie; only https://verex.jaylabs.xyz stayed English. The domain reaches
Cloud Run through a Firebase Hosting rewrite, and Firebase strips EVERY cookie
except one named exactly
__sessionbefore forwarding. The server never sawverex-locale, sogetLocale()fell back to English on every server render — a domain-only bug invisible in all our *.run.app testing. Client components kept switching (React state), which is why the site looked partially translated there rather than plainly broken. - Change:
LOCALE_COOKIErenamed to__sessionwith a comment explaining the name is Firebase's constraint, not a choice; localStorage keeps the descriptiveverex-localekey (existing saved preferences survive; the old cookie is simply ignored and localStorage restore re-writes the new one). - Result: verified on a local production build:
__session=ko→ Korean docs,__session=en→ English, old cookie name ignored. Deployed to prod and verified through the domain itself. Lesson recorded: anything cookie-driven must be tested through the Firebase-fronted domain, not only the run.app URLs.
Top nav layout broke in Korean — nav links wrapped inside the h-14 row
- Cause: jay: the top menu looks weird in Korean mode but fine in English. The
header is a single fixed-height
flex h-14row (SiteNav.tsx); the three nav links (Docs / Create / Portfolio) carried nowhitespace-nowrap, so under horizontal pressure the Korean label마켓 생성(it has a space) wrapped to two lines and blew out the row's vertical alignment. English labels are single tokens, so they never tripped it. - Reasoning: the
Buttoncomponent already setswhitespace-nowrap, so the Faucet button was safe — only the raw<Link>s were exposed. Fixing the wrap alone isn't enough: if the links can't wrap they must be allowed to keep their width, so theflex-1search box needsmin-w-0to yield space first, and the links/wallet box getshrink-0so the search absorbs the squeeze instead of them. Minimal CSS-class-only change — no markup or logic touched, so it can't regress English. - Change:
SiteNav.tsx—whitespace-nowrap shrink-0on the three nav<Link>s,shrink-0on their icons and the wallet box,min-w-0on the search<form>. - Result:
tsc --noEmitclean on@verex/web. Branchclaude/fix-nav-korean-layout, uncommitted at time of writing. Not yet visually verified in a browser (no headless browser available locally; the app needs API+DB to run) — recommend a quickpnpm --filter @verex/web devcheck in Korean before the prod deploy. (Merged as PR #25 and deployed to prod the same session.)
The nav overflowed on narrow viewports — sticky header rode the horizontal scroll
- Cause: jay: "the top menu and content move simultaneously when scrolling
horizontally — separate them." The header is
sticky top-0, which pins only vertically; any horizontal page overflow scrolls the whole body — header included — sideways together. Driving the live prod site with Playwright (Chrome,__sessioncookie for locale) and measuringscrollWidth − clientWidthacross widths pinned the source precisely: no overflow at all on desktop (≥768px, both locales, every page); overflow appears only below ~725px and the sole culprit is the nav's right cluster (ml-auto flex … gap-2, ~609px, can't shrink). The prior day'sshrink-0fix had converted the old "wrap" into "overflow." Realistically this bites via browser zoom (which shrinks the effective viewport) or a split/narrow window, not just phones. - Reasoning: the fix is to make the nav fit narrow widths so the page never scrolls
horizontally — then there is nothing for the header to ride. Collapse the least
essential things first, keeping every control usable: nav-link and Faucet labels go
icon-only below
lg; the wordmark text, wallet balance, and locale label hide belowsm(all non-critical or shown elsewhere). ConfirmedCategoryTabswas a false positive — a precise "not inside an overflow-scroll ancestor" check showed it sits within the viewport and scrolls internally; the residual sub-414px overflow is still the nav's wallet<select>, and only in English ("Demo Wallet 1" > "데모 지갑 1"). Stopped there rather than truncate core wallet text — chasing the last 40px on 360–413px English phones is over-engineering for a demo that isn't phone-first. - Change:
SiteNav.tsx—hidden lg:inlinelabel spans on the three links + Faucet (Faucet gainsaria-labelsince its text can hide);hidden sm:inlineon the wordmark text and the wallet balance;shrink-0on the logo link.LocaleToggle.tsx— locale labelhidden sm:inline. - Result: measured locally against the prod API before deploying (the miss from PR
#25): 0 horizontal overflow from 1440px down to 414px in both locales; Korean fits
to 390px (1px slack), English to ~414px. Screenshotted 768px and 390px — nav fits
cleanly, no cutoff.
tsc --noEmitclean. Branchclaude/fix-nav-korean-layout(reused per the one-branch-per-request rule), uncommitted at time of writing. Known minor: English portrait phones < 414px still show a small nav overflow from the wallet selector width. (Merged as PR #26 and deployed; the residual is closed by the next entry.)
Phone follow-up: compact wallet labels + category tabs wrap instead of clipping
- Cause: jay sent a real iPhone screenshot: nav cut off after "Faucet" and the
category row ending in "스…". The screenshot itself turned out to be a cached
pre-#26 page (text labels + no search box — that combination no longer exists);
emulating an iPhone 14 Pro against live prod showed #26 working (KO overflow 0 on
every page). But it surfaced two real leftovers: EN phones still overflowed 37px on
every page (the wallet
<select>— "Demo Wallet 1" is its widest option), and the category tabs, anoverflow-x-autorow withscrollbar-none, simply look cut on phones — nothing signals there's more to scroll. - Reasoning: a
<select>is as wide as its widest<option>, and option text can't be restyled per-breakpoint — so render two selects sharing the same state/handler: compact labels (Wallet {n}/지갑 {n}, newnav.*Shortkeys) belowsm, full labels above. For the tabs, wrapping beats scroll affordances (fade/scrollbar): every category becomes visible with no hint needed, and desktop fits one line either way so nothing changes there. - Change:
i18n.ts—nav.demoWalletShort/nav.operatorWalletShort(en+ko);SiteNav.tsx— dual<select>(sm:hidden/hidden sm:block);CategoryTabs.tsx—overflow-x-auto scrollbar-none→flex-wrap. - Result: verified on iPhone-14-Pro emulation against the prod API before
deploying: overflow 0px in both locales, all 8 categories visible (2 rows KO,
3 rows EN), desktop 1280px unchanged (tabs one 40px row, full wallet labels,
overflow 0).
tsc --noEmitclean. Told jay the phone needs a hard refresh — the old page is cached on-device.
Category tabs get a curated order — Politics first, not alphabetical
- Cause: jay: "change the order of categories, like Politics before Economics."
The
/categoriesendpoint returnsorderBy: { category: "asc" }, so the tabs rendered in alphabetical accident (Climate, Crypto, Culture, Economics, Politics…) rather than by priority. - Reasoning: display order is a display concern, so the fix lives in the web, not
the API (which stays a dumb distinct-list).
CATEGORY_KEYSinCategoryTabs.tsxalready encodes the canonical order (matching the create-page dropdown): Politics, Sports, Crypto, Economics, Tech & Science, Climate, Culture — its key order now doubles as the sort order. Unknown (future user-created) categories fall to the end alphabetically instead of breaking. - Change:
CategoryTabs.tsx—CATEGORY_ORDER+ exportedsortCategories(); the nav renderssortCategories(categories). - Result: verified locally against the prod API, both locales: All | Politics |
Sports | Crypto | Economics | Tech & Science | Climate | Culture.
tsc --noEmitclean. New branchclaude/category-tab-order(new request).