Skip to content

feat(0193): the portal's UI pass — landing, dashboard, quick start - #249

Open
adamkoot wants to merge 9 commits into
developfrom
feat/0193_portal-presentable-ui-pass
Open

feat(0193): the portal's UI pass — landing, dashboard, quick start#249
adamkoot wants to merge 9 commits into
developfrom
feat/0193_portal-presentable-ui-pass

Conversation

@adamkoot

Copy link
Copy Markdown
Collaborator

Summary

  • Installs MUI 7 + Emotion and transcribes the Figma design system into theme/tokens.ts (the file's variables, verbatim) and theme/theme.ts (the interpretation), with the three families self-hosted — no CDN fonts, no third-party scripts on a page that renders a credential.
  • Builds the landing page from its frame: hero, features, endpoints, fair access, FAQ and the login section, with both prerequisites — Stellar Discord membership and the minimum account age — stated before the sign-in button.
  • Builds the dashboard from its frames: the key masked with a reveal toggle and copy, usage against quota as figures, the reset date and the rate limit, and the rework and revoke actions under a signed-in bar of its own.
  • Adds the quick start at /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-start joins the hosting stack's route list so a hard refresh does not 403.
  • Styles every non-happy state as a screen rather than a blank page, including the closed-portal state, and keeps "could not verify membership" visually and textually distinct from "not a member".
  • Works at 375 px throughout: card rails, a stacked footer, and 44 px touch targets.

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.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant