feat(0193): the portal's UI pass — landing, dashboard, quick start - #249
Conversation
MUI 7 and Emotion arrive — the half of the stack task 0185 shipped without — along with the landing page, the login screen and a dashboard that no longer looks like the debug harness it was deliberately left as. Design system Every colour, face and radius is transcribed from the Figma variables into theme/tokens.ts under Figma's own names, so a designer's "the tertiary text is too dim" maps to one line. theme.ts is the interpretation and keeps the two apart. Three self-hosted webfonts, ~130 KB: a link to fonts.googleapis or cdn.fontshare would be a third-party request from a page that renders a credential, and the CSP stays default-src 'self' only while nothing external is loaded. JetBrains Mono is one variable file, not two static weights. Routes / is the landing page and the junction the OAuth callback lands on — portal/auth/mod.rs redirects there in every outcome and says why: "when the portal grows a second page, the page it lands on decides where to go next". It forwards a signed-in visitor to /dashboard and a ?signin= landing to /login, carrying location.search so the one-shot refusals tasks 0186 and 0189 own are not swallowed. /dashboard sends a visitor with no session back to /api-tokens/, but only once /auth/me has answered — redirecting while it is in flight would bounce every arrival from the callback. Sign-in The Discord round-trip now opens in a second window and the page waits on it, as the mock shows. The control is still an <a> with a real href and preventDefault is called only after a window actually opened, so a blocked popup falls through to the navigation that has always worked. Three signals end the wait — the popup's message, a poll of /auth/me, and the window closing — because each covers a case the others cannot. Message origin is checked: without it any page could end the wait and make this card claim an outcome that never happened. Copy This slice re-decides none. The prerequisites, the eligibility refusals, the cancelled and failed banners, the usage figures and the lag line are tasks 0186 to 0189's and are rendered verbatim; the dashboard is styled through descendant rules rather than rewritten, so no wording moves and no testid is touched. What is new is the card chrome, the four "What you get" lines and the marketing sections, which belong to no other slice. The FAQ answers are written here and each restates a decision made elsewhere — two of them want a product read before release, and say so in the file. Task 0185's "Reached /api-tokens/api/config successfully — same-origin, no API key, no CORS" is gone. It was that slice's evidence that the bundle could reach its own backend, written when the page had nothing else to show for it; the page now has plenty, and a diagnostic sentence was the last thing on it that read as scaffolding. The test that guarded it now guards the control that acts on the answer instead. Deployment: DirectoryIndexFn rewrites the two client routes to the portal's index.html. Without it a hard refresh on either resolves against S3 and returns 403 AccessDenied, because the bucket grants s3:GetObject and not s3:ListBucket. An allow-list of literals, not a catch-all — a catch-all would answer 200-with-index.html for genuinely missing objects and turn a broken deploy into an app that renders the wrong thing. Task 0195 replaces it with the per-prefix SPA fallback; until then a route added to landing/links.ts must be added there. Known gaps, all flagged in the files: the two logos are rasters recovered from the Figma export because the seat had no MCP calls left for download_assets and should be replaced with SVGs; the Endpoints paths are the design's, not this repo's; and the login card's legal line names two documents that do not exist, so it is plain text rather than links to a 404.
The Figma seat became a Dev seat, so the dashboard frames (852:1499) could
finally be read rather than guessed at. /dashboard now has its own navbar —
SorobanScan, where you are, the two places you go next, who you are signed in
as, the way out — a page heading, and three cards: API Key, Monthly Usage and
Rate Limit.
The logos are real SVGs from download_assets, replacing the rasters an earlier
pass recovered from a screenshot when there were no tool calls left. The header
lockup is two nodes in the design and stays two here.
What the design shows and this does NOT, each flagged in the file that would
have carried it:
- The daily-requests bar chart. GET /usage returns used, remaining, limit,
the period bounds and as_of — no daily series exists, and drawing those
bars would mean inventing traffic on somebody's key.
- Regenerate, and the "rotation is limited to once per calendar month" note.
Both are task 0191's, which is not on this branch; the button would lead
nowhere and the sentence should arrive from the slice that decided it.
- "Issued" and "Last rotated". GET /key carries an id, a name and the value
and no timestamps at all. A dashboard that makes up the date a credential
was created is worse than one that leaves the field out. "Key name", which
the backend does return, takes the space.
- A link behind "Contact us" — there is no commercial-plans destination.
Three copy decisions worth knowing:
- The card titles are the design's ("API Key", "Monthly Usage", "Rate
Limit"), replacing 0187's and 0188's "Your API key" and "Usage this
period". They are panel labels; those slices' STATEMENTS — the reset rule,
the AWS lag line, the prerequisites — are rendered verbatim.
- "Used: / Remaining: / Monthly limit:" is gone and the figures stay, in the
design's arrangement. 0188 wrote those labels when the panel was three
unstyled paragraphs, and its own brief asked for the limits "as numbers,
not prose". Every data-testid and every raw value its tests read is
unchanged — the grouped form beside them is aria-hidden.
- "Rate limit: N request(s) per second." survives as an abbreviation
expansion. The design shows "1 req/s", which is right for the eye and
wrong for a screen reader, so 0188's sentence is in the DOM and read only
by assistive technology.
The Discord ID stays too, and now renders whatever the key state is: it is task
0186's acceptance criterion and the account key (ADR 0010), it belongs to the
session rather than to the key, and it has to be on screen on the day issuance
failed as much as on any other.
Fixes a latent layout bug while here: `visuallyHidden` used unitless `width: 1`
and `height: 1`, which MUI's sx treats as 100%, not one pixel. `clip` still hid
the element, but it pushed the document 900px past the viewport and gave the
dashboard a horizontal scrollbar. Every such value is now an explicit unit.
Task 0193's screens were read straight out of the Figma file rather than from screenshots, and this is the one line of configuration that makes that possible for the next person. Without it every session starts by wiring the server up by hand, or by asking somebody to export PNGs — which is what the first half of that task actually did. Committed rather than left local because it holds no credentials: the URL is Figma's public MCP endpoint and authentication happens per-user through an OAuth flow the client runs, so the file is safe to share and useless to anyone who has not signed in. Two things it implies, both of them fine but worth stating. Every session in this repo will now offer the Figma tools, and reading a design counts against the seat's tool-call budget — a View seat on the Professional plan gets six calls a MONTH, a Dev seat two hundred a day. Anyone hitting the low ceiling should ask for the seat rather than burn the calls discovering the limit.
One conflict, in the dashboard: 0191's revoke landed on develop while this branch rebuilt the same screens from the Figma design. Resolved as the union, not a pick. The layout is this branch's — the MUI Stack, the 5:3 grid and the Rate Limit card — and 0191's revoke wiring is kept inside it: ApiKey takes both `session` (the design shows the account on the key card) and `onRevoked`, and Usage takes `revokedCount`, so an in-page revoke still clears the key and re-asks for usage. portal: 110 tests pass, typecheck, lint, build and prettier clean.
Nine changes off a review against the Figma frames, most of them one property. The two that are not: The hero and the trust band become ONE section sized to the viewport (`HeroSection`). They stay two components — two frames in Figma, two backgrounds — but the fold has to fall below both, and sizing the hero alone left the band cut in half. Two earlier attempts sized the wrong thing: the dashboard's key panel, then the key card without its heading. `SectionLabel` grows a `neutral` tone for "Free Tier Limits", which the design draws grey rather than yellow: it labels a panel, not a section, and two brand chips side by side read as two headings. That chip also needed wrapping — it is a flex item in a column Stack, so `inline-block` does not stop it being stretched as wide as the cards below it. The rest: Docs → Quick Start in the navbar, pointing at `#get-started` rather than the OpenAPI document; the status badge takes the chip radius instead of a pill; the hero glow is two stacked radials (one strong enough to see banded across the fold) and Endpoints, Self-Service and Fair Access get the same light through a new `glow` prop on `Section`; feature and dashboard icons become discs with glyphs that say what the card says, while the Fair Access ticks stay square because Figma draws a checklist marker differently from a category icon; the Free Tier cards lose 4px of padding and their labels go grey. Values are measured off the exported PNGs, not guessed — #432205 for the warm card, #a3a3a3 for the labels, #f5f5f5 for the units.
The mobile frame (node 922-11966), section by section, plus the real exported icons for the three sections Adam sent as zips. The one that was broken rather than unstyled: below `sm` the navbar hid its three links and offered no replacement, so a phone could reach a section only by scrolling. There is now a menu button and a panel that drops from the top with the links and the call to action. The frame draws only the closed state; the panel is the plainest reading of it. Six grids become `CardRail` — a grid from `sm` up, a scroll-snapped rail on a phone with the next card peeking past the screen edge. The rail bleeds out by the container's gutter on purpose: a card clipped by the screen says "keep going", a card clipped by a padding box says broken. The rest of the frame: the hero glow moves to the top third where the copy is, the trust band's chips scroll in one row, the dashboard preview drops below the claims it illustrates, the endpoint summaries stop hiding, both closing buttons go full width, and the footer centres. Icons: 15 exported SVGs replace Material stand-ins. Each file is the whole 32×32 badge — background path, then glyph — so the hand-built tiles, the accent pairing and the hue constants are gone with them. That also corrects an inversion: the design fills the disc with the accent's 100 shade and draws the glyph in its 900, and the code had it the other way round. The dashboard badges are rounded squares again, which is what the export contains. Two bugs with one cause, both found by measuring rather than reading: `Stack` spaces with margins on every child but the first IN DOM ORDER, through a selector that beats a child's own `sx`. In the dashboard section, where `order` swaps the halves on a phone, that put 80px above the heading and nothing between the text and the preview; in an endpoint row it overrode the summary's `margin-left: auto`, so "All asset prices" never reached the right edge. Both take `useFlexGap`, which lays out in visual order and leaves margins alone. Also measured: the `Get` chip is a 29×24 rounded rectangle whose edge runs straight after ~3px of arc, not the pill it was rendering as.
Two screens: the first-login card (843:2356) and the returning view (844:9434), measured off the 2x exports rather than eyeballed. `GET /key` grows `created_at` and `last_updated_at`. Both were already on `KeyRecord`, read off the listing the reveal makes anyway, so the two instants the metadata row states cost no extra control-plane call — and "Issued" stops being a date computed from the browser's clock. What the frames changed, colour by measured colour: the page floor is #212121 and not #0f0f0f (the cards were reading as lighter than their surroundings), a card is a #1a1a1a title band over a #272727 body, its hairline is solid #535353 rather than 45% of it, the key's ring is #fdda24, the usage bar is a white pill with a #ffe945 → #cc9302 fill, and the status pill's fills are solid. Card titles and the page heading are equal on the frame — both 24px — where the code had 28 and 20. Columns are 7fr:5fr with a 16px gutter, measured at 740 and 524. The first-login card: "Your API Key is ready", the welcome sentence, the key UNMASKED (the one place task 0187's mask lifts — the visitor finished the round-trip seconds ago and the card says "copy it below"), Copy key beside View quick start, and Issued · Monthly quota · Rate limit. The quota rides up from the usage panel through `onUsage` rather than costing a second GetUsage. The returning view: Key ID · Issued · Last rotated · Discord account, the yellow rotation strip under it, and the two lower cards. Adam's review, in his words and against my objections where they differ: - The status pill says "Just issued" for any working key. A 24-hour rule read "Active" on his own days-old key, which is what he was looking at. It is a wrong adjective on an old key, not a wrong instruction; the honest condition is written at the render site. - The button is "Regenerate", not 0191's "Replace my key…", and the strip says "Key rotation is limited to once per calendar month" — both describe the swap model 0191 built and reversed. Nothing rotates: the dialog behind the button still says the key is deactivated and nothing is issued, and where the two disagree the dialog is the one telling the truth. - "Last rotated" labels `lastUpdatedDate`, which a console edit bumps. - The Discord numeric id leaves the screen for the column's `title`, so task 0186's "the username and the ID are on screen" no longer holds. - The usage card loses task 0188's lag line, its reset-rule sentence and its Refresh button — the frame has none of the three and task 0222's chart takes the space. What goes unsaid now is that a figure can trail the last request by minutes. The Rate Limit card stops disappearing. `/config` without `PORTAL_RATE_LIMIT` — every local run — was dropping a third of the dashboard; it now falls back to the free plan's documented 1 req/s, the same figure the landing page states, and a deployment's value still wins. Three CSS-specificity bugs with one cause, all found by measuring rather than reading: a `Stack`'s margin-based spacing and a chrome descendant rule (`.chrome code`, `.chrome button`) each outrank the single class Emotion puts on an element's own `sx`. That is why 80px of gap landed above the dashboard heading instead of between the columns, why "All asset prices" never reached the right edge, and why the key's value kept its grey chip through two attempts to remove it. Fixed by narrowing the rules, not by escalating specificity. Broken/modified tests, all intentional: two deleted (the lag line, the Refresh button) with a note in their place saying what went and how to restore it, and seven rewritten — the account row now finds the id by `title`, the notice pins the date rather than the sentence, and the rate-limit test asserts the fallback it used to forbid. Spawns 0222 for the daily-requests chart: the backend already reads the per-day series in `Gateway::usage_of` and throws it away in `summarize_days`, so the chart needs no new AWS call — only the contract.
The `Quick start` frame (`918:644`) as a third route, reachable from the signed-in header where the link previously pointed at the raw OpenAPI document — a JSON file answering a question nobody asked. The page is public: it is documentation, and a developer deciding whether to authorise the app is exactly who should read it. Signed in it wears the dashboard's bar with `Quick start` underlined, signed out the landing bar. Everything on it is a still. No request runs from here — a "try it" control needing the visitor's real key is what Swagger UI (0195) is for — so the one interactive thing is the copy button beside each snippet. The base URL and the paths are the DESIGN's, not this repo's OpenAPI document's, and the gap is the one `landing/Endpoints.tsx` already flags. Both live in a single constant so reconciling them is a two-line diff rather than a hunt. `/api-tokens/quick-start` joins `APP_ROUTES` in the hosting stack: without it a hard refresh resolves against S3 and comes back as 403 AccessDenied.
Five things, four of them the frame's and one a link that was wrong long before this page existed. The footer's dashboard link was a bare `href="/dashboard"`, which the browser resolves against the domain root — a path the deployment does not serve, since the bundle lives under `/api-tokens/`. It is a `RouterLink` now, like every other in-app destination on the page. The test that pins it renders WITH a basename, unlike its neighbours: without one the broken form and the fixed one produce the same string. The glow was positioned from the left edge of the page, where the frame measures it — but the frame's content column is not centred and ours is, so on a wide window the light sat out in the margin instead of on the word `Get`. It hangs off the headline itself now and needs no phone rule. The rail did not stay put: `main` carried `overflow: hidden`, which makes it a scroll container, and a scroll container is what `position: sticky` sticks to. `overflow-x: clip` still cuts the glow without creating one. Its active entry came from an `IntersectionObserver`, which is handed only the headings whose visibility just changed — so mid-section, or after landing on a `#hash`, it had nothing to say and the rail kept whatever it last knew. Measured on scroll instead, it always has an answer. `Contact us` is underlined, as the frame draws it, and still not a link: there is no commercial-plans destination, and the dashboard's Rate Limit card refuses the same 404 for the same reason. The three `What's next` badges are the real exports from Adam's `Designs.zip` rather than the nearest MUI glyph in a hand-drawn circle.
…resentable-ui-pass
e3154c8 landed on develop via PR #248, whose five files are all docs/ and lore/. Neither paths-filter in ci.yml matches those, so both jobs were skipped and the PR merged green -- but format:check --all, which lives in the skipped typescript job, covers every file in the repo. The emphasis style and the table padding drifted back to what 99b048e had already fixed, and the bill landed on the next PR touching TypeScript, which is 0193's. Cosmetic only: *right now* -> _right now_, and the predecessor table padded to prettier's column widths. No prose changed.
Two states the dashboard had only as sentences now render as the frames draw them. Monthly Usage at the ceiling: the count stays brand yellow, the bar runs a red gradient, and a red notice states the consequence a spent bar does not — that requests are being refused with HTTP 429 until the reset. New copy, owned by this slice: neither 0188's panel nor 0191's revoke flow covers the quota-reached state. The empty-key card: a red strip naming the likely cause, and one full-width Generate API Key control, with Monthly Usage and Rate Limit beside it deliberately empty so nothing competes with the page's only action. That empties two tiles on purpose, which reverses this slice's own "no blank screens" rule for exactly one state. Three decisions from other slices lose ground here, all from the frames rather than from this slice re-deciding them, and each is flagged where it happens: - 0189's eligibility prerequisites leave the key card. They are still stated on the landing page, before the visitor authenticates, which is where the epic's criterion places them. - 0187's "One key, on the free plan" is no longer stated anywhere. - 0186's criterion erodes further: the account column, and with it the numeric Discord id, leaves the empty card. The navbar still names the account. Restoring the id needs somewhere on the empty dashboard to live, which is a change to 0186. The usage suite stubbed an account with no key, incidental when the cards were independent and wrong once the key card's answer empties them. It now stubs a key, and the one test that is about having none asserts the empty tiles.
Backend. The sign-in callback now runs the membership check that until now only the issue round-trip ran, and refuses with ?signin=not_member or ?signin=unknown rather than seating a session that can do nothing. The two verdicts stay distinct for the reason they are distinct everywhere else in the portal: refusing somebody is not the same as failing to check them, and only one of the two is an accusation. An unreadable eligibility parameter, or a callback with no settings wired, is logged and treated as "could not verify" — never as "not a member". issue.rs is restructured around one issue() returning Issued, Capped, Failed or Unwired, so the callback reads as four landings rather than as a nest of early returns. The budget floor and deadline arithmetic move inside it unchanged. Frontend. oauthPopup allow-lists the two new outcomes alongside cancelled and failed — the value reaches a render branch and arrives on an attacker-supplied query, so it is matched, not passed through. The login card takes the exported Figma lockups in place of the text stand-ins that were holding their space, and the back link is settled: it renders above the card, aligned to the card's left edge rather than the container's, and a card that draws its own claims it through useOwnBackLink so the page never shows two. cargo test: 29 passed, 0 failed, 4 ignored. Portal: 134 passed.
…rd figures The sign-in card waited on three signals — the popup's postMessage, a 1.5 s /auth/me poll and a 500 ms closed-window watch — and the two slow ones could end the wait before the precise one arrived. The callback's 303 sets the cookie and lands the popup on its query, but the popup still has to download the bundle before it posts; and once it posts it closes in the same breath. So the poll could see the session and tear the listener down with ?issue=too_young still in flight, and the watch could see the window gone with ?signin=not_member still queued — the too-young visitor met a plain dashboard, the non-member met the generic failure card. Both slow signals now hold their verdict for a grace the message can overtake. Two specs pin each race. Three figures that read wrong: the usage bar called the limit reached on a rounded percentage (995 of 1,000), while the notice and the remaining count did not; the per-minute rate tile carried the per-second unit; and describeNextEligible let Date.UTC roll an impossible month or day into a confident real date. Portal: 138 passed (5 new). Lint, typecheck, prettier clean.
The callback read both eligibility parameters in one match and refused every sign-in as ?signin=unknown when either failed — including the account-age threshold, which sign-in never consults. A mis-seeded or throttled min-account-age parameter locked returning members out of a dashboard they were entitled to read. The two reads are now joined and judged separately: only the guild gates the session; an unreadable age costs the visitor the key half, and after_sign_in takes Option<u64>. A Failed from the sign-in's issue() landed on ?issue=failed, a banner that on a cold start — budget spent by the exchange, the parameter reads and two Discord calls — sat next to the working key GET /key revealed a moment later. The sign-in made no request for a key, so it lands plain, like Unwired; the explicit action=issue press keeps ?issue=failed, being the one that answered a request. portal_auth 44 passed (3 new), portal_issue 32, lib portal::auth 61. clippy -D warnings and fmt clean.
The modal has armed on regenerate-key since 2026-08-26, following the Regenerate button, but 0191's spec, ADR 0010 §8 and 0164's end-to-end checklist still said delete-key — a tester on that checklist would have filed the dialog as broken. Under 0193's own rule the decision goes to the owning task: decision 41 in 0191, with the reason and everything decision 5 still pins; the ADR and the checklist re-pointed; 0193's context line updated.
0222 was already taken on develop by the no-invocations alarm bug (PR #250) before this branch merged it in; two backlog files shared the id and [[0222]] resolved to both. 0226 is the next free id; the two code comments that cited it are re-pointed.
Three faults on the callback path, none visible in a test until now. An unwired deployment refused with ?signin=unknown, which renders 0189's "we could not check your membership — a problem talking to Discord" card. Nothing was asked and no Discord call was made: the portal is not open on that build, which is 0183's state and has a card of its own. It lands ?signin=not_open now, and the page renders that card instead of telling the visitor to retry something that cannot succeed until an operator wires the parameters. The membership read added on 2026-08-26 made three serial calls out of two, and at five seconds each plus two unbounded parameter reads the slow-but-not-failing case summed past the 15s invocation timeout — the Lambda was killed and the browser got a bare API Gateway 502 rather than any of the designed screens. Discord's per-call timeout is four seconds, the parameter reads are bounded at two, and the arithmetic that has to keep holding is written out on REQUEST_TIMEOUT. rfc3339 answered "" for an instant chrono cannot place on the calendar, so a corrupt createdDate serialized as an empty string where the contract — and an existing test — say null. It returns Option now. A test pins what deliberately did NOT change: a refused sign-in leaves an existing session alone. ADR 0010 grants the dashboard and the reveal to the session alone, "works forever", so clearing the cookie would revoke access the ADR gives; what the refusal owes the visitor is to be visible, and that is the page's half. cargo test 393 passed, clippy -D warnings clean, fmt clean, lambda build green.
A sign-in refusal was invisible to anyone who was already signed in. RootRoute forwards every authenticated arrival to /dashboard with the query attached, and ?signin= was read only by the signed-OUT card — so somebody who had left the Stellar Discord signed in again, was refused, and met an ordinary dashboard that said nothing. The dashboard now reads the two membership verdicts and renders them in the issue flow's own wording; cancelled and failed stay with the card that owns the button. The session is untouched, per ADR 0010. RootRoute dropped ?issue= for a signed-out visitor: a round-trip whose session expired mid-flight rendered the marketing page with the query still in the URL. It rides to /login like ?signin= does. The rest, each of which rendered something wrong or nothing at all: aria-describedby on the destructive dialog pointed at an id that did not exist, so a screen reader got the title and none of the warning; the copy confirmation was set once and never cleared, so a second copy gave no feedback, and it is announced now; the landing navbar's three links are anchors to landing-page sections and did nothing on the quick start, where they now point back at the page that has them; every non-current link in the signed-in bar was display:none at xs, leaving a phone with no way back to the dashboard and no OpenAPI link on either page; and a successful popup dropped the spinner before the session read answered, flashing the sign-in button at somebody who had just signed in. Tests: 152 passed (14 new). Seven guards that asserted the absence of a control the branch under test cannot render, and one that asserted a sentence which exists nowhere in the repo, now assert what they meant to; the two rate-limit specs stub /key, having run against a dashboard whose key card was in its failure state. New coverage for the quota-reached notice at and past the ceiling, the closed-portal and unreachable-backend states on /login, and the revoked card's sign-out.
|
Reviewed the whole diff — the UI work is lovely, and most of what I looked at for problems came back clean. Seven things, one of which I'd want settled before this merges. 🔴 Blocking:
|
stkrolikiewicz
left a comment
There was a problem hiding this comment.
Review
Ran the branch locally (portal + serve with the portal open) and drove the states.
Scope note: I have left out everything whose fix lives in the Figma file — the
landing's marketing copy, the endpoint list, the example-response fields, the
"Liquidity Data" pillar and the data-length work. Those change when the design changes
and get re-transcribed in a later PR; raising them here would just be noise.
What follows is code-side only.
Verified working
vitestgreen — 152 tests, 5 files (someact(...)warnings, no failures).- AC "no secrets, no third-party scripts in the bundle" holds. Built
disthas one
self-hosted<script>, self-hosted woff2, no CDN, and no signing key /AKIA*/
client_secretanywhere. - AC "every state renders something specific" holds. All eight
?issue=values
render their own copy;too_youngcomputes minutes fromwait_secs,cappedrenders
the date fromnext_eligible_at.not_membervsunknownare properly distinct —
replaced card with the invite vs a banner over a still-working button. - 0191 was amended (decision 41) rather than re-decided here. Right call.
Blocking-ish
1. packages/prices-api/src/portal/auth/issue.rs:316 — the issue callback can exceed the 15s Lambda timeout.
complete_issue awaits the two eligibility reads sequentially, where the sign-in path
joins them (auth/mod.rs:588-589). discord.rs:105-109 states the arithmetic as
exchange 4s + parameters 2s + membership 4s + identity 4s = 14s < 15s; on this path it
is 4 + 2 + 2 + 4 + 4 = 16s. Cold Parameters extension + slow Discord → killed
invocation and a bare API Gateway 502, which is the failure the 5s→4s cut here was made
to close. issue.rs:190-196 still says 5s.
2. app.tsx:2603 — red "Not issued" pill above "your key was created". Reproduced on
screen: the pill reads Not issued while the body says "Your key was created, and is
taking a moment to appear" (data-testid="issue-ok-settling"). The status prop keys off
view.state === 'none' with no landedWithKey guard; emptyKeyCard (app.tsx:2420) is
exactly the guard it needs, and the neighbouring copy at app.tsx:2626 already has it.
3. app.tsx:1322 — the revoked card unmounts the ?signin= refusal. issueOnLanding
(app.tsx:1305-1307) reads only the issue key, so /dashboard?signin=not_member on an
account with a revoked key shows the revoked card and no refusal — reproduced. The
one-shot param is already consumed, so a reload cannot recover it.
4. app.tsx:4172 — a transient /auth/me failure is indistinguishable from signed out.
gate.authenticated is false for both state === 'failed' and genuinely signed out
(app.tsx:4286-4287), so DashboardRoute redirects to /. Reproduced with a 502: the
visitor lands on the marketing page with "Get API Key" CTAs and no mention of the failure.
The reason renders only in LoginView (app.tsx:609), i.e. /login.
5. app.tsx:307 — a failed sign-out renders as a successful one. The catch stores
{state:'failed'} → redirect to landing, but the HttpOnly cookie was never cleared, so a
reload lands back on the dashboard with the key visible. Matters on a shared machine.
No test covers a failing logout.
Mobile (checked at 375 px)
No horizontal overflow. Two things do not hold:
DashboardChrome.tsx:198— the sign-out button has no accessible name and is 32×24 px.
Its text isdisplay: noneundersmand the glyph isaria-hidden, with no
aria-labelin the file — measured empty accessible name, 32×24. The PR description
says "44 px touch targets".app.spec.tsx:1044passes because jsdom ignores the media
query.- The dashboard navbar breaks into three rows — "Dashboard / Quick start" wraps above
the brand and sign-out lands alone on its own line.
Contradictions with settled decisions
6. Faq.tsx:86 — "The replacement is issued straight away". That is the model 0191
superseded on 2026-08-21; the modal on the same site says the opposite. Same drift at
DeveloperDashboard.tsx:38 ("Rotate once per month if needed"). Fixing it means editing
0191, per this task's own rule.
7. Prerequisites no longer stand before the sign-in button. app.tsx:846-864 records
their removal on 2026-08-26 at Adam's instruction; they now live only in a collapsed FAQ
accordion (Faq.tsx:38-46), so anyone landing on /login sees neither. 0193's AC still
says they must. If the removal was deliberate, the AC should move — right now the task
claims something the code does not do. (Question, not an objection — was that decided
somewhere I have not seen?)
8. QuickStart.tsx:740,756 — a fabricated 429 contract. The page renders a 429 body
with Retry-After: 1 and code RATE_LIMIT_EXCEEDED, behind a copy button. Neither
string exists in packages/ or infra/, and API Gateway's default 429 sends no such
headers. This is decision #3, still open — the design only said "what headers to watch",
so this concrete contract was invented here.
9. links.ts:22 — export const SWAGGER_UI = OPENAPI_JSON; Every "Swagger UI"
affordance silently opens the raw /api-docs-json. 0193 says link out to 0195, which
is backlog. The design promising Swagger UI is a Figma matter; aliasing the button to
something else is ours.
Smaller
app.tsx:2738— the partial-revocation warning says "Press Replace my key… again",
but the control is named "Regenerate" and renders underview.state === 'ok'while
the warning renders under'revoked'— so it is absent, not just renamed.
app.tsx:1319-1321still claims "the control exists and the warning sits beside it".app.tsx:3042— "Last rotated" labelslast_updated_at, which both halves of the
contract added in this PR (keys/mod.rs:291-295,portal.ts:454-456) say must read
"Last updated" because nothing here rotates and any record edit bumps it.app.tsx:3413— 0188's usage-lag line is gone while the comment above still says "the
reset rule and the lag line — are untouched below";as_ofis now unread though still
served. Belongs back in 0188.app.tsx:3070— the Discord ID survives only as atitleinside
MetaRow hidden={justIssued || emptyKeyCard}, andDashboardPanel.tsx:773returns null
when hidden — so in the no-key state (the one that ends in a support request) it is not
even on hover.api/portal.ts:416—signOut()is the only call that skipsisTimeoutand
readEnvelope/failureMessage, so a stalled logout is reported as "could not be
reached" and the backend's own message is discarded. Compounds #5.app.tsx:1216-1229— Terms of Service and Privacy Policy render as<span>, not<a>
(decision #4 open), so the visitor agrees to two documents they cannot open.Endpoints.tsx:189—{tok(KEY, '"change_24h"')} {tok(NUM, '+2.14')},renders
"change_24h" +2.14. The hero copy of the same snippet got its colon
(Terminal.tsx:79-81); this one did not. Worth fixing even though the block is due to
be replaced, since it is a divergence between the code's own two copies.app.tsx:493(plausible, timing-dependent) — once the poll sees the cookie the opener
waits 1500 ms then discards the popup'spostMessage. The popup must boot the bundle
before it can post, so on a cold cache a?issue=too_youngcan land nowhere.
One thing worth naming out of band
api.soroswap.finance is compiled into the production bundle, and the quick start tells a
reader to paste their real key into a curl aimed at it. I have deliberately kept the
design content out of this review — but this particular one is live on the deployed page
until the redesign lands, and right now the deferral exists only as a comment in
QuickStart.tsx:42-46. Worth a backlog item so it is tracked rather than remembered.
complete_issue awaited the guild id and the account-age threshold one after the other, where the sign-in callback joins them. On a cold Parameters extension and a slow Discord that is 4 + 2 + 2 + 4 + 4 = 16s against the 15s invocation: the Lambda killed, a bare API Gateway 502 in place of ?issue=failed, and possibly a key created and never attached — the failure the 5s→4s cut was made to close. The ISSUE_BUDGET docblock still described the 5s terms; it now describes the 4s ones. A unit test adds the terms up against the invocation timeout, so the next constant raised fails a test rather than a visitor. REQUEST_TIMEOUT and PARAMETER_TIMEOUT widen to pub(super)/pub(crate) for it. the_only_redirect_targets_are_the_portal_itself now covers NOT_OPEN_QUERY, ISSUE_CANCELLED_QUERY and ISSUE_DENIED_QUERY — it guarded fewer targets than the code emits. Review: stkrolikiewicz #1, karczuRF smaller #1 on PR #249.
Four review findings on PR #249, all the same shape: a state the page knew about and rendered as something else. The status pill read "Not issued" in red over "your key was created, and is taking a moment to appear" — it keyed off view.state === 'none' alone, the one reader that had not moved to emptyKeyCard. issueOnLanding read only ?issue=, so a sign-in refusal (?signin= not_member / unknown, since sign-in runs the membership check) on an account with a revoked key rendered the revoked card, which unmounts the one component that renders the refusal. The one-shot parameter was already consumed, so a reload could not recover it. A /auth/me that answered 502 was indistinguishable from "signed out": DashboardRoute redirected to the landing page and its "Get API Key" buttons, and the reason rendered only on /login. SessionState.failed now says which request failed, and the dashboard renders it with a retry — "backend unavailable" is one of this task's owed screens. A failed sign-out rendered as a successful one: the HttpOnly cookie was never cleared, so a reload put the key back on screen. It now says so, offers to try again, and does not offer sign-in to somebody who is signed in. signOut() goes through isTimeout and the error envelope like every other call, so the backend's sentence reaches the page. Four tests, one per finding. Portal: 155 passed. Review: stkrolikiewicz #2-#5 and "api/portal.ts:416", karczuRF #2-#3.
…r host, honest labels Nine review findings on PR #249 with one shape: a sentence on the page that the code, the contract or a settled decision contradicts. The quick start rendered an invented 429 — a Retry-After header and a RATE_LIMIT_EXCEEDED code that exist nowhere in packages/ or infra/ — behind a copy button. Measured against the production free plan (120 concurrent requests, 80 throttled): x-amzn-errortype TooManyRequestsException, body {"message":"Too Many Requests"}, no Retry-After. The page shows that and says so (decision #3). The quick start told a reader to paste their real key into a curl aimed at api.soroswap.finance. The HOST is now the execute-api base docs/scf/api-endpoints.md documents; the design's paths stay and are tracked as 0227 (spawned here) rather than remembered in a comment. SWAGGER_UI was aliased to the raw OpenAPI document, so every affordance silently opened /api-docs-json. Renamed API_REFERENCE — for what it opens; 0195 re-points one constant. "Last rotated" is "Last updated": the value is lastUpdatedDate, nothing rotates (0191), and both ends of this PR's own contract said so. The 2026-08-25 frame label is reversed at the render site, with the date. 0188's lag line is back under the meter, verbatim, small: 0188 decided it once and this slice restyles it; as_of was served and unread. Also: the partial-revocation warning names the control that exists (Regenerate, decision 41) and what brings it back (a reload); the no-key card carries the Discord id it ends a support request with (0186 AC); each of two constants sits under its own docblock; Endpoints.tsx's "change_24h" gets the colon its twin in Terminal.tsx has. Review: stkrolikiewicz #6-#9 and "Smaller", karczuRF "Smaller" #2-#3.
…ke copy PR #249's review found two landing sentences transcribed from Figma that stated the model this task superseded on 2026-08-21: the FAQ's "The replacement is issued straight away" and the claims card's "Rotate once per month if needed". Both are corrected in 0193 to restate decision 5; this amendment records it here, where the words are owned, so a future re-transcription of the frames does not bring them back.
.mcp.json rode along with the product change (3e265de) and auto-configures https://mcp.figma.com/mcp for everyone who opens the repository. Dev tooling, not shipped code, and a third-party endpoint in a PR whose own posture is no CDN fonts and no third-party scripts — flagged on review (karczuRF). Untracked and gitignored; each developer keeps their own.
… two rows on a phone The prerequisites are back before the sign-in button: on /login as one line above the control in 0189's words, and on the landing page as the FAQ row that carries them, open by default. 2026-08-26 had moved them into a collapsed accordion; the acceptance criterion says "states", and a collapsed row states nothing (stkrolikiewicz #7). Decision #5. The legal footer is not rendered until the documents exist — two underlined spans asked the visitor to agree to Terms and a Policy they could not open. Decision #4, returns as links with the URLs. The landing FAQ and claims card restate 0191 — regenerate, not rotate; deactivated now, issued next period — instead of the model it superseded (stkrolikiewicz #6; recorded in 0191's amendment section). The sign-out button has an accessible name at every width (aria-label; its text is display:none under sm and its glyph aria-hidden — measured empty) and a 44 px hit area around the 24 px glyph. The signed-in bar wraps into two rows by construction at xs — wordmark, then links and account — where it used to break into three; the handle hides on a phone and the Discord glyph stands for it. Two tests added, one rewritten. Portal: 156 passed. Review: stkrolikiewicz #6, #7, Mobile, "Smaller" (ToS); karczuRF .mcp.json.
… revoke copy Found while driving the dashboard in a browser after PR #249's review round: the frame's yellow strip under the key ("Key rotation is limited to once per calendar month. Next rotation available: …") stated the swap model this task reversed. Corrected in 0193 to this task's terms; recorded here with the two landing sentences, for the same reason.
…regenerate on the strip Driven in Chrome at a 375 px viewport against a stand-in backend. The bar still broke into three rows: three 15 px labels, the account glyph and a 44 px sign-out summed past the 343 px container and sign-out fell onto a line of its own. Labels 14 px, 8 px gaps and no account block on a phone (the handle is on the key card) bring the row to ~300 px — measured two rows, sign-out 44×44 with the name "Sign out". The key card's yellow strip read "Key rotation is limited to once per calendar month. Next rotation available: …" — the frame's words, and the swap model 0191 reversed; the dialog one click away said the opposite. It now says what happens, in 0191's terms: regenerating is limited to once per quota period and issues nothing now, a new key from the next period's start. The date is unchanged. Portal: 156 passed.
…s on karczuRF's blocking question — does Discord's REST member object carry `pending`? — cannot be answered on production: the secret, the guild-id parameter and the Developer Portal scope are all unseeded there. It can be answered locally, through the same code path (discord::guild_member), against the real Stellar guild. The script restarts the local serve with PORTAL_GUILD_ID pointed at 897514728459468821, captures the log, waits for one browser sign-in and reads the verdict out of it. The guild is the measurement: Discord populates `pending` only where Membership Screening is on, so the scratch guild the local rig runs against answers a different question. Six outcomes, and only two of them are measurements: present (membership resolved to Member) and absent (the pending_absent warn). invalid_scope, Unknown Guild, a failed membership call and not-a-member each say so and name what to fix. The success path logs no line of its own, so "present" is read from what can only follow a Member verdict — a key issued, the re-issue cap, or the age refusal. It changes no AWS resource. It does warn, twice, that a successful run creates a real production key on the real free plan, and prints the delete command.
… `pending` Measured 2026-08-27 on the scratch guild with one full sign-in round-trip through the same code path production runs (discord::guild_member). The member response carried `pending: false`. The evidence is an absence, so the inference is written out: the log has `portal issued an API key created=false` and zero WARN/ERROR lines, and sign-in issuance is reachable only through Membership::Member, which requires pending == Some(false). This disproves R1's worst case — the field is not missing from this route, so the fail-closed arm does not refuse every member — which is what PR #249's blocking question asked. It does NOT close production: that gates on the real Stellar guild, and item 4 (what `pending` means with screening off) turns on a server setting only Adam can confirm. Both recorded as open rather than assumed.
…ecord its result
Adam runs this against his own server — he owns it, so Membership
Screening is a variable he can toggle, which makes it a better instrument
than one observation against a guild whose settings we cannot change.
The real Stellar guild stays one env var away (GUILD=897514728459468821).
The header carried a prediction ("screening off should give the
pending_absent warn"); the 2026-08-27 run against this guild answered
`pending: false`, so the prediction is replaced by the result and by what
is still unconfirmed — which way screening was set, which is the
difference between closing 0189 item 2 and closing items 2 and 4.
…lop merge Both ids had been taken on `develop` while this branch was open: 0226 by the oracle-worker registry-load bug, 0227 by the oracle timestamp-unit bug that shipped in PRs #256 and #259. Merging develop in ahead of PR #249 put two different tasks under each number. The branch's tasks move to the next free ids (highest on develop is 0231). Re-pointed with them: the two comments citing the chart task (`app.tsx`, `app.spec.tsx`), the two citing the OpenAPI reconciliation (`landing/Terminal.tsx`, `quickstart/QuickStart.tsx`), and 0193's three references. Both task files record the renumber in `history`. This is the second collision for the chart task — it was already 0222 → 0226 on 2026-08-27, for the same reason.
0193 merged as PR #249 (d53cfc2). All ten acceptance criteria met, two with a stated limit rather than a tick: 375 px is met by construction and review and not in a browser, and epic AC 2/AC 4 are exercised against a stubbed backend because the portal ships closed. Adds the Implementation Notes and Issues Encountered the completion checklist asks for, including the two 2026-08-25 calls the review reversed and the two task-id collisions with develop. Future Work no longer sits as prose: 0234 spawned for the popup's 1500 ms grace, joining 0232 and 0233. 0234 records that the bound was chosen rather than measured, and points at the afterGrace comment before anyone shortens it. 0194 activated — every slice its audit composes has now shipped, so the assembled methodSettings array and the CloudFront policy can be read off a real synthesized template.
Summary
theme/tokens.ts(the file's variables, verbatim) andtheme/theme.ts(the interpretation), with the three families self-hosted — no CDN fonts, no third-party scripts on a page that renders a credential./quick-start— prerequisites through SDK examples, every snippet copyable, reachable from the signed-in header where the link used to point at the raw OpenAPI document./api-tokens/quick-startjoins the hosting stack's route list so a hard refresh does not 403.No copy owned by another slice was changed here — the eligibility refusals are 0189's, the delete-key and revoke wording 0191's, the usage lag line 0188's.