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..ab36f1a --- /dev/null +++ b/e2e/stu383-account-overview.spec.js @@ -0,0 +1,404 @@ +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", + ); + }); + + /** + * Seeds real stored FlutterFlow credentials into the same encrypted-at-rest + * format the app writes (AES-256-GCM under a session-migrated JWK) so the + * connected branch of the account connection card is exercised through the + * app's genuine decryption + render path, not a mocked DOM. + */ + async function makeStoredCredentials() { + const key = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"], + ); + const jwk = await crypto.subtle.exportKey("jwk", key); + const encrypt = async (plaintext) => { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const data = new TextEncoder().encode(plaintext); + const buf = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, data); + const combined = new Uint8Array(12 + buf.byteLength); + combined.set(iv); + combined.set(new Uint8Array(buf), 12); + return Buffer.from(combined).toString("base64"); + }; + return { + jwk: JSON.stringify(jwk), + apiKey: await encrypt("ff-live-key-123"), + projectId: await encrypt("demo-proj-9f2a"), + }; + } + + test("connection card renders the connected state from stored FlutterFlow credentials", async ({ page }) => { + const creds = await makeStoredCredentials(); + await seedSession(page); + await page.addInitScript(({ jwk, apiKey, projectId }) => { + sessionStorage.setItem("ccc_encryption_key", jwk); + localStorage.setItem("ccc_api_key_flutterflow", apiKey); + localStorage.setItem("ccc_api_key_flutterflow_project_id", projectId); + }, creds); + await applyDefaultRoutes(page, { + ...refreshOverride(), + [ENDPOINTS.identity]: identityWithUsage({ email: undefined, count: 1 }), + [ENDPOINTS.getSubscription]: freeSubscription(), + }); + await page.goto("/"); + await openAccount(page); + + // Both a key and a project are stored: the card reads as fully connected. + await expect(page.locator("#acct-ff-status")).toHaveText("Connected to FlutterFlow"); + await expect(page.locator("#acct-ff-dot")).toHaveClass(/ok/); + await expect(page.locator("#acct-ff-project")).toHaveText("demo-proj-9f2a"); + await expect(page.locator("#acct-ff-endpoint")).toHaveText("Production"); + }); + + test("capture account screenshots for human visual review (layout asserted, pixels not compared)", async ({ page }) => { + // Human-review artifact: produces the account surfaces the criteria name — + // long email, zero/max usage and an unresolved subscription — at 1440x900 + // and 360x800 as committed PNGs for a person to eyeball. No pixel diff is + // run here, so the name says so. The one genuine render gate below fails on + // a broken layout: the surface must lay out inside the viewport with no + // horizontal overflow and a positively-sized account card. + const assertAccountLayout = async () => { + await expect(page.locator("#account-view")).toBeVisible(); + const layout = await page.evaluate(() => { + const card = document.querySelector("#account-view .account-card"); + const cardRect = card ? card.getBoundingClientRect() : null; + return { + docScrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + cardLeft: cardRect ? Math.round(cardRect.left) : null, + cardRight: cardRect ? Math.round(cardRect.right) : null, + cardHeight: cardRect ? Math.round(cardRect.height) : 0, + }; + }); + expect(layout.cardHeight, "account card must render non-empty").toBeGreaterThan(0); + expect( + layout.docScrollWidth, + "no horizontal overflow on the account view", + ).toBeLessThanOrEqual(layout.clientWidth); + expect(layout.cardRight, "account card must stay inside the viewport right edge") + .toBeLessThanOrEqual(layout.clientWidth); + }; + + 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 assertAccountLayout(); + 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 assertAccountLayout(); + await page.screenshot({ path: `${outDir}/account-unresolved-${viewport.name}.png`, fullPage: true }); + } + }); +}); diff --git a/index.html b/index.html index 7bc4bef..2a7d8a2 100644 --- a/index.html +++ b/index.html @@ -2867,7 +2867,190 @@ 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; } + /* The connection row's Configure button is non-shrinking (flex: none); + on narrow screens the long status text pushes it past the viewport, + so drop it onto its own full-width line instead. */ + .acct-conn-row { flex-wrap: wrap; } + .acct-conn-row .btn-action { flex: 1 1 100%; } + } @media (max-width: 920px) { .hero { padding: 24px 16px 28px; } .headline { font-size: clamp(34px, 10vw, 56px); line-height: 1.08; } @@ -3165,22 +3348,115 @@
Account
+Who you are signed in as, how much of your plan you have used this period, and what is connected to FlutterFlow.
+Sign in to track usage and manage your subscription.
—
+—
+Signed in by email magic link.
+Current plan
+Free $0 / month
+No subscription.
+Renewal date not available yet.
+ Free +Generations left this period
+— of —
+Runs reset at the start of each month.
+Saved history isn't available yet.
+Per-run records aren't persisted, so there is no past list to show. Your current result stays open in the generation workspace.
+Sign-in method
+Email magic link. No password is stored.
+API keys
+Provider and FlutterFlow keys are encrypted and kept in this browser.
+