diff --git a/CLAUDE.md b/CLAUDE.md
index ca5c7eaf..ea4d64d6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -181,8 +181,9 @@ promoted to `dev` in batches. While it is in progress:
writing the `.dark` class and `plugins/theme.client.ts` mirrors the preference into
pilotui's store, so the two never disagree, but they are not styled to match and will not
be until each screen migrates.
- ⚠️ `ui/src/styles/theme-tokens.css` still holds the EARLIER handoff's palette. The design
- system's own `subturtle-theme.css` supersedes it wholesale — see the banner in that file.
+ `ui/src/styles/theme-tokens.css` is the dark layer that ships. The design system carries
+ its own `subturtle-theme.css`, but that is a reference for values only — it writes hex
+ where Tailwind needs RGB channels — so diff the two rather than swapping the file in.
- `subturtle-ui` is a `link:../ui` dependency whose `dist/` is not committed, so the
frontend's `postinstall` builds it. Any context that installs the frontend needs `ui/`
present — the Dockerfile copies it in.
diff --git a/frontend/components/StarterUsageCard.vue b/frontend/components/StarterUsageCard.vue
deleted file mode 100644
index b80021a2..00000000
--- a/frontend/components/StarterUsageCard.vue
+++ /dev/null
@@ -1,75 +0,0 @@
-
-
-
-
+ Clear all subscription and freemium data for testing purposes. This action cannot be undone.
+
+
+ Reset Profile
-
+
+
+
+
+
+
+
diff --git a/frontend/tests/e2e/specs/subscription-ui.spec.ts b/frontend/tests/e2e/specs/subscription-ui.spec.ts
index 0250ab14..15be2507 100644
--- a/frontend/tests/e2e/specs/subscription-ui.spec.ts
+++ b/frontend/tests/e2e/specs/subscription-ui.spec.ts
@@ -254,26 +254,35 @@ test.describe('Subscription UI — free/Starter surfaces (§7)', () => {
await expect(page.getByText('Upgrade to unlock')).not.toBeVisible();
});
- test('/settings/subscription renders four plan cards with GBP base prices', async ({ page }) => {
+ // The redesign replaced the four standalone pricing cards with a tablist + a detail
+ // panel for the selected tier, so each tier is a tab and only one plan's full detail
+ // is on screen at a time. The page opens on the highlighted tier (Learner).
+ test('/settings/subscription renders all four tiers as tabs with GBP base prices', async ({ page }) => {
await page.goto('/#/settings/subscription');
- // All four tiers render as cards.
- await expect(page.getByRole('heading', { name: 'Starter', exact: true })).toBeVisible();
- await expect(page.getByRole('heading', { name: 'Reader', exact: true })).toBeVisible();
- await expect(page.getByRole('heading', { name: 'Learner', exact: true })).toBeVisible();
- await expect(page.getByRole('heading', { name: 'Coach', exact: true })).toBeVisible();
+ // All four tiers render as tabs.
+ await expect(page.getByRole('tab', { name: /^Starter/ })).toBeVisible();
+ await expect(page.getByRole('tab', { name: /^Reader/ })).toBeVisible();
+ await expect(page.getByRole('tab', { name: /^Learner/ })).toBeVisible();
+ await expect(page.getByRole('tab', { name: /^Coach/ })).toBeVisible();
- // Starter is the free user's current level (not a "Downgrade" CTA).
- await expect(page.getByText('Current plan')).toBeVisible();
+ // Starter is the free user's current level, marked on its tab.
+ await expect(page.getByRole('tab', { name: /^Starter/ })).toContainText('Current');
- // Learner is the highlighted tier: "Most popular" ribbon + 3-day trial CTA.
+ // Learner is the highlighted tier, so it is selected on arrival: "Most popular"
+ // ribbon (from Stripe metadata) + its 3-day trial CTA in the detail panel.
await expect(page.getByText('Most popular')).toBeVisible();
- await expect(page.getByText('Start 3-day free trial')).toBeVisible();
+ await expect(page.getByRole('tab', { name: /^Learner/ })).toHaveAttribute('aria-selected', 'true');
+ await expect(page.getByRole('button', { name: 'Start 3-day free trial' })).toBeVisible();
// GBP base prices (Adaptive Pricing probe disabled → £ fallback).
- await expect(page.getByText('£4.49')).toBeVisible();
- await expect(page.getByText('£10.99')).toBeVisible();
- await expect(page.getByText('£24.99')).toBeVisible();
+ await expect(page.getByText('£4.49 / month')).toBeVisible();
+ await expect(page.getByText('£10.99 / month')).toBeVisible();
+ await expect(page.getByText('£24.99 / month')).toBeVisible();
+
+ // Selecting the tier the user is on swaps the detail panel to the disabled marker.
+ await page.getByRole('tab', { name: /^Starter/ }).click();
+ await expect(page.getByRole('button', { name: 'Current plan' })).toBeDisabled();
});
test('paid Learner sees the voice meter in the "This month" section', async ({ page }) => {
@@ -290,9 +299,13 @@ test.describe('Subscription UI — free/Starter surfaces (§7)', () => {
await page.goto('/#/settings/subscription');
- await expect(page.getByRole('heading', { name: 'This month' })).toBeVisible();
- // The meter sub-line (base 90, used 30) — proves VoiceMeter computed the balance.
- await expect(page.getByText('60 of 90 minutes left this month', { exact: true })).toBeVisible();
+ // The redesign folds voice into the current-plan usage strip as one of four
+ // meters, so the balance reads as used / granted (30 of 90) rather than as the
+ // old VoiceMeter's "60 of 90 minutes left this month" sub-line.
+ await expect(page.getByText('Voice minutes', { exact: true })).toBeVisible();
+ await expect(page.getByText('30 / 90', { exact: true })).toBeVisible();
+ // Paid tiers keep a top-up entry point (it used to live inside VoiceMeter).
+ await expect(page.getByRole('button', { name: 'Top up minutes' })).toBeVisible();
});
test('paid user sees the voice top-ups section on /settings/billing', async ({ page }) => {
@@ -348,19 +361,24 @@ test.describe('Subscription UI — free/Starter surfaces (§7)', () => {
await page.goto('/#/settings/subscription');
- // The active-plan "This month" card lists each entitlement as a label + used/limit
- // row; Reader's finite text-chat cap renders as "23 / 60" (saved phrases stay Unlimited).
+ // The usage strip meters each entitlement as a label + used/limit; Reader's finite
+ // text-chat cap renders as "23 / 60", while an absent cap reads "Unlimited".
await expect(page.getByText('Text chats', { exact: true })).toBeVisible();
await expect(page.getByText('23 / 60', { exact: true })).toBeVisible();
+ await expect(page.getByText('Unlimited').first()).toBeVisible();
});
- test('Starter (free) user sees a usage card with the free-tier limits', async ({ page }) => {
- // Default stub is a free Starter user; the usage card renders for them.
+ test('Starter (free) user sees the usage strip with the free-tier limits', async ({ page }) => {
+ // Default stub is a free Starter user; one strip serves every tier now, so it
+ // names the tier from the plan list ("Starter") rather than a separate free card.
await page.goto('/#/settings/subscription');
- await expect(page.getByRole('heading', { name: 'Free plan' })).toBeVisible();
+ await expect(page.getByText('Free, forever. No card needed.').first()).toBeVisible();
await expect(page.getByText('Saved phrases', { exact: true })).toBeVisible();
await expect(page.getByText('Text chats', { exact: true })).toBeVisible();
await expect(page.getByText('Live sessions', { exact: true })).toBeVisible();
+ // A free tier never reads "Unlimited" — the caps come through as used / limit.
+ await expect(page.getByText('0 / 200', { exact: true })).toBeVisible();
+ await expect(page.getByText('0 / 3', { exact: true })).toBeVisible();
});
});
diff --git a/ui/src/styles/theme-tokens.css b/ui/src/styles/theme-tokens.css
index aec1f2a4..3c13a440 100644
--- a/ui/src/styles/theme-tokens.css
+++ b/ui/src/styles/theme-tokens.css
@@ -1,34 +1,29 @@
/* ============================================================
Subturtle — theme layer (dark).
- ⚠️ SUPERSEDED, AND STILL HERE ON PURPOSE.
-
- The design system now ships `subturtle-theme.css` (its own tokens/dark.css plus three
- patches), and THAT is the file this one has to be replaced by, wholesale. It was not
- available when this was written, so the palette below is the earlier profile-menu
- handoff's. The two genuinely disagree, so do not reconcile them by hand:
-
- - ramps here invert step-for-step; there they invert around their MID-step, with
- --rose-500 / --jade-500 keeping their exact brand values
- - --paper #161219 here, #15111a there; --surface-card #211b28 here, #1e1826 there
- - --white is NOT re-pointed here, but IS #1e1826 there ("the card neutral")
-
- That last one reverses the meaning of --white, so the literal-white audit already done
- in the components (`st-text-on-brand`, see tailwind.config.cjs) is correct for BOTH and
- should survive the swap. Everything else in this file is placeholder-accurate only.
+ THIS IS THE FILE THAT SHIPS. The design system also carries a dark layer
+ (`subturtle-theme.css`, in the screen handoff); it is a reference for VALUES, not a
+ replacement for this file, and an earlier note here claiming it supersedes this one
+ wholesale predated the theme PR landing. Do not swap the files. Two things only this
+ one has, and both are load-bearing:
+
+ - colours as space-separated RGB channels, so Tailwind can compose
+ `rgb(var(--token) / )`. The handoff writes hex; shipping that verbatim
+ would emit `rgb(#211b28 / 1)` and silently break every `st-` colour utility in dark.
+ - the `.theme-switching` rule and the `--blob-alpha` / `--surface-topbar` additions.
+
+ Where the two disagree on a value, this file's is the deliberate one and carries its
+ measured contrast in a comment beside it (e.g. --ink-500 is lightened to #9d93a9 for
+ 5.9:1 on --surface-card, against the handoff's #8d8496). Diff before changing anything
+ here, and carry a handoff value across only when it is actually better.
Loaded from index.css
immediately after tokens.css, which is the whole mechanism: this file re-points the
SAME token names under `html[data-theme='dark']`, so every component already styled with
var(--surface-card) / var(--text-body) / var(--ink-100) themes itself with no edits.
- ONE DELIBERATE DIFFERENCE FROM THE HANDOFF FILE — and it is not optional. The handoff
- writes colours as hex; tokens.css stores them as space-separated RGB channels so
- Tailwind can compose `rgb(var(--token) / )`. Shipping the hex verbatim
- would emit `rgb(#211b28 / 1)` and silently break every `st-` colour utility in dark.
- The VALUES below are the handoff's, converted channel-for-channel; the two tokens
- tokens.css documents as finished colours (--ring-focus) or as caller-alpha channels
- (--surface-overlay) keep their documented form.
+ The two tokens tokens.css documents as finished colours (--ring-focus) or as
+ caller-alpha channels (--surface-overlay) keep their documented form.
The handoff's translucent tints (rgba(249,30,90,.18) and friends) cannot survive that
conversion — a channel token has no room for its own alpha. They are pre-composited