Commit Graph

6 Commits

Author SHA1 Message Date
Jiayuan Zhang
7803a5b9ea feat(ui): establish a role-named type scale and migrate ad-hoc font sizes (MUL-5451) (#6136)
tokens.css defined colours, radii and font families but not a single --text-*
step, so font sizes had no baseline to align to and grew wherever they were
needed: 51 distinct sizes across web + desktop, 370 written as arbitrary
values, six at half a pixel (10.5 / 11.5 / 12.5 / 13.5 / 14.5 / 15.5px).
text-xs and text-sm carried nearly all UI text while the range between them —
11, 13, 15px — could only be reached with arbitrary values. Hierarchy does not
come from having more sizes; past a handful, each extra size makes the
hierarchy blurrier.

Add ten role-named steps, each with its own line-height so leading cannot
fragment the way size did, and move every product-UI call site onto them.
Steps are named for what the text is for, not for a t-shirt size, because
that is what keeps the scale from drifting again.

Six steps deliberately keep the exact size/line-height pairs of the Tailwind
defaults they replace, so the ~1,900-call-site rename moves nothing on screen.
The visible changes are confined to former arbitrary values snapping to a step:
8/9/10px -> micro (11px) on badges and overlines; 17 -> 18; 22 -> 24; 30
(text-3xl) -> 36 on headings and stat numbers; 12.8px -> label (13px) on small
buttons and toggles. Half-pixel sizes are gone.

This supersedes #6108, which was reverted by #6116 because the sidebar group
labels rendered at the inherited 16px. The cause was not the scale but cn():
`text-<x>` is ambiguous in Tailwind, and tailwind-merge resolves it against a
table listing only the default sizes, so it filed every role step under
text-colour and dropped whichever of `text-caption` /
`text-sidebar-foreground/70` came first. Registering the steps as a font-size
class group restores the real conflict groups — size beats size, colour beats
colour, the two coexist — and a test pins the list against the scale, since
the failure is silent in source.

Hand-written CSS is covered too. The transcript kept a 12.5px body long after
every Tailwind call site was on the scale, so the "no half-pixel sizes" claim
was true of the classes and false of the product; the editor's prose, code and
mermaid ramps had the same blind spot, and seven of their eight values already
equalled a step exactly. All now reference var(--text-*). The guard test reads
raw `font-size:` declarations as well as class names, exempting only the 16px
iOS input-zoom workaround in base.css and the landing pages' marketing ramp.

apps/mobile (own NativeWind config) and apps/docs (fumadocs' own type system)
keep Tailwind's default scale and are untouched. Landing display type
(rem/clamp, 2.2-6.4rem) stays on its separate ramp, as do four decorative
emoji / serif-hero sizes.

Verified on a running local stack: pinned sidebar rows and group labels
measure 12px/16px, nav items 14px/20px — identical to pre-migration. An audit
of every rendered font size across the product surfaces finds nothing off the
scale; the only exceptions are avatar initials and emoji, which
actor-avatar.tsx sizes proportionally to the avatar diameter by design.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 13:42:33 +08:00
Jiayuan Zhang
545afea827 Revert "feat(ui): establish a role-named type scale and migrate ad-hoc font s…" (#6116)
This reverts commit d68d636c91.
2026-07-29 17:10:33 +08:00
Jiayuan Zhang
d68d636c91 feat(ui): establish a role-named type scale and migrate ad-hoc font sizes (MUL-5451) (#6108)
tokens.css defined colours, radii and font families but not a single
--text-* step, so font sizes had no baseline to align to and grew wherever
they were needed: 51 distinct sizes across web + desktop, 370 of them written
as arbitrary text-[Npx] values, six at half a pixel (10.5 / 11.5 / 12.5 /
13.5 / 14.5 / 15.5px). text-xs and text-sm carried nearly all UI text while
the range between them — 11, 13, 15px — could only be reached with arbitrary
values. Hierarchy does not come from having more sizes; past a handful, each
extra size makes the hierarchy blurrier.

Add ten role-named steps, each with its own line-height so leading cannot
fragment the way size did, and move every product-UI call site onto them.
Steps are named for what the text is for, not for a t-shirt size, because
that is what keeps the scale from drifting again.

Six steps deliberately keep the exact size/line-height pairs of the Tailwind
defaults they replace, so the 1,900-call-site rename (text-sm -> text-body
and friends) moves nothing on screen. The visible changes are confined to
former arbitrary values snapping to a step:

- 8 / 9 / 10px -> micro (11px): 102 sites, mostly badges and overline
  labels. Deliberate — under 12px is a counter role, not a text role.
- 17px -> title (18px), 22px -> display-sm (24px), 30px (text-3xl) ->
  display (36px): 21 sites, all headings or stat numbers in flexible
  containers.
- Half-pixel steps are gone entirely.

Arbitrary sizes also inherited whatever line-height was above them; the
tokens now pin one, which removes latent overflow risk in the fixed-height
h-4/h-5 badges those sizes were used in.

apps/mobile (own NativeWind config) and apps/docs (fumadocs' own type system)
keep Tailwind's default scale and are untouched. Landing-page display type
(rem/clamp, 2.2-6.4rem) is marketing typography on a separate ramp and stays
out of the scale, as do four decorative emoji / serif-hero sizes.

A guard test fails the build on any font size written outside the scale,
reporting file:line — without it nothing in a Tailwind build makes an
off-scale value look wrong, which is how the drift happened.

Verified against a local stack: typecheck, lint and the full TS suite show no
new failures, and before/after screenshots of issues, issue detail, runtimes
(populated), usage, agents, inbox, my-issues and settings differ only in the
intended 10px -> 11px labels, with no row-height, truncation or layout shift.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 16:30:13 +08:00
LinYushen
372a330707 fix(billing): wire test page through useT instead of silencing i18n rule (#3451)
The previous fix (#3446) for the i18next/no-literal-string CI failure
took the lazy route — added a file-level eslint-disable comment with
the rationale that the test page is slated for deletion when the real
billing UI ships, so investing in a translation namespace was wasted
churn.

That argument is weak in practice:

  * The test page has been around since #3442 and there is no
    concrete date for when the real billing UI lands. "Throwaway"
    surfaces tend to outlive their stated half-life.
  * File-level disables hide future violations of the same rule from
    review — anyone touching this file later inherits an opt-out
    they didn't ask for.
  * The translation namespace is genuinely cheap. The page has ~50
    distinct strings, all of which now have a single en.json/zh.json
    home that the locale-parity test guards.

This commit replaces the silencer with a real `billing` namespace:

  * packages/views/locales/en/billing.json + zh-Hans/billing.json —
    every literal English label from the page, plus interpolated
    sentences with i18next `{{var}}` placeholders. The zh-Hans
    bundle is a basic translation; not perfect prose, but the parity
    test passes and a Chinese reviewer testing the page won't see
    raw English mixed with native UI chrome.
  * packages/views/i18n/resources-types.ts — adds the namespace to
    `I18nResources` so `t($ => $.billing.x.y)` is type-checked.
  * packages/views/locales/index.ts — registers both bundles in
    `RESOURCES` so the parity test sees them.
  * packages/views/billing/billing-test-page.tsx — every JSX literal
    rewritten as `t(($) => $.path)`, including aria-label,
    interpolated sentences (balance meta line, transactions row meta,
    paging footer), and conditional branches (with-bonus vs
    without-bonus rendering goes through two distinct keys rather
    than two literals). The file-level eslint-disable is removed
    along with its multi-paragraph rationale comment.
  * formatDate now takes `t` as an argument so the en/zh "—" dash
    placeholder is itself translated; calling useT inside the util
    would have violated the rule of hooks (the function is called
    from conditional render branches).

Verification:
  * pnpm --filter @multica/views lint   — 0 errors (15 unrelated
    warnings, all pre-existing on main)
  * pnpm --filter @multica/views typecheck — clean
  * pnpm --filter @multica/views test    — 883/883 passing,
    including the locale-parity test (which fails the build if
    en and zh-Hans diverge)

When the real billing UI ships and this file is deleted, the
`billing` namespace JSONs and the `resources-types.ts` /
`locales/index.ts` entries get deleted with it — same blast radius
as the eslint-disable would have had, but with proper i18n along
the way.
2026-05-28 17:38:46 +08:00
Bohan Jiang
9b280bc631 chore(billing): silence i18next/no-literal-string in dev-only test page (#3446)
billing-test-page.tsx (introduced in #3442) ships dozens of raw English
JSX labels and is explicitly documented as "not a finished UI — slated
for deletion when the real billing UI ships". Routing every label
through useT() and inventing throwaway translation keys would be
wasted churn that gets deleted in the same week the real UI lands.

Add a file-level eslint-disable so CI passes on every PR that touches
unrelated files. When the real billing UI replaces this page, this
file (and the disable) gets deleted together.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 17:14:52 +08:00
LinYushen
7bbca54e92 feat(billing): test page consuming /api/cloud-billing/* (#3442)
* feat(billing): test page consuming /api/cloud-billing/*

Stuffs every cloud-billing endpoint onto a single dev page so we can
verify the proxy + Stripe end-to-end flow without a designed UI.
Reachable at /<workspaceSlug>/billing — the page is account-level
data but lives under the workspace dashboard layout because that's
where the authenticated shell sits. No sidebar entry on purpose;
this is test-quality and meant to be deleted when the real billing
UI ships.

What's there:

  * Balance card (GET /balance)
  * Stripe-success polling banner — visible only when ?session_id=
    is in the URL (Stripe substitutes it into checkout_success_url
    on its way back). React Query refetchInterval polls every 2s
    until the topup status reaches credited / failed / canceled,
    then a 'Clear from URL' button calls navigation.replace(pathname).
  * Buy section: server-authoritative price tier buttons (GET
    /price-tiers) → POST /checkout-sessions → window.location to the
    Stripe URL. We do NOT hard-code amounts on the frontend; tier
    config lives in cloud's billing.price_tiers.
  * Stripe Billing Portal button (POST /portal-sessions). Opens in a
    new tab so the originating page stays put for easy verification.
    Documented behaviour: 400 is expected for users with no Stripe
    customer record yet.
  * Three lists: transactions / batches / topups.

Plumbing:

  * packages/core/types/billing.ts — interfaces mirroring the cloud
    response shapes. Status / source / tx_type fields are typed
    'string' rather than enum unions to match the schemas' z.string()
    parsing (same convention as CloudRuntimeNode); the canonical
    enum values are exported as separate type aliases for callers
    that want to switch on them.
  * packages/core/api/schemas.ts — 9 zod schemas + 7 EMPTY_ fallbacks,
    all .loose() so a non-breaking cloud-side field addition doesn't
    crash the parser.
  * packages/core/api/client.ts — 8 methods using parseWithFallback,
    matching the existing cloud-runtime shape.
  * packages/core/billing/{queries,mutations,index}.ts — React Query
    queryOptions + mutations. Notable choices: balance / lists are
    NOT keyed on workspace (account-level data), and the
    checkout-session polling stops automatically when status is
    terminal so we don't poll forever after a user closes the tab.
  * packages/core/package.json + packages/views/package.json — exports
    map updated for @multica/core/billing and @multica/views/billing.

Verification:

  * pnpm --filter @multica/core typecheck clean
  * pnpm --filter @multica/views typecheck — only pre-existing
    hast-util-to-html error in editor code (exists on main)
  * pnpm --filter @multica/core test — 412 passing
  * pnpm --filter @multica/views test — 877 passing, 1 failure
    (editor/readonly-content) is also pre-existing on main, not
    caused by this change

Out of scope: real production-quality billing UI; sidebar entry; i18n
strings; mobile app. This is a single test page; it gets replaced
when the real UI ships.

* fix(billing): refetch balance/lists when checkout polling reaches terminal

Closes the second-half of the Stripe-return race the previous commit
left dangling.

Symptom:
  After Stripe redirects back with ?session_id=..., the banner polls
  /checkout-sessions/{id} every 2s and the rest of the page (balance,
  transactions, batches, topups) is fetched once on mount. The
  webhook race means those four queries usually see pre-credit state
  — but the banner is the only thing that keeps polling, so once it
  reads 'credited' nothing else on the page knows. The user would
  see 'Final status: credited' next to a stale balance card until
  they manually refresh.

Fix:
  Add useInvalidateBillingDataAfterCredit() in @multica/core/billing —
  a hook returning a callback that flushes balance / transactions /
  batches / topups (NOT the checkout-session itself; its
  refetchInterval already terminated, refetching would just confirm
  the same value). The Stripe-success banner runs this callback in
  a useEffect keyed on terminal-status transition, so it fires
  exactly once when the polling lands.

  Strict scope is documented in the hook's JSDoc:
    - balance/transactions/batches: only change at the 'credited'
      transition (cloud writes ledger + batch + wallet in one DB tx)
    - topups: changes on every terminal transition
    - For 'failed' / 'canceled' we technically over-fetch the first
      three; three cheap round-trips, simplifies the call site, fine
      on a test page.

  Effect dep is . terminal flips false→true at most once
  per session id (the polling stops when terminal is true so the
  data won't change again). If the user lands here with a session
  that is already terminal (re-opened tab on a credited URL), the
  effect still fires on first data load and we still re-fetch —
  correct, the cached snapshot is just as stale in that case.

go build / pnpm typecheck / pnpm test clean (core 412 passing; only
pre-existing hast-util-to-html error in unrelated editor code on
views, same as on main).
2026-05-28 16:48:04 +08:00