From 78ee58cca69a167a285cc2fdf043aec1a2954223 Mon Sep 17 00:00:00 2001 From: Stuart Date: Tue, 22 Sep 2026 23:49:47 +1000 Subject: [PATCH 1/2] STU-383: build the account overview with real identity, usage and honest history states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuild the account view into a single-source-overview bound to the same session/subscription/metered state as the topbar (renderAccountOverview feeds from renderUsageSurfaces): identity from the signed-in email, real plan/price, a metered usage meter, and honest period information. A renewal/reset date is shown only from a real subscriptionState.periodEnd — never fabricated. The per-run breakdown/history the redesign illustrated is not persisted, so the overview renders a truthful no-history state and keeps current results reachable via the generation workspace. Guest/loading/unresolved states are preserved and never surface stale private identity. Verified: npm test (290), npm run build, and a targeted Playwright spec whose load-bearing assertions were falsified (break -> FAIL -> restore -> PASS). --- app.js | 141 ++++++++ e2e/stu383-account-overview.spec.js | 324 ++++++++++++++++++ index.html | 293 +++++++++++++++- .../account-free-zero-usage-desktop.png | Bin 0 -> 152870 bytes .../account-free-zero-usage-mobile.png | Bin 0 -> 138611 bytes .../account-power-max-usage-desktop.png | Bin 0 -> 161342 bytes .../account-power-max-usage-mobile.png | Bin 0 -> 146799 bytes ...-professional-31-50-long-email-desktop.png | Bin 0 -> 170943 bytes ...t-professional-31-50-long-email-mobile.png | Bin 0 -> 154220 bytes .../stu-383/account-unresolved-desktop.png | Bin 0 -> 162520 bytes .../stu-383/account-unresolved-mobile.png | Bin 0 -> 146698 bytes 11 files changed, 747 insertions(+), 11 deletions(-) create mode 100644 e2e/stu383-account-overview.spec.js create mode 100644 screenshots/stu-383/account-free-zero-usage-desktop.png create mode 100644 screenshots/stu-383/account-free-zero-usage-mobile.png create mode 100644 screenshots/stu-383/account-power-max-usage-desktop.png create mode 100644 screenshots/stu-383/account-power-max-usage-mobile.png create mode 100644 screenshots/stu-383/account-professional-31-50-long-email-desktop.png create mode 100644 screenshots/stu-383/account-professional-31-50-long-email-mobile.png create mode 100644 screenshots/stu-383/account-unresolved-desktop.png create mode 100644 screenshots/stu-383/account-unresolved-mobile.png diff --git a/app.js b/app.js index 4d7172c..7a2961a 100644 --- a/app.js +++ b/app.js @@ -4744,6 +4744,147 @@ function renderUsageSurfaces() { guestEl.textContent = `${gCount} / ${TIER_LIMITS.free} generations used`; } } + + renderAccountOverview(); +} + +/** + * Renders the account overview: identity, plan, usage meter and available + * period information. This is the single presentation adapter for the account + * view and reads the same session, subscription and metered state as the + * topbar (renderUsageSurfaces), so sign-in/out and a usage refresh move all + * surfaces together. + * + * Honesty contract, enforced by render: every number/date/identity traces to a + * real field or a documented derivation (email-derived display name/avatar + * initial, metered count, tier limit). A renewal/reset date is shown ONLY when + * subscriptionState.periodEnd is a real value — never fabricated — and an + * unresolved plan surfaces checking/unavailable copy, never a stale balance. + */ +function renderAccountOverview() { + const signedIn = authState.isVerified && !!authState.email; + if (!signedIn) { + // Signed-out/guest state has its own container; leave the signed-in + // overview untouched in the hidden subtree so no previous identity lingers + // anywhere a later sign-in could briefly flash it. + return; + } + + const loading = isSubscriptionLoading(); + const resolved = isSubscriptionResolved(); + const usage = getUsage(); + const count = resolved ? usage.count : 0; + const limit = resolved ? getRunLimit() : 0; + const remaining = Math.max(0, limit - count); + const tier = resolved ? (subscriptionState.tier || "free") : null; + const tierLabel = { free: "Free", professional: "Pro", power: "Power" }[tier] || "Free"; + + // --- identity: real email, documented derivations only, no member-since --- + const email = authState.email || ""; + const localPart = email.split("@")[0] || email; + const avatar = document.getElementById("acct-avatar"); + if (avatar) avatar.textContent = (email[0] || "?").toUpperCase(); + const name = document.getElementById("acct-name"); + if (name) name.textContent = localPart || "—"; + + // --- plan --- + const planTier = document.getElementById("acct-plan-tier"); + const planPrice = document.getElementById("acct-plan-price"); + const planNote = document.getElementById("acct-plan-note"); + const renewal = document.getElementById("acct-renewal"); + if (planTier) { + planTier.textContent = loading ? "Checking…" : resolved ? tierLabel : "Plan unavailable"; + } + if (planPrice) { + if (loading) planPrice.textContent = "—"; + else if (!resolved) planPrice.textContent = "—"; + else if (tier === "free") planPrice.textContent = "$0 / month"; + else { + const money = formatPrice(BASE_PRICES_AUD[tier] ?? 0, detectUserCurrency()); + planPrice.textContent = `${money} / month`; + } + } + if (planNote) { + if (loading) planNote.textContent = "Verifying your subscription…"; + else if (!resolved) planNote.textContent = "Could not verify your subscription. Check billing or sign out."; + else if (tier === "free") planNote.textContent = "No subscription. Upgrade for more generations."; + else planNote.textContent = "billed monthly through Stripe"; + } + if (renewal) { + const periodEndMs = Date.parse(subscriptionState.periodEnd || ""); + const hasRealPeriodEnd = resolved && tier !== "free" && !!subscriptionState.periodEnd && !Number.isNaN(periodEndMs); + if (loading) renewal.textContent = ""; + else if (!resolved) renewal.textContent = ""; + else if (tier === "free") renewal.textContent = ""; + else if (hasRealPeriodEnd) { + const date = new Date(periodEndMs).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" }); + renewal.textContent = `Renews ${date} · reset at the start of each month.`; + } else { + // No fabricated date: if the plan has no real period end we say so plainly. + renewal.textContent = "Renewal date is not available for this period."; + } + } + + // --- usage: generations left + meter --- + const leftCount = document.getElementById("acct-left-count"); + const leftLimit = document.getElementById("acct-left-limit"); + const resetNote = document.getElementById("acct-reset-note"); + if (leftCount) leftCount.textContent = loading ? "…" : !resolved ? "—" : String(remaining); + if (leftLimit) leftLimit.textContent = loading ? "…" : !resolved ? "—" : String(limit); + if (resetNote) { + if (loading) resetNote.textContent = "Checking plan…"; + else if (!resolved) resetNote.textContent = "Plan check failed. Refresh or manage billing."; + else resetNote.textContent = "Runs reset at the start of each month."; + } + + const usedCount = document.getElementById("acct-used-count"); + const meterFill = document.getElementById("acct-meter-fill"); + const meterMeta = document.getElementById("acct-meter-meta"); + const meterTrack = document.getElementById("acct-meter-track"); + if (usedCount) usedCount.textContent = loading ? "…" : !resolved ? "—" : String(count); + if (meterFill) { + const pct = resolved && limit > 0 ? Math.min(100, Math.round((count / limit) * 100)) : 0; + meterFill.style.width = `${pct}%`; + } + if (meterMeta) { + if (loading) meterMeta.textContent = "Checking…"; + else if (!resolved) meterMeta.textContent = "Plan check failed"; + else if (limit === 0) meterMeta.textContent = `${count} of ${limit}`; + else { + const pct = Math.round((count / limit) * 100); + meterMeta.textContent = `${count} of ${limit} · ${pct}%`; + } + } + if (meterTrack) { + if (loading) meterTrack.setAttribute("aria-label", "Checking usage…"); + else if (!resolved) meterTrack.setAttribute("aria-label", "Usage unavailable"); + else meterTrack.setAttribute("aria-label", `${count} of ${limit} generations used`); + } + + // --- FlutterFlow connection (real stored credential state) --- + const connKeyConfigured = hasStoredKey("flutterflow"); + const connProjectConfigured = hasStoredKey("flutterflow_project_id"); + const ffDot = document.getElementById("acct-ff-dot"); + const ffStatus = document.getElementById("acct-ff-status"); + const ffEndpoint = document.getElementById("acct-ff-endpoint"); + const ffProject = document.getElementById("acct-ff-project"); + if (ffStatus) { + if (connKeyConfigured && connProjectConfigured) { + ffStatus.textContent = "Connected to FlutterFlow"; + if (ffDot) { ffDot.className = "acct-dot ok"; } + } else if (connKeyConfigured) { + ffStatus.textContent = "API key set — pick a project"; + if (ffDot) { ffDot.className = "acct-dot warn"; } + } else { + ffStatus.textContent = "Not connected — add your FlutterFlow API key"; + if (ffDot) { ffDot.className = "acct-dot"; } + } + } + if (ffEndpoint) { + const endpoint = getFlutterFlowEndpoint(); + ffEndpoint.textContent = endpoint && endpoint.includes("staging") ? "Staging" : "Production"; + } + if (ffProject) ffProject.textContent = flutterflowProjectId || "—"; } function updateUsageDisplay() { diff --git a/e2e/stu383-account-overview.spec.js b/e2e/stu383-account-overview.spec.js new file mode 100644 index 0000000..8b201c2 --- /dev/null +++ b/e2e/stu383-account-overview.spec.js @@ -0,0 +1,324 @@ +import { test, expect } from "@playwright/test"; +import { + applyDefaultRoutes, + guestIdentity, + freeSubscription, + professionalSubscription, + powerSubscription, + unresolvedSubscription, + ENDPOINTS, +} from "./fixtures/apiFixtures.js"; + +/** + * STU-383 — the account overview with real identity, usage and honest history + * states. + * + * The account view is bound to the same metered/session/subscription state as + * the topbar (renderAccountOverview reads the same fields renderUsageSurfaces + * reads), so it reflects sign-in/out and a usage refresh without ever showing + * another user's cached identity. Every number traces to a real field or a + * documented derivation; a renewal/reset date is shown only from a real + * periodEnd — never fabricated. With no persistent history, the overview + * renders an honest, readable no-history state and keeps current results + * reachable through the generation workspace. + */ +const LONG_EMAIL = "alexandra.von.der-long-lane@subdomain.ap-southeast-2.example"; +const EMAIL = "metered@example.com"; +const currentMonth = () => new Date().toISOString().slice(0, 7); + +// initializeAuth refreshes the stored session against /auth/refresh-session and +// adopts whatever email that endpoint returns, so a signed-in scenario must +// point that endpoint back at the seeded identity or it is overwritten. +function sessionFor(email = EMAIL) { + return { + status: 200, + body: JSON.stringify({ email, sessionToken: "session-token" }), + contentType: "application/json", + }; +} + +function refreshOverride(email = EMAIL) { + return { [ENDPOINTS.authRefreshSession]: sessionFor(email) }; +} + +function identityWithUsage({ email, count, month = currentMonth(), status = "recognized" }) { + return { + status: 200, + body: JSON.stringify({ + status, + user_id: "acct-0001", + identity_token: "identity-token", + usage_count: count, + usage_month: month, + }), + contentType: "application/json", + }; +} + +function subscriptionWithPeriodEnd(periodEnd) { + const f = professionalSubscription(); + const body = JSON.parse(f.body); + body.data.periodEnd = periodEnd; + body.data.subscription.periodEnd = periodEnd; + return { ...f, body: JSON.stringify(body) }; +} + +async function seedSession(page, email = "metered@example.com") { + await page.addInitScript(({ email }) => { + localStorage.setItem( + "ccc_auth_session", + JSON.stringify({ email, sessionToken: "session-token" }), + ); + }, { email }); +} + +async function openAccount(page) { + await page.locator('a.nav-link[data-view="account"]').click(); + await expect(page.locator("#account-view")).toBeVisible(); +} + +test.describe("STU-383 account overview", () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem("hasSeenWalkthrough", "true"); + }); + }); + + test("identity traces to the signed-in session (long email) with no fabricated member date", async ({ page }) => { + await seedSession(page, LONG_EMAIL); + await applyDefaultRoutes(page, { + ...refreshOverride(LONG_EMAIL), + [ENDPOINTS.identity]: identityWithUsage({ email: LONG_EMAIL, count: 12 }), + [ENDPOINTS.getSubscription]: professionalSubscription(), + }); + await page.goto("/"); + await openAccount(page); + + // Local-part display name is a documented derivation of the real email. + await expect(page.locator("#acct-name")).toHaveText(LONG_EMAIL.split("@")[0]); + await expect(page.locator("#auth-user-email")).toHaveText(LONG_EMAIL); + await expect(page.locator("#acct-avatar")).toHaveText(LONG_EMAIL[0].toUpperCase()); + // No fabricated member-since date is shown. + await expect(page.locator("#acct-since")).toHaveText("Signed in by email magic link."); + await expect(page.locator("#acct-since")).not.toContainText("member since"); + }); + + test("no renewal date is invented when the subscription has none", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, { + ...refreshOverride(), + [ENDPOINTS.identity]: identityWithUsage({ email: undefined, count: 12 }), + [ENDPOINTS.getSubscription]: professionalSubscription(), + }); + await page.goto("/"); + await openAccount(page); + + // The professional fixture carries no periodEnd; the honest surface must + // say so rather than print a made-up date. + await expect(page.locator("#acct-renewal")).toHaveText( + "Renewal date is not available for this period.", + ); + const bodyText = await page.locator("#auth-signedin").innerText(); + expect(bodyText).not.toMatch(/Renews\s+\d{1,2}\s+\w+\s+\d{4}/); + }); + + test("a real periodEnd surfaces the renewal date", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, { + ...refreshOverride(), + [ENDPOINTS.identity]: identityWithUsage({ email: undefined, count: 12 }), + [ENDPOINTS.getSubscription]: () => subscriptionWithPeriodEnd("2026-11-01T00:00:00.000Z"), + }); + await page.goto("/"); + await openAccount(page); + + await expect(page.locator("#acct-renewal")).toContainText("Renews"); + await expect(page.locator("#acct-renewal")).toContainText("2026"); + }); + + test("usage meter and remaining runs trace to metered count and tier limit", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, { + ...refreshOverride(), + [ENDPOINTS.identity]: identityWithUsage({ email: undefined, count: 31 }), + [ENDPOINTS.getSubscription]: professionalSubscription(), + }); + await page.goto("/"); + await openAccount(page); + + await expect(page.locator("#acct-left-count")).toHaveText("19"); // 50 - 31 + await expect(page.locator("#acct-left-limit")).toHaveText("50"); + await expect(page.locator("#acct-used-count")).toHaveText("31"); + await expect(page.locator("#usage-counter")).toHaveText("31 / 50 runs this month"); + await expect(page.locator("#acct-meter-meta")).toHaveText("31 of 50 · 62%"); + await expect(page.locator("#acct-meter-track")).toHaveAttribute( + "aria-label", + "31 of 50 generations used", + ); + }); + + test("the no-history state is truthful and points to the generation workspace", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, { + ...refreshOverride(), + [ENDPOINTS.identity]: identityWithUsage({ email: undefined, count: 3 }), + [ENDPOINTS.getSubscription]: professionalSubscription(), + }); + await page.goto("/"); + await openAccount(page); + + await expect(page.locator("#acct-no-history")).toContainText("Saved history isn't available yet."); + await expect(page.locator("#acct-no-history")).toContainText("generation workspace"); + // No illustrative breakdown/history rows are fabricated. + const text = await page.locator("#acct-history").innerText(); + expect(text).not.toMatch(/Custom Widget 9|Committed/); + + // The empty state keeps current results reachable via the generation workspace. + await page.locator("#acct-no-history a").click(); + await expect(page.locator("#home-view")).toBeVisible(); + await expect(page.locator("#home-view")).toHaveClass(/is-active/); + }); + + test("account reflects a usage refresh without stale private information", async ({ page }) => { + await seedSession(page); + let count = 5; + await applyDefaultRoutes(page, { + ...refreshOverride(LONG_EMAIL), + [ENDPOINTS.identity]: () => identityWithUsage({ email: LONG_EMAIL, count }), + [ENDPOINTS.getSubscription]: professionalSubscription(), + }); + await page.goto("/"); + await openAccount(page); + await expect(page.locator("#acct-used-count")).toHaveText("5"); + + // Refresh the server-reconciled count; the account must move to the new + // number for the same signed-in user, never a stale cached one. + count = 9; + await page.reload(); + await openAccount(page); + await expect(page.locator("#acct-used-count")).toHaveText("9"); + await expect(page.locator("#auth-user-email")).toHaveText(LONG_EMAIL); + }); + + test("signing out clears the signed-in overview so no identity lingers", async ({ page }) => { + await seedSession(page, LONG_EMAIL); + await applyDefaultRoutes(page, { + ...refreshOverride(LONG_EMAIL), + [ENDPOINTS.identity]: identityWithUsage({ email: LONG_EMAIL, count: 1 }), + [ENDPOINTS.getSubscription]: freeSubscription(), + }); + await page.goto("/"); + await openAccount(page); + await expect(page.locator("#auth-user-email")).toHaveText(LONG_EMAIL); + + await page.locator("#auth-signedin button", { hasText: "Sign out" }).first().click(); + await expect(page.locator("#auth-signedin")).toBeHidden(); + await expect(page.locator("#auth-signedout")).toBeVisible(); + await expect(page.locator("#auth-signedout")).toContainText("Sign in to track usage"); + }); + + test("a guest sees the anonymous Free allowance, never a signed-in overview", async ({ page }) => { + await applyDefaultRoutes(page, { + [ENDPOINTS.identity]: guestIdentity(), + }); + await page.goto("/"); + await openAccount(page); + + await expect(page.locator("#auth-signedin")).toBeHidden(); + await expect(page.locator("#auth-signedout")).toBeVisible(); + await expect(page.locator("#guest-usage-text")).toHaveText("0 / 2 generations used"); + }); + + test("an unresolved subscription shows checking then unavailable — no fake balance", async ({ page }) => { + await seedSession(page); + let release; + const held = new Promise((r) => { release = r; }); + await applyDefaultRoutes(page, { + ...refreshOverride(), + [ENDPOINTS.identity]: identityWithUsage({ email: undefined, count: 12 }), + }); + await page.route("**/stripe/get-subscription", async (route) => { + await held; + await route.fulfill(unresolvedSubscription()); + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.locator('a.nav-link[data-view="account"]').click(); + await expect(page.locator("#acct-left-count")).toHaveText("…"); + await expect(page.locator("#acct-plan-tier")).toHaveText("Checking…"); + + release(); + await expect(page.locator("#acct-plan-tier")).toHaveText("Plan unavailable"); + await expect(page.locator("#acct-left-count")).toHaveText("—"); + await expect(page.locator("#acct-used-count")).toHaveText("—"); + await expect(page.locator("#acct-meter-meta")).toHaveText("Plan check failed"); + const balance = await page.locator("#usage-counter").textContent(); + expect(balance).not.toMatch(/\d+\s*\/\s*\d+\s*runs this month/); + }); + + test("connection card reflects real stored FlutterFlow credentials", async ({ page }) => { + await seedSession(page); + await page.addInitScript(() => {}); + await applyDefaultRoutes(page, { + ...refreshOverride(), + [ENDPOINTS.identity]: identityWithUsage({ email: undefined, count: 1 }), + [ENDPOINTS.getSubscription]: freeSubscription(), + }); + await page.goto("/"); + await openAccount(page); + + // No stored credentials on a fresh browser: the card reads as honest, real state. + await expect(page.locator("#acct-ff-status")).toHaveText( + "Not connected — add your FlutterFlow API key", + ); + }); + + test("screenshots for desktop and mobile reference comparisons", async ({ page }) => { + // Captures the surfaces the criteria name — long email, zero/max usage and + // an unresolved subscription — at 1440x900 and 360x800 for reference diffing. + const scenarios = [ + { + name: "professional-31-50-long-email", + email: LONG_EMAIL, + count: 31, + sub: () => professionalSubscription(), + }, + { name: "free-zero-usage", email: "zero@example.com", count: 0, sub: () => freeSubscription() }, + { name: "power-max-usage", email: "max@example.com", count: 50, sub: () => professionalSubscription() }, + ]; + const fs = await import("node:fs"); + const outDir = "screenshots/stu-383"; + fs.mkdirSync(outDir, { recursive: true }); + + for (const viewport of [ + { name: "desktop", width: 1440, height: 900 }, + { name: "mobile", width: 360, height: 800 }, + ]) { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + for (const s of scenarios) { + await seedSession(page, s.email); + await applyDefaultRoutes(page, { + ...refreshOverride(s.email), + [ENDPOINTS.identity]: () => identityWithUsage({ email: s.email, count: s.count }), + [ENDPOINTS.getSubscription]: s.sub, + }); + await page.goto("/"); + await openAccount(page); + await page.screenshot({ path: `${outDir}/account-${s.name}-${viewport.name}.png`, fullPage: true }); + } + // Unresolved subscription reference. + await seedSession(page, "unresolved@example.com"); + await applyDefaultRoutes(page, { + ...refreshOverride("unresolved@example.com"), + [ENDPOINTS.identity]: () => identityWithUsage({ email: undefined, count: 3 }), + }); + await page.route("**/stripe/get-subscription", (route) => + route.fulfill(unresolvedSubscription()), + ); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openAccount(page); + await expect(page.locator("#acct-plan-tier")).toHaveText("Plan unavailable"); + await page.screenshot({ path: `${outDir}/account-unresolved-${viewport.name}.png`, fullPage: true }); + } + }); +}); diff --git a/index.html b/index.html index 7bc4bef..f2b81a2 100644 --- a/index.html +++ b/index.html @@ -2867,7 +2867,185 @@ color: var(--accent); } + /* Account overview — real identity, plan, usage and honest history. + Every number below is written by renderAccountOverview() from the same + metered/session/subscription state the topbar reads; nothing here is + illustrative, and no renewal/member date is ever fabricated. */ + .acct-head { padding: 4px 0 22px; } + .acct-eyebrow { + font-family: var(--font-mono); + font-size: 11.5px; + font-weight: 500; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--accent); + margin-bottom: 12px; + } + .acct-title { + font-family: var(--font-display); + font-size: clamp(28px, 5vw, 40px); + line-height: 1.06; + letter-spacing: -0.03em; + font-weight: var(--fw-bold); + margin: 0; + } + .acct-lead { + margin: 14px 0 0; + font-size: 15px; + line-height: 1.6; + color: var(--muted); + max-width: 62ch; + } + + .acct-grid { + display: grid; + grid-template-columns: minmax(0, 1.4fr) repeat(2, minmax(0, 1fr)); + gap: 16px; + margin: 0 0 16px; + } + .acct-card { + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + padding: 20px; + } + .acct-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + .acct-card-head h2 { + font-family: var(--font-display); + font-size: 17px; + font-weight: var(--fw-bold); + letter-spacing: -0.02em; + margin: 0; + } + .acct-card-head .acct-meta { + font-family: var(--font-mono); + font-size: 11px; + color: var(--muted-2); + } + + .acct-who { display: flex; align-items: center; gap: 14px; } + .acct-avatar { + width: 52px; height: 52px; flex: none; + border-radius: 50%; + display: grid; place-items: center; + background: var(--fg); color: var(--surface); + font-size: 19px; font-weight: 600; + } + .acct-name { + font-family: var(--font-display); + font-size: 19px; font-weight: var(--fw-bold); + letter-spacing: -0.02em; margin: 0; + } + .acct-email { + font-family: var(--font-mono); + font-size: 12.5px; color: var(--muted); + margin: 3px 0 0; overflow-wrap: anywhere; + } + .acct-since { + font-size: 12.5px; color: var(--muted-2); margin: 7px 0 0; + } + + .acct-k { + font-family: var(--font-mono); + font-size: 10.5px; letter-spacing: 0.11em; text-transform: uppercase; + color: var(--muted-2); margin: 0; + } + .acct-v { + font-size: clamp(26px, 3vw, 32px); + font-weight: var(--fw-bold); + letter-spacing: -0.035em; + line-height: 1.05; + margin: 10px 0 0; + overflow-wrap: anywhere; + } + .acct-v .acct-unit { font-size: 15px; color: var(--muted); font-weight: 500; letter-spacing: 0; } + .acct-n { + font-size: 12.5px; color: var(--muted); margin: 8px 0 0; + } + + .acct-identity { margin-bottom: 16px; } + .acct-usage-meter { margin-bottom: 16px; } + .acct-meter { margin-top: 18px; } + .acct-meter-track { + height: 10px; border-radius: 999px; + background: var(--track); overflow: hidden; + } + .acct-meter-fill { + height: 100%; border-radius: 999px; + background: var(--accent); + width: 0%; + transition: width 700ms var(--ease, ease); + } + .acct-meter-meta { + display: flex; align-items: baseline; justify-content: space-between; + gap: 14px; flex-wrap: wrap; + margin-top: 12px; font-size: 13px; color: var(--muted); + } + .acct-meter-meta .acct-num { font-family: var(--font-mono); font-size: 13px; color: var(--fg); } + .acct-counter { font-family: var(--font-mono); font-size: 12px; } + + .acct-empty { + margin-top: 14px; + padding: 16px; + border: 1px dashed var(--border-strong); + border-radius: var(--radius-sm, 10px); + background: var(--surface-sunken); + font-size: 13.5px; + line-height: 1.6; + color: var(--muted); + } + .acct-empty p { margin: 0 0 6px; } + .acct-empty p:last-child { margin-bottom: 0; } + .acct-empty a { color: var(--accent); text-decoration: underline; } + + .acct-connection { margin-bottom: 16px; } + .acct-conn-row { + display: flex; align-items: center; gap: 10px; + margin-top: 14px; font-size: 14px; + } + .acct-dot { + width: 9px; height: 9px; border-radius: 50%; flex: none; + background: var(--muted-2); + } + .acct-dot.ok { background: var(--ok); } + .acct-dot.warn { background: var(--warn); } + .acct-conn-spacer { flex: 1; } + .acct-kv { margin: 12px 0 0; } + .acct-row { + display: flex; align-items: baseline; justify-content: space-between; + gap: 16px; padding: 8px 0; border-bottom: 1px solid var(--border); + font-size: 13.5px; + } + .acct-row:last-child { border-bottom: 0; } + .acct-row dt { color: var(--muted); } + .acct-row dd { margin: 0; font-family: var(--font-mono); font-size: 13px; text-align: right; overflow-wrap: anywhere; } + + .acct-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; } + .acct-actions .btn-action { flex: 1 1 auto; } + .btn-action.ghost { + background: transparent; color: var(--muted); + border: 1px solid var(--border); + } + .btn-action.sm { padding: 7px 12px; font-size: 12.5px; flex: none; } + + .acct-access-row { + display: flex; align-items: baseline; justify-content: space-between; + gap: 16px; padding: 11px 0; border-bottom: 1px solid var(--border); + font-size: 13.5px; + } + .acct-access-row:last-child { border-bottom: 0; } + .acct-access-row p { margin: 0; color: var(--muted); } + .acct-access-row p:first-child { color: var(--fg); font-weight: 500; flex: none; } + /* Mobile compact */ + @media (max-width: 920px) { + .acct-grid { grid-template-columns: 1fr; } + } @media (max-width: 920px) { .hero { padding: 24px 16px 28px; } .headline { font-size: clamp(34px, 10vw, 56px); line-height: 1.08; } @@ -3165,22 +3343,115 @@

Paste FlutterFlow Build Errors