Skip to content

feat(ui): migrate eight screens to subturtle-ui, plus the dark theme layer - #65

Open
SomiVista wants to merge 29 commits into
devfrom
new-design
Open

feat(ui): migrate eight screens to subturtle-ui, plus the dark theme layer#65
SomiVista wants to merge 29 commits into
devfrom
new-design

Conversation

@SomiVista

@SomiVista SomiVista commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Batch promotion of new-designdev. Two things landed here since this PR was opened: the theme layer it was created for, and eight screens rebuilt on subturtle-ui.

main stays frozen regardless (CLAUDE.md) — this lands on dev only, which is why a half-migrated UI is expected here.

Screens migrated

Screen Route
Today's board /board
Phrase bundles /bundles
Bundle detail /bundles/[id]
Start a session /sessions/new
Session history /sessions
Review settings /settings/preferences
Subscription /settings/subscription
Your profile /settings/profile

Each 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 under pages/, components/ and layouts/ still import pilotui (38 before Profile); those screens keep their own dark: behaviour until they migrate.

Theme layer

  • data-theme="light" | "dark" on <html>. Both Tailwind builds use darkMode: ['selector', '[data-theme="dark"]']; @nuxtjs/color-mode owns 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 pilotui ThemeSwitcher + ProfileButton pair.

The palette question raised in the original description is settled. That note said the design system's subturtle-theme.css supersedes ui/src/styles/theme-tokens.css wholesale. It doesn't, and it predated the theme PR landing: the handoff writes hex where Tailwind needs space-separated RGB channels (rgb(#211b28 / 1) would silently break every st- colour utility in dark), and only theme-tokens.css carries the .theme-switching rule and the measured contrast values. It is now the file that ships; theme-tokens.css and CLAUDE.md both say so. Treat the handoff as a reference for values and diff before carrying one across.

Library additions (ui/)

StInput, StTextarea, StModal, StSwitch, StSegmentedControl, StProgressBar — each landing with its first consumer, the last two ported from the design system's SegmentedControl.jsx / ProgressBar.jsx. StPagination is 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 call InlineNotice records.

Bugs found while migrating

All pre-existing in the code being replaced:

  • The bundle filter never filtered. The dataProvider controller was built once at setup, capturing filter.value as '' for the page's lifetime. Rebuilt per fetch, debounced 300ms, always back to page 1.
  • Stale rows on an empty result. The controller's onFetched hook doesn't fire for zero rows, so a non-matching filter showed the previous page under "Showing 4 of 0". Rows come from fetchPage's return value now.
  • A successful bundle create never navigated. analytic.track() throws with no Mixpanel token (CI, e2e, local dev); the .catch then destructured { error } off a TypeError and hit undefined.includes. Guarded, and the catch no longer assumes the rejection shape.
  • The Voice/Text badge could never render "Text" — it keyed off session._isText, a field nothing in the codebase assigns. It reads metadata.mode, which the practice pages do write.
  • Discard in Review settings restored hardcoded defaults, not the fetched settings. The unsaved-changes count also compared per-level arrays across a changed length, reporting "3 unsaved changes" for one edit.
  • A failed save in Review settings had no visible outcometoastError alone renders nothing (see below), so the design's error banner is raised too.
  • Deleting a phrase left the bundle's count wrong. removePhrase now drops the id from the bundle's own list; the old screen showed no count, so the asymmetry never surfaced.
  • Extension install links went nowhere. chromeWebStoreUrl fell back to a PLACEHOLDER detail URL, so the login, statistics and nudge-banner CTAs were dead unless NUXT_PUBLIC_CHROME_WEB_STORE_URL was set — it isn't in any env file here.
  • The Google profile picture didn't render. downloadAndCachePicture() needs crossOrigin only for its canvas toDataURL(), so its usual failure is CORS — which the catch treated as "URL is broken", marking it failed for 24h and stripping gPicture. A plain <img src> needs no CORS. The mark stays (it stops re-encoding every load); gPicture survives, and StAvatar falls back to initials on the <img> error event.

Correction — the toaster note in this PR blamed the wrong system. There are two. pilotui/toast works: it wraps SweetAlert2, skinned by the .swal2-* rules in pilotui/style.css, and has 8 callers. The one that renders nothing is frontend/utils/toaster.ts, which asks for a TairoToaster component the app never registers — 7 call sites, all silently dead. Because utils/ is auto-imported, it also publishes toastSuccess/toastError globally 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) — LeitnerSettings grew its inline error banner on that false premise while its own toastError was 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.

  • Identity banner on bg-st-sunkenStAvatar, 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 at all, so it can't be focused or toggled.
  • The status ladder the design specifies: Nothing to save yet → Unsaved changes (amber) → All changes saved (jade), with Save flipping softsolid and disabled until dirty.
  • TimezonePicker rebuilt on StModal + StInput, keeping its v-model contract; the scrim covers the sidebar, per the handoff note.
  • solar:alt-arrow-down-bold-duotone joins the ui/ icon allowlist (64 icons) for the timezone chevron — which is why two ui/ files ship with this screen.

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. Separately, 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, 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. updateProfile uses updateOne without 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-info returns an additional duePhraseIds array, so the session picker can show per-bundle due counts (review state lives in leitner_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

  • Both data-theme and the light/dark class are written. Deliberate — pilotui's compiled CSS and every un-migrated screen's dark: utility were built against .dark. plugins/theme.client.ts mirrors the preference into pilotui's store so the two never disagree, including on an OS-level flip while on system (which never moves preference).
  • --white no longer means white. It's the card neutral now, so everything meaning "ink on a rose CTA" moved to a new on-brand colour (a hard #fff).
  • The -600 ramp steps were flipped too. The handoff flips -700 for the "text on a soft tint" role; this codebase uses -600 that way as well. Left alone, #e30b4b on --color-primary-soft measured 2.8:1, under the 3:1 the handoff requires for icons. Values reused from the handoff, never invented.
  • bg-st-sunken for the subscription 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.
  • Subscription's usage strip serves every tier, so StarterUsageCard is 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".
  • Bundle detail's Live session hands off to /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.
  • Session-history search and the All/Voice/Text filter are client-side over the loaded page: the RPC paginates server-side and takes no query arguments.
  • Page-local components stay page-local. The bundle pick tile, coach voice tile, filter chip and timeline row are single-screen specialisations with no design-system counterpart.

Deliberately not shipped

  • Review settings' free-plan lock (dimmed levels, "Learner" pills, upsell card). featureCapFor(_, 'smart_review') returns null — "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.vue really does switch at leitnerLevel >= 3.
  • The bundles subtitle's phrase total. Only the loaded page carries phrase arrays, so the number would be wrong on every page but the last.
  • The topbar plan pill, removed on review feedback — the switcher sits where the design places it "between the plan pill and the avatar", with no pill present.
  • The Google button outline in dark: this screen's CTA is the rose primary button with a small white plate behind the mark, so there's no white-on-white surface to outline.

Known gaps

  • No ClickUp task id on any commit subject — none was available.
  • --red-600 on --color-danger-soft measures 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.
  • PoolSettings keeps its pilotui styling — it shares a route with Review settings but is its own PR. settings/billing is likewise still pending, so Settings is not finished by this batch.
  • settings/profile still imports pilotui/toast. Deliberate: the toaster replacement is one change across all 15 call sites in both systems, not a per-screen one.
  • layouts/default.vue and stores/profile.ts are left Prettier-unformatted; both already failed format:check before this branch touched them.

Verified

  • ui builds clean; 57/57 frontend unit tests pass (10 files). The subscription e2e spec was asserting against the old card layout and VoiceMeter sub-line; updated to the new structure, 8/8 pass.
  • Driven in a real browser against a live server on a standard freemium session: data-theme="dark" present at document-commit on hard reload with system + OS dark (no flash); system follows prefers-color-scheme live in both directions with no reload, data-theme and the pilotui body class in lockstep.
  • Switcher: cycle order, persistence, aria-label and tooltip in all three states ("System · dark").
  • Menu: panel is a direct child of <body> at position: fixed (the topbar's backdrop-filter no longer bleeds over it), tracks the trigger on resize and inner-<main> scroll, arrow-key roving with wrap, Escape restores focus, outside mousedown / route change close.
  • Geometry measured, not eyeballed: 40×40 in all three placements, login at 22/26, review-bar gap exactly 10px.
  • Screen states exercised per screen: populated, empty, no-match, locked, loading.

Two traps worth knowing about

  • vue-i18n blanks patterns. Passing 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-relative beat absolute. subturtle-ui/style.css loads after the app's Tailwind, so a fall-through absolute on the switcher lost the specificity tie and the button landed mid-page. It's positioned by a wrapper now — the st- prefix can't help when both rules set position on the same element.

🤖 Generated with Claude Code

SomiVista and others added 7 commits September 1, 2026 10:45
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>
@SomiVista
SomiVista requested a review from navidshad September 1, 2026 16:03
@navidshad navidshad added the review Claude Routine will take this and review the PR label Sep 1, 2026

Copy link
Copy Markdown
Contributor

Automated PR Review

Primary Task: No ClickUp task ID found — see "Known gaps" note in the PR body.
Related tasks from commits: None identified (no CU- or #taskId in any commit subject or body).


Task alignment

This is a batch promotion of the new-design branch covering three shipped milestones: the dark/light/system theme layer, StThemeSwitcher, and StProfileMenu. No ClickUp task was linked (acknowledged in the PR body), so alignment is evaluated against the PR's own stated scope:

  • Theme layer@nuxtjs/color-mode wired, data-theme stamping, subturtle:theme storage key, pre-paint flash guard, both Tailwind builds updated to ['selector', '[data-theme="dark"]'].
  • StThemeSwitcher — new ui/src/elements/StThemeSwitcher.vue, placed on the shared dashboard topbar, /auth/login, and PracticeToolScaffold (covers all practice tools).
  • StProfileMenu — new ui/src/shell/StProfileMenu.vue, replaces old PartialProfileButton.vue (deleted), adapter in frontend/components/partial/ProfileMenu.vue.
  • Audit fixes--whiteon-brand migration in StBadge, StButton, StIconButton, StBundleCard, StSidebarNav; skeleton pulse corrected; avatar ring and blob alpha corrected.
  • Store fixdownloadAndCachePicture CORS failure no longer strips gPicture; StAvatar falls back to initials on <img> error and resets when src changes.
  • CLAUDE.md updated — dark-mode status section updated to reflect current state.

Scope is exactly what the PR claims — no unexplained extras, nothing obviously missing for this milestone.


Commit messages

# Commit Assessment
1 fix(ui): lift the app shell topbar above the content column fix is correct — a real rendering bug (z-index). Clear, specific.
2 feat: wire the three-state theme switch and its no-flash guard ⚠️ Missing scope: feat(ui): would be consistent with the rest of the series. The message body is thorough but the subject alone doesn't say where this lands. Minor.
3 feat(ui): make dark real on the st- design system ✅ Correct type and scope. Detailed body.
4 feat(ui): add the topbar profile menu with the theme switch ✅ Correct.
5 fix(ui): act on the profile-menu review ✅ Correct — three concrete changes from review feedback.
6 refactor(ui): apply the theme by data-theme, and stop meaning white with --white ⚠️ This is a mechanism change that required auditing and fixing components — it is closer to feat(ui): than refactor. refactor means no user-facing change; moving from darkMode: 'class' to data-theme selector and introducing on-brand / overlay / card / page Tailwind aliases is a capability addition. This will suppress a semver bump it arguably earns. Not a blocker on dev, but worth noting before the branch reaches main.
7 feat(ui): add StThemeSwitcher and place it on all three surfaces ✅ Correct. Detailed and accurate.

Cross-cutting gap (acknowledged in PR): None of the seven commits carry a ClickUp task ID (CU-… or #taskId). Per CLAUDE.md § Commits & versioning: "when the work has a task id, append it as #<taskId> to the commit subject". The PR body flags this ("No ClickUp task id on any commit subject — none was available"). If a task ID exists or is assigned later, the PR title at least should carry it, as squash-merge uses the PR title as the commit message.


Prior review follow-up

No prior automated or manual reviews found. Nothing to check.


Convention check

✅ Architecture / module structure — No violations. New StThemeSwitcher lives in ui/src/elements/ and StProfileMenu in ui/src/shell/, consistent with the existing layout. Dashboard adapter lives in frontend/components/partial/ as expected. useAppTheme composable in frontend/composables/, plugins/theme.client.ts in frontend/plugins/. All correct.

✅ Pilotui boundaryStProfileMenu ships with no router, store, or i18n coupling, consistent with the "library ships to the extension too" constraint documented in CLAUDE.md. Consumer adapter (ProfileMenu.vue) holds all app-specific wiring.

✅ SSR offplugins/theme.client.ts is correctly .client.ts so it does not run server-side. useColorMode and useAppStore are only called there. No Nuxt server-only feature used.

⚠️ StProfileMenu.vue — stale prop in withDefaults (ui/src/shell/StProfileMenu.vue, line ~1719 in the diff):

{
    name: '',
    align: 'right',
    width: 284,
    theme: 'system',    // ← `theme` is not a declared prop
    ...
}

theme does not appear in the defineProps generic — StProfileMenu carries no theme prop (theme is owned by StThemeSwitcher / useAppTheme). This stale default is harmless at runtime (TypeScript should catch it if strict mode is enforced), but it is dead code and may generate a TS warning.

⚠️ theme-tokens.css selector mismatch — The file is loaded under html[data-theme='dark'] (correct for the new mechanism), but ui/src/styles/index.css imports it with the comment "MUST come after tokens.css — it re-points the same names under html.dark". The comment says html.dark; the actual selector in the file is html[data-theme='dark']. The behavior is correct; only the comment in index.css is wrong. Low-priority.

⚠️ disableTransition: false in nuxt.config.ts — the comment says "turns OFF the module's own cross-fade guard", but false is the default and also happens to be the value that enables transitions, meaning the guard is not active either way when set to false. What disables the module's built-in transition suppression is setting disableTransition: true. The current config (false) means the module does nothing about transitions — which is the intended outcome since plugins/theme.client.ts handles it. The value is correct for the intended effect, but the comment ("turns OFF the module's own cross-fade guard") could mislead: false here means "do not disable transitions" (the guard stays off), which is exactly what you want so the plugin doesn't interfere. Suggest rewording to: "disableTransition: false — the default; the module leaves transitions alone, so our html.theme-switching class in plugins/theme.client.ts is the only mechanism."

✅ Tailwind config — Both frontend/tailwind.config.cjs and ui/tailwind.config.cjs updated to ['selector', '[data-theme="dark"]']. Consistent.

✅ i18n strings — New keys (account.*, theme.*) added to frontend/locales/en.json. Sentence case per handoff. Existing preferences.nav and sign-out keys untouched (correct — they're still used by un-migrated screens).

✅ Prettier — The PR body acknowledges layouts/default.vue and stores/profile.ts were already failing format:check before this branch; the diff confirms this branch did not introduce new violations.

StAvatar fixbroken ref reset on src change is correct and handles the "later working URL suppressed by earlier failure" case properly.

✅ Teleport + capture-phase scroll — The StProfileMenu implementation correctly uses { capture: true } (true as the third argument to addEventListener) and removes with the matching true so the listener is actually removed. ✅

ℹ️ Known design-system gap (acknowledged)theme-tokens.css carries the earlier handoff palette, not subturtle-theme.css. The PR body explains this and documents the swap as a one-file change. Acceptable to merge to dev; must not reach main until the palette is swapped.


Verdict

NEUTRAL (with one item to confirm)

The implementation is thorough, well-reasoned, and self-consistent. The known palette gap is clearly documented and the dev-only target is appropriate. Three minor issues worth addressing before merge or as a follow-up:

  1. Stale theme default in StProfileMenu.withDefaults — remove it (it's not a declared prop).
  2. Comment in ui/src/styles/index.csshtml.darkhtml[data-theme='dark'].
  3. disableTransition comment in nuxt.config.ts — rephrase to avoid "turns OFF" confusion.

None of these are blockers for dev. The missing ClickUp task ID on commits is acknowledged and also not blocking.


Generated by Claude Code

@navidshad navidshad removed the review Claude Routine will take this and review the PR label Sep 1, 2026 — with Claude
SomiVista and others added 19 commits September 1, 2026 19:35
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>
SomiVista and others added 2 commits September 5, 2026 11:59
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@SomiVista SomiVista changed the title feat(ui): dark theme, profile menu and topbar theme switcher feat(ui): migrate seven screens to subturtle-ui, plus the dark theme layer Sep 5, 2026
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>
@SomiVista SomiVista changed the title feat(ui): migrate seven screens to subturtle-ui, plus the dark theme layer feat(ui): migrate eight screens to subturtle-ui, plus the dark theme layer Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants