Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,32 @@ CU-<taskId>_<Short-Task-Title-Dashed>_<Author-Name>
e.g. `CU-86ext1gpf_Make-subscription-tiers-Stripe-metadata-driven-adaptive-pricing-Council-004-rollout_Navid-Shad`. The `<taskId>` is the ClickUp custom id (the `CU-…` shown on the task), the title is the task name with spaces → dashes, and the author is the assignee.

- Branch off the latest `dev`; open a **PR into `dev`**. `dev` reaches `main` via the long-running `dev → main` PR — so a task only needs the one PR into `dev`.
- 🚧 **`main` is frozen for the duration of the UI migration. Do not merge `dev → main`.** The redesign lands one screen at a time (see [Design system migration](#design-system-migration)), so `dev` carries a deliberately half-migrated UI: screens already rebuilt on `subturtle-ui` sit beside screens still on pilotui, and dark mode is light-only on the migrated ones. Shipping that to production would put a visibly inconsistent app in front of users. The standing `dev → main` PR was closed for this reason; open a fresh one once the last screen is migrated and pilotui is removed.
- **Footgun:** if you create the branch with `git switch -c <branch> origin/dev`, Git sets its upstream to `origin/dev`, and a plain `git push` (or a Git-client "sync") then lands the commits **straight on `dev`** instead of a new remote branch. Create it without that tracking and publish it explicitly: `git switch -c <branch>` then `git push -u origin <branch>` (or `--no-track` when branching off `origin/dev`).

## Design system migration

The UI is being rebuilt on **`subturtle-ui`** (`ui/`), an in-house Vue 3 component library
carrying Subturtle's real brand, replacing **pilotui** — a generic admin theme whose blue
`#4361ee` palette was never ours. See [ui/README.md](ui/README.md).

The redesign lands **one screen per PR** onto the long-running `new-design` branch, which is
promoted to `dev` in batches. While it is in progress:

- **`main` is frozen** — see the note under [Branching](#branching). Nothing ships to
production until every screen is migrated.
- **pilotui stays installed** and is still required. Removing it is blocked on more than
components: `pilotui/style.css` supplies global classes the app markup uses directly
(`panel`, `btn`, `form-input`, `badge`, `animate__*`, `screen_loader`, `main-section`),
plus `pilotui/toast` and `useAppStore` from `pilotui/store`. Un-migrated screens render
inside the new shell and keep working.
- **Dark mode is light-only on migrated surfaces.** The theme switcher stays and
`:root.dark` is scaffolded but empty, so toggling dark leaves migrated screens light while
pilotui ones go dark. Deliberate; dark lands as its own milestone.
- `subturtle-ui` is a `link:../ui` dependency whose `dist/` is not committed, so the
frontend's `postinstall` builds it. Any context that installs the frontend needs `ui/`
present — the Dockerfile copies it in.

## Commits & versioning

This repo uses **semantic versioning**, and commit titles follow **Conventional Commits** so the type prefix maps to the intended `vMAJOR.MINOR.PATCH` bump. Pick the type by the change's real impact, not by habit:
Expand Down
108 changes: 108 additions & 0 deletions frontend/components/auth/LoginBoardPreview.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<template>
<!--
Decorative only: a blurred, tilted mock of Today's board with two phrase cards floating
off its left edge, so a signed-out visitor can see what they are signing in to.

aria-hidden and pointer-events-none — none of this copy describes real data, so it must
never reach a screen reader or take focus.

Shown from xl, not lg: the sign-in column is 540px and this box overhangs the right edge
by 90px of its 780px, so anything under 1230px would put the floating phrase cards on top
of the heading. Below that the column simply centres and this is dropped.
-->
<div
aria-hidden="true"
class="pointer-events-none absolute -right-[90px] top-1/2 z-[1] hidden h-[560px] w-[780px] -translate-y-1/2 [perspective:1400px] xl:block"
>
<div class="absolute inset-0 origin-left [transform:rotateY(-19deg)_rotateX(5deg)_rotate(-2deg)]">
<div
class="absolute inset-0 flex flex-col gap-5 overflow-hidden rounded-st-xl border border-st-line bg-st-page p-[34px] opacity-[0.92] shadow-st-xl blur-[4px]"
>
<div>
<div class="st-overline mb-[9px]">{{ t('auth.preview.today') }}</div>
<div class="font-st-display text-st-2xl font-black leading-[1.05] tracking-st-tight text-st-strong">
{{ t('auth.preview.board-title') }}
</div>
<div class="mt-2 text-st-md font-semibold text-st-muted">{{ t('auth.preview.board-subtitle') }}</div>
</div>

<div>
<div class="mb-3 text-st-2xs font-extrabold uppercase tracking-st-caps text-st-faint">{{ t('auth.preview.due-today') }}</div>

<div class="grid grid-cols-3 gap-[14px]">
<!-- Smart Review — the due card, with its level pips and a solid CTA. -->
<div class="flex h-[270px] flex-col rounded-st-lg border border-st-line bg-st-card p-[22px] shadow-st-sm">
<div class="mb-[18px] flex items-start justify-between gap-[10px]">
<span class="flex h-[54px] w-[54px] items-center justify-center rounded-st-md bg-st-warning-soft text-st-amber-600">
<StIcon name="solar:layers-minimalistic-bold-duotone" :size="29" />
</span>
<span
class="inline-flex h-[22px] items-center rounded-st-pill bg-st-warning-soft px-[9px] text-st-2xs font-extrabold text-st-amber-600"
>
{{ t('auth.preview.due') }}
</span>
</div>
<div class="mb-1.5 text-st-md font-extrabold text-st-strong">{{ t('auth.preview.review-title') }}</div>
<div class="text-st-sm font-semibold leading-[1.45] text-st-muted">{{ t('auth.preview.review-body') }}</div>
<div class="my-[18px] flex gap-1.5">
<span v-for="n in 5" :key="n" class="h-1.5 flex-1 rounded-st-pill" :class="n <= 3 ? 'bg-st-primary' : 'bg-st-ink-150'" />
</div>
<div class="mt-auto flex h-10 items-center justify-center gap-2 rounded-st-md bg-st-primary text-st-sm font-extrabold text-white">
{{ t('auth.preview.start-review') }}
</div>
</div>

<!-- Live session — same shell, outline CTA. -->
<div class="flex h-[270px] flex-col rounded-st-lg border border-st-line bg-st-card p-[22px] shadow-st-sm">
<div class="mb-[18px] flex items-start justify-between gap-[10px]">
<span class="flex h-[54px] w-[54px] items-center justify-center rounded-st-md bg-st-primary-soft text-st-rose-600">
<StIcon name="solar:microphone-3-bold-duotone" :size="29" />
</span>
<span class="text-st-xs font-bold text-st-faint">{{ t('auth.preview.time-left') }}</span>
</div>
<div class="mb-1.5 text-st-md font-extrabold text-st-strong">{{ t('auth.preview.session-title') }}</div>
<div class="text-st-sm font-semibold leading-[1.45] text-st-muted">{{ t('auth.preview.session-body') }}</div>
<div
class="mt-auto flex h-10 items-center justify-center gap-2 rounded-st-md border-[1.5px] border-st-primary text-st-sm font-extrabold text-st-rose-700"
>
{{ t('auth.preview.start-session') }}
</div>
</div>

<!-- Resting — sunken, no border or shadow: nothing to do here. -->
<div class="flex h-[270px] flex-col rounded-st-lg bg-st-sunken p-[22px]">
<span class="mb-[18px] flex h-[54px] w-[54px] items-center justify-center rounded-st-md bg-st-card text-st-faint">
<StIcon name="solar:moon-sleep-bold-duotone" :size="28" />
</span>
<div class="mb-1.5 text-st-md font-extrabold text-st-body">{{ t('auth.preview.resting-title') }}</div>
<div class="text-st-sm font-semibold leading-[1.45] text-st-muted">{{ t('auth.preview.resting-body') }}</div>
</div>
</div>
</div>

<div class="text-st-2xs font-extrabold uppercase tracking-st-caps text-st-faint">{{ t('auth.preview.optional') }}</div>
</div>
</div>

<!-- The two phrase cards float in front of the board, unblurred, so the eye lands on
the one thing the product is actually about. -->
<div class="absolute -left-[58px] top-16 w-[290px] rotate-[-5deg]">
<div class="rounded-st-lg border border-st-line bg-st-card px-5 py-[18px] shadow-st-xl">
<div class="text-st-md font-extrabold text-st-strong">{{ t('auth.preview.phrase-1') }}</div>
<div class="mt-[5px] text-st-sm font-semibold text-st-muted">{{ t('auth.preview.phrase-1-translation') }}</div>
</div>
</div>
<div class="absolute -left-6 bottom-[78px] w-[258px] rotate-[4deg]">
<div class="rounded-st-lg border border-st-line bg-st-card px-5 py-[18px] shadow-st-lg">
<div class="text-st-md font-extrabold text-st-strong">{{ t('auth.preview.phrase-2') }}</div>
<div class="mt-[5px] text-st-sm font-semibold text-st-muted">{{ t('auth.preview.phrase-2-translation') }}</div>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { StIcon } from 'subturtle-ui';

const { t } = useI18n();
</script>
47 changes: 47 additions & 0 deletions frontend/components/common/InlineNotice.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<template>
<div class="flex gap-3 rounded-st-md px-[17px] py-[15px]" :class="tone.surface">
<StIcon :name="tone.icon" :size="20" class="mt-px flex-none" :class="tone.mark" />
<div>
<div v-if="title" class="text-st-sm font-extrabold" :class="tone.mark">{{ title }}</div>
<p class="text-st-sm font-semibold leading-[1.5] text-st-ink-700 [text-wrap:pretty]" :class="title ? 'mt-1' : ''">
{{ message }}
</p>
</div>
</div>
</template>

<script setup lang="ts">
/**
* Soft inline notice — the design's non-blocking counterpart to a toast, for a message that
* belongs beside the thing it is about rather than in a screen corner.
*
* App-local rather than in subturtle-ui on purpose: the design system defines a `Toast` but
* no inline notice, callout or alert, so this is our composition of its tokens and not a
* library primitive. The second screen that wants one is the moment to graduate it.
*/
import { computed } from 'vue';
import { StIcon } from 'subturtle-ui';

const props = withDefaults(
defineProps<{
/** Semantic colour. `warning` = something happened to you; `danger` = something failed. */
color?: 'warning' | 'danger' | 'info';
message: string;
/** Optional bold lead-in above the message. */
title?: string;
/** Overrides the icon the colour picks by default. */
icon?: string;
}>(),
{ color: 'warning' }
);

// Full class strings only — Tailwind scans this file as text, so anything assembled at
// runtime would be purged from the build.
const TONES = {
warning: { surface: 'bg-st-warning-soft', mark: 'text-st-amber-600', icon: 'solar:clock-circle-bold' },
danger: { surface: 'bg-st-danger-soft', mark: 'text-st-red-600', icon: 'solar:danger-triangle-bold' },
info: { surface: 'bg-st-info-soft', mark: 'text-st-sky-600', icon: 'solar:clock-circle-bold' },
};

const tone = computed(() => ({ ...TONES[props.color], icon: props.icon || TONES[props.color].icon }));
</script>
47 changes: 44 additions & 3 deletions frontend/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -402,11 +402,52 @@
}
},
"auth": {
"signin_with_google": "SIGN IN WITH GOOGLE",
"signin_with_google": "Sign in with Google",
"login_page": "Login Page",
"signin": "Sign In",
"signin": "Sign in",
"signup": "Sign Up",
"signin_subtitle": "Sign in with Google to get started"
"signin_subtitle": "Sign in with Google to get started",
"heading-back": "Heading back to where you left off",
"no-account": "Don't have an account ?",
"no-account-cta": "Just sign in and you will get a new account.",
"chrome-extension": "Chrome extension",
"waiting": {
"label": "Waiting for Google\u2026",
"hint": "A Google window should have opened. Nothing there?",
"retry": "Open it again"
},
"returning": {
"title": "Signing you in\u2026",
"subtitle": "Taking you to your dashboard."
},
"notice": {
"expired": "Your session ended, so we brought you here. Nothing you saved was lost.",
"failed": {
"title": "That didn't go through",
"message": "The Google window closed before you finished. Nothing was saved \u2014 try once more."
}
},
"preview": {
"today": "Today",
"board-title": "Today's board",
"board-subtitle": "Two things today, about ten minutes. Day 9 of your streak.",
"due-today": "Due today",
"due": "Due",
"review-title": "Smart Review",
"review-body": "12 phrases from levels 1 to 3. Cloze cards start at level 3.",
"start-review": "Start review",
"time-left": "3 min left",
"session-title": "Live session",
"session-body": "Say this week's phrases out loud with Coach. Money Heist \u2014 s1e4.",
"start-session": "Start session",
"resting-title": "Resting",
"resting-body": "Levels 4 and 5 are asleep. Their next cards land on Thursday.",
"optional": "Optional practice",
"phrase-1": "Je n'en reviens pas",
"phrase-1-translation": "I can't believe it",
"phrase-2": "\u00c7a vaut le coup",
"phrase-2-translation": "It's worth it"
}
},
"practice": {
"nav": "Practice",
Expand Down
Loading
Loading