feat(ui): migrate eight screens to subturtle-ui, plus the dark theme layer - #65
feat(ui): migrate eight screens to subturtle-ui, plus the dark theme layer#65SomiVista wants to merge 29 commits into
Conversation
Opening the topbar avatar menu rendered it behind the page: the "Install extension" button and the stat cards drew on top of the open dropdown. <main> is position: relative while <header> was static, and CSS paints positioned elements above static ones whatever the DOM order — so the content column and its whole subtree covered the header, and with it any popover opened from the #header-right slot. The header's backdrop-blur does not rescue this: the stacking context it creates still paints in the non-positioned layer. Adds st-relative st-z-sticky to the header. 100 sits above <main> (z-auto) and below the mobile drawer and its scrim (z-overlay, 200), which must keep covering the header below md. Fixed in the shell rather than in ProfileButton so the next popover in that slot — and the extension, which shares this library — gets it too. Also drops three template comments in this file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds @nuxtjs/color-mode with `classSuffix: ''`, so a bare `light`/`dark` class
lands on <html> — the hook subturtle-ui's token override will use, and the class
both Tailwind builds already resolve `darkMode: 'class'` against.
Three states, with `system` following `prefers-color-scheme` live: the module
registers its own matchMedia listener, so the composable stays a thin accessor
rather than double-registering one.
No flash on a hard reload, including `system`: the module inlines a blocking
pre-paint script into the SPA shell's <head> that reads the stored preference,
resolves `system` from the media query, and stamps the class before <body>.
Verified at document-commit, not just by eye.
plugins/theme.client.ts carries the two things the module does not do:
- The cross-fade guard. Nearly every `st-` surface transitions its colours, so
a palette swap would otherwise fade dozens of properties independently on
different frames. An `html.theme-switching` class kills transitions for the
one frame in which the swap lands; the rule ships in subturtle-ui's
stylesheet, un-namespaced so it reaches app markup too. The module's own
equivalent (`disableTransition`, an injected anonymous <style>) is turned off
so there is exactly one mechanism, and it is inspectable in devtools. The
watcher is `flush: 'sync'` because it has to have added the class before the
module's own watcher swaps the one on <html>.
- The pilotui mirror. Un-migrated screens keep their own `dark:` styling; this
only keeps pilotui's store in step so the two never disagree. It watches the
resolved value as well as the preference — pilotui resolves `system` against
the media query at the moment toggleTheme() is called, so an OS-level flip,
which never moves `preference`, has to re-poke it.
Also adds the seven profile-menu icons to the generated allowlist.
The dark palette itself (ui/src/styles/theme-tokens.css) is not in this commit —
it is transcribed verbatim from the design handoff, which is not yet available.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ships the design handoff's theme-tokens.css: the same token names re-pointed
under `html.dark`, loaded straight after tokens.css. Anything already styled
with var(--surface-card) / var(--text-body) / var(--ink-100) themes itself, so
no component grew a `dark:` variant.
ONE DELIBERATE DIFFERENCE from the handoff file, and it is not optional: the
handoff writes hex, tokens.css stores space-separated RGB channels so Tailwind
can compose `rgb(var(--token) / <alpha-value>)`. Shipping the hex verbatim would
emit `rgb(#211b28 / 1)` and silently break every st- colour utility in dark. The
values are the handoff's, converted channel-for-channel. Its translucent tints
cannot survive that conversion (a channel token has no room for its own alpha),
so they are pre-composited against --surface-card with the original rgba kept in
a comment beside each.
The two flips the handoff calls out are in, and are what the audit fixes below
lean on: the --ink-* ramp inverts (ink-50 darkest), and --rose-700 / --jade-700
become light tints because components use them as "text on a soft brand tint".
--white is NOT re-pointed; it means ink on a rose CTA, never a surface.
Audit list:
- Avatar's --white ring and online dot -> --surface-card (new `border-card`).
- Topbar backdrop -> --surface-topbar, a finished token, because dark needs a
different base AND a different alpha (translucent --surface-card) than the
old bg-page/80 could express.
- Ambient blobs -> --blob-alpha (5% light, 3% dark); the login screen's
stronger radials scale off the same token.
- Skeletons -> base --ink-100, highlight --ink-150, animating background-color
instead of a single fill's opacity, per the handoff's stated pair.
- Scrims and the bundle-cover pill -> `bg-overlay`, NOT `bg-ink-950`. That ramp
inverts, so a scrim written against it turns into a white veil in dark.
- Solid `neutral` on Button/IconButton/Badge -> `bg-inverse` + `text-page`, for
the same reason in the other direction: an ink-900 fill with white text would
become a near-white fill with white text.
Two findings worth review, both measured with WCAG contrast in the browser
rather than eyeballed:
- The handoff flips the -700 steps for the "text on soft tint" role, but this
codebase uses the -600 steps that way too (StIconButton soft, StBadge soft,
the Progress lock tile, LoginBoardPreview) and the audit did not enumerate
them. Left alone they kept light values: #e30b4b on --color-primary-soft
measured 2.8:1, under the 3:1 the handoff requires for icons. Flipped using
values the handoff already supplies for the adjacent step or the status
colour, never invented. See the comment in theme-tokens.css.
- --red-600 on --color-danger-soft measures 4.4:1, marginally under AA for the
11px badge label. FLAGGED, not substituted — picking a different red is a
design call. --text-faint (2.9:1) and white-on-rose (3.6:1) are likewise
below AA, but they are the handoff's own values and fail identically in the
light theme today, so they are pre-existing rather than regressions.
The Google plate on /auth/login is left as-is. The audit's item assumes a white
Google button; this screen's CTA is the rose primary button with a small white
plate behind the mark, which is already "ink on a rose CTA" — there is no
white-on-white surface to outline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the pilotui ThemeSwitcher + ProfileButton pair in StAppShell's
#header-right with a plan pill and one avatar-triggered account menu. Changed
once in the shared layout, so every migrated screen picks it up.
StProfileMenu lives in the library rather than in a page because the menu is
identical on every screen and the browser extension needs the same one. It has
no router, store or i18n coupling: `items` is a plain array carrying the caller's
own handlers (translating ProfileMenu.d.ts's contract), the theme is a controlled
prop plus `update:theme`, and every visible string arrives through `labels`.
Two implementation details that are requirements, not preferences:
- The panel is TELEPORTED to <body> and positioned `fixed` against the trigger
rect, re-placed on resize and on CAPTURE-PHASE scroll. The topbar sets
backdrop-filter, which bleeds its blur under an absolutely-positioned
descendant and washes the panel out; the design prototype hit exactly this
and fixes it the same way. Capture phase matters because the shell scrolls an
inner <main> — a bubbling window listener never sees it.
- Because of that teleport, keydown is bound to BOTH the wrapper and the panel.
Teleported DOM events do not bubble through the wrapper the way React's
synthetic events do in the prototype.
Appearance is a non-closing row: three icon-only segments in a role="radiogroup",
hand-rolled because SegmentedControl is not ported yet and the library may not
depend on pilotui. Choosing a theme leaves the menu open so the page repaints
behind it.
The Subscription row's meta comes from the live stores — voice minutes on a paid
plan, allowed_save_words on Free — and is omitted entirely while the
subscription is still fetching or a field is missing, rather than rendering a
placeholder that would read as a real number.
New account.* / appearance.* strings are sentence case per the handoff. The
existing `preferences.nav` and `sign-out` keys are Title Case and still used by
un-migrated pilotui screens, so they are left alone rather than restyled out from
under them; `profile.profile` and the sign-out confirmation keys are reused.
Verified in the browser against a real freemium session: panel is a direct child
of body at position fixed, 296px, z-index 200; rows render "9 / 200 saves" from
the store; arrow keys rove and wrap; Escape closes and restores focus to the
trigger; outside mousedown, route change and row activation close; the
Appearance row does not, and repaints the page behind it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three changes from the annotated screenshot. 1. Drop the topbar plan badge. StPlanPill goes with it — nothing else used it, and the git history has it if the extension's streak-pill variant ever wants it back. The plan pill inside the menu's identity header stays. 2. Show the Google profile picture. The avatar was falling back to initials because the store was deleting a perfectly good URL. downloadAndCachePicture() draws the avatar to a canvas and calls toDataURL(), which is the ONLY reason it sets crossOrigin — so its usual failure mode is a CORS one. The catch treated that as "this URL is broken", marked it failed for 24h and stripped gPicture from local state. But a plain <img src> needs no CORS at all; the browser renders that URL fine. So one CORS-blocked cache write hid the avatar for a day. The old ProfileButton masked this by falling back to a placeholder PNG, which is why it surfaced only now that the design's initials fallback replaced it. The failure mark stays (it stops us re-attempting the encode every load), but gPicture is no longer stripped, on that path or on the knownFailed path. A genuinely dead URL is now caught where it belongs: StAvatar falls back to initials on the <img> error event, and resets that when `src` changes so a later working URL is not suppressed by an earlier failure. 3. Move the theme switch out of the menu and into the topbar, beside the avatar, where the previous version had it. The three-segment control is extracted to StThemeSwitch so both placements render the same component: the dashboard uses the topbar one, and StProfileMenu's `themeSwitch` row — which the handoff specifies and the extension may still want — now renders it too rather than duplicating the markup. Verified: topbar badge gone, switch sits before the avatar and drives the theme, Appearance row gone from the menu, and the avatar renders a working URL, falls back to initials on a dead one, and recovers when a working one replaces it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ith --white Two mechanism changes the design system's own theme layer requires, landed ahead of that layer so the palette swap is a one-file change. 1. data-theme, not a class. @nuxtjs/color-mode gets `dataValue: 'theme'`, so it stamps data-theme="light|dark" on <html> — in the pre-paint script too, which is what keeps a hard reload flash-free, `system` included. Both Tailwind builds move to darkMode: ['selector', '[data-theme="dark"]']. The `light`/`dark` CLASS is deliberately kept alongside it. pilotui's compiled CSS and every un-migrated screen's `dark:` utility were built against `.dark`; dropping it would take those screens' dark mode with them. The two always agree. Storage key moves to `subturtle:theme`. 2. --white no longer means white. The incoming layer redeclares it as the CARD NEUTRAL (#1e1826), because that is how the design system uses it. So every place that meant "ink on a rose CTA" had to stop saying it: those now use a new `on-brand` colour, a hard #fff no theme can reach. Audited both directions — StBadge/StButton/StIconButton solids, StBundleCard's cover pill and scrim, StSidebarNav's active item. Surfaces keep using `bg-card`, which was already correct. The dark scope's --color-on-primary / --color-on-accent / --text-on-dark are now explicit literals rather than var(--white), since an alias declared at :root has already resolved by the time the dark scope runs. theme-tokens.css is retargeted to the new selector and carries a banner: it still holds the EARLIER profile-menu handoff's palette, and subturtle-theme.css supersedes it wholesale. The two genuinely disagree — ramps invert step-for-step here vs around their mid-step there, different --paper and --surface-card, and the --white reversal above — so the banner says not to reconcile them by hand. The literal-white audit is correct under both and survives the swap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One round icon button cycling Light -> Dark -> System, replacing the segmented
control from the earlier profile-menu handoff. That handoff's Appearance row is
superseded and removed: the design system ships a dedicated topbar control, so
StProfileMenu keeps its four rows and loses `themeSwitch` entirely.
40px `md` + `soft` everywhere, per the design's "no per-screen sizing": the
shared dashboard topbar (so every migrated screen inherits it), /auth/login
top-right at 22/26, and the practice session bar 10px left of End session. That
last one lives in PracticeToolScaffold, which every practice tool shares, so
Smart Review gets it along with the rest rather than only the one screen.
The glyph carries the state as much as the shape — sun in --amber-500, moon in
--rose-600, monitor in --text-muted — and is keyed on the mode so each press
remounts it and replays the 180deg spin. `system` resolves through a live
matchMedia listener, so the tooltip reads "System · dark" and follows the OS at
sunset without a reload.
Two things worth knowing, both found by testing rather than by reading:
- `labels.aria` and `labels.resolved` are FORMATTERS, not patterns. Passing
t('theme.aria') straight through looks right and silently is not: vue-i18n
interpolates {current}/{next} on the way out, so the aria-label arrived as
"Theme: . Switch to ." The consumer now passes (current, next) => t(...).
- The switcher is positioned by a WRAPPER element, never by handing layout
classes to the component. Its root carries `st-relative`, and
subturtle-ui/style.css loads after the app's Tailwind, so a fall-through
`absolute` loses the specificity tie and the button lands mid-page. The `st-`
prefix cannot help here — both rules set `position` on the same element.
In the dashboard the control runs CONTROLLED, with persist-key="" and
:apply="false", because @nuxtjs/color-mode already owns the attribute and the
storage key; standalone persist/apply stays for the DS specimens and the
extension. Strings move from appearance.* to theme.*.
Verified: cycle order and persistence, aria and tooltip in all three states,
data-theme and the pilotui body class in lockstep, live OS follow on `system`,
data-theme="dark" already present at document-commit on reload, and the measured
geometry on both hand-placed screens (40x40; login 22/26; review gap exactly 10).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Automated PR ReviewPrimary Task: No ClickUp task ID found — see "Known gaps" note in the PR body. Task alignmentThis is a batch promotion of the
Scope is exactly what the PR claims — no unexplained extras, nothing obviously missing for this milestone. Commit messages
Cross-cutting gap (acknowledged in PR): None of the seven commits carry a ClickUp task ID ( Prior review follow-upNo prior automated or manual reviews found. Nothing to check. Convention check✅ Architecture / module structure — No violations. New ✅ Pilotui boundary — ✅ SSR off —
{
name: '',
align: 'right',
width: 284,
theme: 'system', // ← `theme` is not a declared prop
...
}
✅ Tailwind config — Both ✅ i18n strings — New keys ( ✅ Prettier — The PR body acknowledges ✅ ✅ Teleport + capture-phase scroll — The ℹ️ Known design-system gap (acknowledged) — VerdictNEUTRAL (with one item to confirm) The implementation is thorough, well-reasoned, and self-consistent. The known palette gap is clearly documented and the
None of these are blockers for Generated by Claude Code |
Automated review on #65, the three findings that were real: - StProfileMenu's withDefaults still carried `theme: 'system'`. The `theme` prop went with the Appearance row the design system superseded, so this was a default for a prop that no longer exists. vue-tsc did not catch it — withDefaults does not reject keys absent from the props generic. - index.css said theme-tokens.css re-points names under `html.dark`. It is `html[data-theme='dark']` since the mechanism change. - The disableTransition note read as though `false` were switching something off. It is the module's own default; the value is right, the explanation was not. Reworded to say what each value does and why the explicit setting is worth keeping. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fourth screen in the pilotui -> subturtle-ui migration, after the app shell,
Progress and Login. Page markup moves to the `st-` Tailwind namespace; all
script logic (both stores, the parallel fetchBoard/fetchPool under `loading`,
consumeActivity -> /practice/review, the page meta) is unchanged.
The design's "Due today" row is three fixed cards; the board only ever returns
the activities the server actually raised, so the grid is one card per activity.
`leitner_review` gets the amber icon tile, the "Due" badge and the items_due
line; any other type falls back to a generic card. The double-spinner becomes
StSkeleton blocks and the caught-up state an StEmptyState inside an StCard.
Three design elements are deliberately not shipped, because no data stands
behind them:
- The level pips under Smart Review. Every BoardService.refreshActivity call
site writes meta as { dueCount, isActive } — there is no level distribution
on the activity to draw them from.
- "Resting". No board data behind it at all.
- Two of the three optional-practice tiles. Match game has no route; Text chat
needs a dispatcher-resolved ?session= and lands in errorMode without one.
Flashcards -> /practice/bundle-review is kept (it falls through to /bundles
when no review is staged, which is the "pick a bundle" behaviour that tile
wants).
The grids use auto-fill rather than the design's auto-fit: with real data a row
often holds a single card, and auto-fit stretches it the full width.
PoolCard stays pilotui and unrestyled — it gets its own PR. Its height is why
the Smart Review card stretches; that resolves when it migrates.
board.no_activities and the activities.* group lose their only consumer here and
are dropped. solar:card-2-bold-duotone replaces the handoff's
solar:cards-bold-duotone, which does not exist in @iconify/json.
Verified against a standard (freemium) user under both data-theme values: the
populated board, the caught-up empty state, and Start review -> /practice/review.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two fidelity defects against the design screen: - Cards stretched to PoolCard's height. PoolCard is still the pilotui card and is roughly twice a design card's height, so the grid's implicit stretch inflated the Smart Review card to ~570px against the design's ~280px, leaving a large void above the button. The grid is items-start until PoolCard migrates, at which point stretch is worth revisiting. - "Optional practice" rendered in the caught-up state. The design gates it and "Due today" on the same `listShow`, so it belongs inside the populated branch, not beside the empty-state card. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The board's encode-queue card was the last pilotui surface on the screen, and at roughly twice a design card's height it was what stopped the row reading as the design. It now uses the same board-card pattern as its Smart Review sibling: 54px tinted icon tile, text-md/800 title, text-sm/600 body, mt-auto footer. The handoff has no pool card of its own — the design's "Due today" row is Smart Review, Live session and Resting — so it inherits the shape that row already defines rather than inventing one. Jade keeps it distinct from Smart Review's amber and the Flashcards tile's sky. All logic is unchanged: the poolCount > 0 guard, chunkSize, estMinutes, the four-range cardCopy, start() and doNext(). With both cards the same height, the grid goes back to the design's stretch so the footers share a baseline. pool.module_label loses its only consumer (the design's cards carry no eyebrow) and is dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second screen in the pilotui -> subturtle-ui migration batch. Adds StInput,
StTextarea and StModal to the library — each lands with its first consumer.
The page keeps the dataProvider.list query shape, the page meta and
GenerativeCard (already on StBundleCard). The header, filter, grid, footer and
the New bundle modal move to the `st-` namespace.
Behaviour changes, both deliberate:
- The filter actually filters. The controller was built once at setup, which
captured `filter.value` as '' for the lifetime of the page, so typing in
"Filter bundles" never re-queried. It is now rebuilt per fetch, debounced
300ms, always back to page 1.
- The footer is the design's "Showing X of Y" + Load more, appending pages,
replacing the Pagination control.
Wiring the filter gives the empty state two meanings, and the design only covers
one. A non-matching filter now gets its own variant ("No bundles match …" with
Clear filter); the install/New-bundle empty state is kept for a genuinely empty
library.
Three bugs found while verifying, all pre-existing in the code being replaced:
- A successful create never navigated. analytic.track() throws when Mixpanel has
no token (CI, e2e, local dev), which fell into the .catch, where destructuring
`{ error }` off a TypeError gave `undefined.includes`. The track call is now
guarded the way the subscription page's pricing-page_viewed is, and the catch
no longer assumes the rejection shape.
- The list kept stale rows when a query returned nothing: the controller's
`onFetched` hook does not fire for an empty result, so a non-matching filter
showed the previous page under "Showing 4 of 0". Rows now come from
fetchPage's return value.
- pilotui's toaster renders nothing app-wide — `toastError` asks it for a
`TairoToaster` component this app never registers (10+ call sites affected).
Out of scope to fix here, but it means the duplicate-title toast is invisible,
so that case is also surfaced inline on the Name field.
The subtitle counts the whole library, not the filtered result, and is
pluralised. The design's phrase total is left out: only the loaded page carries
phrase arrays, so the number would be wrong on every page but the last.
The old `[role='dialog']` z-index workaround is NOT carried over: it existed
because pilotui's modal outranked the toast, and StModal's --z-modal (300) is
already below pilotui's toast (1060).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third screen in the pilotui -> subturtle-ui migration batch. Adds StSwitch to
the library and reuses StInput / StModal from the Bundles screen.
The page becomes the design's single view: three stat tiles, then a left column
("The journey" + "Start over") and a right column ("The chain" — a row per level
with the number chip, tag, count bar, interval and cards-per-session inputs, and
Manage). The header carries the status dot, Discard and Save preferences.
All the data logic is unchanged: the get-stats fetch with its onSaved/onReset
refetch, localSettings, adjustArrays, getItemCount, performSave/performReset and
their emits, isDirty/settingsDirty, and the picker's loadPickerData,
fetchPhrases, addPhrase/removePhrase and emits.
- LeitnerPhrasePicker stops being a modal and becomes the inline panel under the
level row, one open at a time, with activeBox following the open row. Only its
shell changed.
- Discard restores the FETCHED settings, not the component's hardcoded defaults.
- The status count compares against the fetched settings, and per-level arrays
only over their overlapping range: adjustArrays resizes both when the level
count changes, which otherwise reported "3 unsaved changes" for one edit.
- The timezone line reads the profile's timeZone, falling back to the browser's
rather than a hardcoded UTC.
The free-plan lock is NOT shipped — no dimmed levels, no "Learner" pills, no
dark upsell card. featureCapFor(_, "smart_review") returns null with the comment
"unlimited on every tier (Council 004)" (server/src/modules/subscription/
tiers.ts:125), and both test suites assert it, so there is no tier that caps
Smart Review levels and the design's lock would be invented.
The Cloze tag at level 3 IS shipped: FlashCard.vue switches to the fill-in-the-
blank card at `leitnerLevel >= 3`, so it describes real behaviour.
The Smart Review / Pool tab row stays as one route; PoolSettings keeps its
pilotui styling for its own PR. A save failure now also raises the design's
error banner — pilotui's toaster renders nothing in this app, so toastError
alone left a failed save with no visible outcome.
The hour field is a 0-23 number with ":00" beside it rather than the design's
free-text time, because the stored value is an integer hour.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings Today's board and the migrated PoolCard onto new-design alongside Phrase bundles and Review settings, so all three migrated screens live on the one branch. Both conflicts were the shared icon allowlist, where each side had appended its own names. Resolved as the union — solar:check-circle-bold is the reset modal's bullet and solar:check-circle-bold-duotone is the board's caught-up state, so both are needed. icons.generated.ts was regenerated rather than hand-merged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the four standalone pricing cards with the design's tablist + detail panel: a current-plan usage strip, one tab per tier, and the selected tier's price, CTA and features in a single panel. All `st-` tokens, no raw hex and no `dark:` variants, so the screen reads correctly under both themes. No new components or icons were needed — StSwitch, StBadge, StButton, StCard, StSkeleton, StEmptyState, StModal and solar:check-circle-bold all already ship. Notable decisions, and where they depart from the prototype: - The usage strip serves every tier, so StarterUsageCard (used only here) is deleted and the paid "This month" block folds into the same four meters. - `bg-st-sunken` for the detail panel's left rail, not the design's `--ink-50`: the ink ramp inverts in dark, where ink-50 lands within one channel step of --surface-card and the rail disappears. - Meter fill stays rose at rest as drawn, but keeps the amber/red cap warning Council 004 specced — the prototype's all-zero demo never shows it. - An unlimited allowance renders an empty accent track; a full bar would read as "at your cap". A free tier never reads Unlimited (FREE_CAPS fallback), and a Reader with no voice budget keeps the "Upgrade to Learner" upsell instead of a dead 0 / 0. - The ribbon uses Stripe's `badge` metadata, falling back to the i18n string. - Manage/change/downgrade CTAs are preserved; goToPortal no longer branches on trialing (the off-ramp's own "continue" calls it, which would have recursed), manageSubscription owns that. - The strip waits on isSubscriptionFetching so its defaults never render as a real answer, and dead `isLoading` state is dropped. theme-tokens.css and CLAUDE.md carried a note saying the design system's subturtle-theme.css supersedes ui/'s dark layer wholesale. Per the screen handoff that note predates the theme PR; ui/'s is the one that ships (RGB channels for Tailwind, the .theme-switching rule, measured contrast). Corrected in both places — comments only, no palette change. The e2e spec asserted against the old card layout and VoiceMeter sub-line; its assertions are updated to the new structure. 8/8 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebuilds /sessions/new — the page, the Gemini StartNew entry point, the shared StartLiveSessionForm and VoicePicker — in the st- design language. All script logic is carried over verbatim: the dataProvider bundle controller, GEMINI_VOICES, the formData shape, isFormValid over the form's exposed selectionError/randomError, the freemium and voice-cap gates, and pickPhraseIds + encodeSessionRequest. Layout follows the design: a two-column grid with the bundle picker and coach card on the left and a sticky summary panel on the right, carrying the selection, the voice-minute or freemium meter, and the single Start button. Adds StSegmentedControl and StProgressBar to subturtle-ui, ported from the design system's SegmentedControl.jsx and ProgressBar.jsx. Both are real design-system primitives and both are used again by Session history. Three design elements are deliberately not shipped, for want of data: - The "Due today" / "Never practised" bundle chips. Leitner due-counts live per phrase in a separate database and a bundle carries no last-practised marker, so only Recent / All are wired. - "Repeat your last session". Its only source is the list-live-sessions RPC, which is Learner+ gated and throws for the free tier — not cheap, and broken for the tier most likely to be on this page. - The voice "Preview" pill. CoachVoice reserves avatarUrl as null and carries no audio; there is no preview endpoint. The bundle pick tile, coach voice tile and filter chip stay page-local: they are single-screen specialisations with no design-system counterpart, following the call already made for InlineNotice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebuilds /sessions/new to the redesign: a two-column layout with the bundle picker and coach card on the left and a sticky session summary on the right. Session logic is unchanged — the same dataProvider bundle controller, form shape and defaults, selectionError/randomError validation, freemium gate and pickPhraseIds + encodeSessionRequest hand-off to /practice/live-session. Library: - StSegmentedControl and StProgressBar, ported from the design system's SegmentedControl.jsx / ProgressBar.jsx, plus seven Solar icons. Screen: - Bundle picker with search, Recent / Due today / Never practised / All chips, and a per-bundle "N phrases · N due" line on a colour-coded tile. - Coach cards carry the server voice list's descriptions and a Preview pill that plays a sample through the existing textToSpeechBase64 function. - Summary panel: bundle, mode, voice, fallback, phrase range, the voice-minute meter and the primary Start session button. - "Repeat your last session" replays the last setup from localStorage rather than from list-live-sessions, which is Learner+ and throws for the free tier. The picker's due counts need review state the client cannot read (leitner_system is owner-access), so get-phrase-management-info returns an additional duePhraseIds array. The field is additive; its only existing caller ignores it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rebuilds /sessions as the design's timeline: a date and duration column, a
rail with a per-mode node, and a card carrying the type icon, bundle title,
Voice/Text badge, dialog count and chevron.
List logic is unchanged — the same list-live-sessions RPC and pagination,
useInlineFeatureLock('session_history'), the try/catch that flips to locked,
isPracticeSession and goToBundles.
- Search and an All / Voice / Text StSegmentedControl narrow the current page.
The RPC paginates server-side and takes no query arguments, so both filters
are deliberately client-side over the loaded rows.
- StPagination replaces pilotui's Pagination. It is app-local, not in ui/: the
design system defines no pagination component, so this is a composition of
its tokens rather than a library primitive — the same call InlineNotice
records.
- States: populated, empty, no-match, locked and loading. Locked replaces
FeatureLocked on this page only, showing three withheld rows and the upgrade
path instead of the list.
Two fixes found along the way:
The Voice/Text badge keyed off `session._isText`, a field nothing in the
codebase ever assigned, so the Text badge could never render. It now reads
`metadata.mode`, which the practice pages do write.
The timeline's date and duration are formatted for the column rather than the
visitor's locale: en-US would render "Aug 12, 02:20 PM" and break the fixed
width. formatSessionDuration keeps its precise "8m 0s" form for the session
detail page; a list scanned at a glance reads "8 min".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bundle screen in the redesigned language: rose eyebrow + display title with the phrase count, language pair and description as the lead, Live session and Flashcards as outline pills, the saves allowance as a card with a meter and the Add phrase button, then the phrase list. - The topbar breadcrumb can now be set by a page (`usePageCrumb`), so a detail route names the record — "Library › Money Heist — s1e4 · 42 phrases" — instead of repeating the nav's "Practice › Phrase bundles". - PhraseCard is rebuilt on StCard/StTextarea, with its position, capture source (from `sourceUrl`) and quiet row actions; editing, audio and delete behave as before, with the confirmation now an StModal. - Live session hands the bundle to /sessions/new?bundle=<id>, which preselects it — one session setup form instead of a second copy in a pilotui modal. DetailCard's rename/delete moved into a Bundle settings modal behind the header's overflow button; both pilotui components are gone. - removePhrase now drops the id from the bundle's own list too, so the phrase count and numbering stay right after a delete — the old screen showed no count, so the asymmetry never surfaced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
chromeWebStoreUrl fell back to a PLACEHOLDER detail URL, so the install CTAs on the login page, the statistics page and the nudge banner all led nowhere unless NUXT_PUBLIC_CHROME_WEB_STORE_URL was set (it isn't in any env file here). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…bturtle-ui feat(ui): rebuild Subscription on subturtle-ui
…ubturtle-ui feat(ui): rebuild Start a session on subturtle-ui
…-subturtle-ui feat(ui): rebuild Session history on subturtle-ui
…bturtle-ui Merge pull request #68 from codebridger/claude/start-session-screen-s…
Merge pull request #67 from codebridger/claude/subscription-screen-su…
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follows `Subturtle Profile.dc.html` (direction 1a — the single centered card). The screen was previously listed as having no design; it has one. - Identity banner on `bg-st-sunken`: StAvatar, the Google-owned email, and a live clock for the selected zone. StAvatar's own src -> initials fallback replaces the page's `onAvatarLoadError`. - "Personal information" as a two-up grid: editable name, disabled email with "From Google. Not editable.", the timezone field, and an inert "Coming soon" reminders row (rendered without an input so it cannot be focused). - Status ladder the design specifies — "Nothing to save yet" -> "Unsaved changes" (amber) -> "All changes saved" (jade) — with Save flipping soft <-> solid and disabled until dirty. - TimezonePicker rebuilt on StModal + StInput, keeping its `v-model` contract. Its scrim covers the sidebar, per the design note. Two behaviour changes, both because the design depends on them: `initialTimeZone` was never seeded, so the form was dirty from load whenever a timezone existed — disabled-until-dirty is impossible in that state. Baselines now seed after `getProfileInfo()` resolves, and after a save they key off the values just submitted rather than the store's mirror, which only updates when `userDetail` already holds a profile document. The avatar upload UI is gone. Its file input was already `:disabled="true"`, so `selectedFile` could never be set and the whole upload branch was unreachable; the design makes the photo read-only from Google. `solar:alt-arrow-down-bold-duotone` joins the `ui/` icon allowlist for the timezone field's chevron (64 icons), so the two `ui/` files ship with the screen that needs them. Not fixed here, and worth a separate look: profile saves are a silent no-op for any account with no profile document. `updateProfile` uses `updateOne` without upsert, and profile docs are only ever created by the Google OAuth path (server/src/modules/auth/router.ts:145), so a user who never took it gets a success toast and no write. Real Google users are unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Batch promotion of
new-design→dev. Two things landed here since this PR was opened: the theme layer it was created for, and eight screens rebuilt onsubturtle-ui.mainstays frozen regardless (CLAUDE.md) — this lands ondevonly, which is why a half-migrated UI is expected here.Screens migrated
/board/bundles/bundles/[id]/sessions/new/sessions/settings/preferences/settings/subscription/settings/profileEach moves its markup to the
st-namespace and carries its script logic over verbatim — same stores, same RPCs, same validation and freemium gates — unless a fix is called out below. 37 files underpages/,components/andlayouts/still import pilotui (38 before Profile); those screens keep their owndark:behaviour until they migrate.Theme layer
data-theme="light" | "dark"on<html>. Both Tailwind builds usedarkMode: ['selector', '[data-theme="dark"]'];@nuxtjs/color-modeowns the preference (subturtle:theme) and injects the pre-paint script.StThemeSwitcher— one round 40px button cycling Light → Dark → System, in the dashboard topbar, on/auth/login, and in the practice session bar.StProfileMenu— identity header over four rows, teleported to<body>. Replaces the pilotuiThemeSwitcher+ProfileButtonpair.Library additions (
ui/)StInput,StTextarea,StModal,StSwitch,StSegmentedControl,StProgressBar— each landing with its first consumer, the last two ported from the design system'sSegmentedControl.jsx/ProgressBar.jsx.StPaginationis deliberately app-local (components/common/): the design system defines no pagination component, so it composes tokens rather than claiming to be a primitive — the same callInlineNoticerecords.Bugs found while migrating
All pre-existing in the code being replaced:
filter.valueas''for the page's lifetime. Rebuilt per fetch, debounced 300ms, always back to page 1.onFetchedhook doesn't fire for zero rows, so a non-matching filter showed the previous page under "Showing 4 of 0". Rows come fromfetchPage's return value now.analytic.track()throws with no Mixpanel token (CI, e2e, local dev); the.catchthen destructured{ error }off aTypeErrorand hitundefined.includes. Guarded, and the catch no longer assumes the rejection shape.session._isText, a field nothing in the codebase assigns. It readsmetadata.mode, which the practice pages do write.toastErroralone renders nothing (see below), so the design's error banner is raised too.removePhrasenow drops the id from the bundle's own list; the old screen showed no count, so the asymmetry never surfaced.chromeWebStoreUrlfell back to a PLACEHOLDER detail URL, so the login, statistics and nudge-banner CTAs were dead unlessNUXT_PUBLIC_CHROME_WEB_STORE_URLwas set — it isn't in any env file here.downloadAndCachePicture()needscrossOriginonly for its canvastoDataURL(), so its usual failure is CORS — which the catch treated as "URL is broken", marking it failed for 24h and strippinggPicture. A plain<img src>needs no CORS. The mark stays (it stops re-encoding every load);gPicturesurvives, andStAvatarfalls back to initials on the<img>error event.Correction — the toaster note in this PR blamed the wrong system. There are two.
pilotui/toastworks: it wraps SweetAlert2, skinned by the.swal2-*rules inpilotui/style.css, and has 8 callers. The one that renders nothing isfrontend/utils/toaster.ts, which asks for aTairoToastercomponent the app never registers — 7 call sites, all silently dead. Becauseutils/is auto-imported, it also publishestoastSuccess/toastErrorglobally under an object signature that collides by name with pilotui's string signature: an explicit import wins, everything else gets the broken one. Two in-repo comments repeat the original mistake (Leitner/LeitnerSettings.vue:3-4,bundle/AddNew.vue:60-61) —LeitnerSettingsgrew its inline error banner on that false premise while its owntoastErrorwas firing fine. Still out of scope here; it wants one replacement across all 15 sites.Profile
Follows
Subturtle Profile.dc.html(direction 1a — the single centered card). Worth noting because the screen had been recorded as having no dedicated design: it has one, with handoff notes.bg-st-sunken—StAvatar, the Google-owned email, and a live clock for the selected zone.StAvatar's own src → initials fallback replaces the page'sonAvatarLoadError.soft↔solidand disabled until dirty.TimezonePickerrebuilt onStModal+StInput, keeping itsv-modelcontract; the scrim covers the sidebar, per the handoff note.solar:alt-arrow-down-bold-duotonejoins theui/icon allowlist (64 icons) for the timezone chevron — which is why twoui/files ship with this screen.Two behaviour changes, both because the design depends on them.
initialTimeZonewas never seeded, so the form was dirty from load whenever a timezone existed — disabled-until-dirty is impossible in that state. Baselines now seed aftergetProfileInfo()resolves, and after a save they key off the values just submitted rather than the store's mirror, which only updates whenuserDetailalready holds a profile document. Separately, the avatar upload UI is gone: its file input was already:disabled="true", soselectedFilecould never be set and the whole upload branch was unreachable, and the design makes the photo read-only from Google.Found, not fixed: profile saves are a silent no-op for any account with no profile document.
updateProfileusesupdateOnewithout upsert, and profile docs are only ever created by the Google OAuth path (server/src/modules/auth/router.ts:145). A user who never took that path gets a success toast and no write. Real Google users are unaffected, but the failure mode is invisible — it's a backend fix, not a UI one.Server change
get-phrase-management-inforeturns an additionalduePhraseIdsarray, so the session picker can show per-bundle due counts (review state lives inleitner_system, which is owner-access and unreadable from the client). The field is additive; its only existing caller ignores it.Decisions a reviewer should check
data-themeand thelight/darkclass are written. Deliberate — pilotui's compiled CSS and every un-migrated screen'sdark:utility were built against.dark.plugins/theme.client.tsmirrors the preference into pilotui's store so the two never disagree, including on an OS-level flip while onsystem(which never movespreference).--whiteno longer means white. It's the card neutral now, so everything meaning "ink on a rose CTA" moved to a newon-brandcolour (a hard#fff).-600ramp steps were flipped too. The handoff flips-700for the "text on a soft tint" role; this codebase uses-600that way as well. Left alone,#e30b4bon--color-primary-softmeasured 2.8:1, under the 3:1 the handoff requires for icons. Values reused from the handoff, never invented.bg-st-sunkenfor the subscription rail, not the design's--ink-50: the ink ramp inverts in dark, whereink-50lands within one channel step of--surface-cardand the rail disappears.StarterUsageCardis deleted and the paid "This month" block folds into the same four meters. An unlimited allowance renders an empty accent track — a full bar would read as "at your cap"./sessions/new?bundle=<id>rather than opening a second copy of the setup form in a modal;DetailCard's rename/delete moved into a Bundle settings modal. Both pilotui components are gone.Deliberately not shipped
featureCapFor(_, 'smart_review')returnsnull— "unlimited on every tier (Council 004)",server/src/modules/subscription/tiers.ts:125— and both test suites assert it. No tier caps Smart Review levels, so the design's lock would be inventing one. The Cloze tag at level 3 is shipped:FlashCard.vuereally does switch atleitnerLevel >= 3.Known gaps
--red-600on--color-danger-softmeasures 4.4:1, marginally under AA for the 11px badge label. Flagged rather than substituted; picking a different red is a design call.--text-faint(2.9:1) and white-on-rose (3.6:1) are also sub-AA, but those are the handoff's own values and fail identically in light today — pre-existing, not regressions.PoolSettingskeeps its pilotui styling — it shares a route with Review settings but is its own PR.settings/billingis likewise still pending, so Settings is not finished by this batch.settings/profilestill importspilotui/toast. Deliberate: the toaster replacement is one change across all 15 call sites in both systems, not a per-screen one.layouts/default.vueandstores/profile.tsare left Prettier-unformatted; both already failedformat:checkbefore this branch touched them.Verified
uibuilds clean; 57/57 frontend unit tests pass (10 files). The subscription e2e spec was asserting against the old card layout andVoiceMetersub-line; updated to the new structure, 8/8 pass.data-theme="dark"present at document-commit on hard reload withsystem+ OS dark (no flash);systemfollowsprefers-color-schemelive in both directions with no reload,data-themeand the pilotui body class in lockstep.aria-labeland tooltip in all three states ("System · dark").<body>atposition: fixed(the topbar'sbackdrop-filterno longer bleeds over it), tracks the trigger on resize and inner-<main>scroll, arrow-key roving with wrap, Escape restores focus, outsidemousedown/ route change close.Two traps worth knowing about
t('theme.aria')through as a pattern looks right and silently isn't — it interpolates{current}/{next}on the way out, so the aria-label rendered"Theme: . Switch to .". The label API takes formatters now.st-relativebeatabsolute.subturtle-ui/style.cssloads after the app's Tailwind, so a fall-throughabsoluteon the switcher lost the specificity tie and the button landed mid-page. It's positioned by a wrapper now — thest-prefix can't help when both rules setpositionon the same element.🤖 Generated with Claude Code