From 4342018178bbbc4b7155dc887bdb16f8175fada0 Mon Sep 17 00:00:00 2001 From: Wifsimster Date: Fri, 17 Jul 2026 12:03:42 +0200 Subject: [PATCH 1/2] style(mobile): refresh palette to exact web design-system tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert theme.tsx to faithful OKLCH→sRGB conversions of apps/web/src/ app.css (:root + .dark) instead of hand-eyeballed values. Callout surfaces now reproduce the web's color-mix(in oklab, …) recipes as translucent rgba fills + borders, foregrounds from color-mix(…,black| white). tip → accent gold scale, alert → warning. Core surfaces already matched; this pins every token to its source --var. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/mobile/src/lib/theme.tsx | 110 +++++++++++++++++++--------------- 1 file changed, 61 insertions(+), 49 deletions(-) diff --git a/apps/mobile/src/lib/theme.tsx b/apps/mobile/src/lib/theme.tsx index 31973c08..d04f81be 100644 --- a/apps/mobile/src/lib/theme.tsx +++ b/apps/mobile/src/lib/theme.tsx @@ -36,63 +36,75 @@ export type Palette = { chevron: string; }; +// Light — exact conversions of app.css `:root` (OKLCH → sRGB). Core surfaces +// are warm cream (bg/card) with the teal primary; secondary is pale sage. The +// callout surfaces/borders reproduce the web's `color-mix(in oklab, C n%, +// transparent)` as rgba, and the foregrounds its `color-mix(… , black)`. export const lightColors: Palette = { - brand: "#358891", + brand: "#358891", // --primary oklch(0.58 0.08 205) action: "#358891", - secondary: "#e1efe5", - text: "#221812", - subtext: "#52443b", - muted: "#6d6059", - border: "#e6e0d9", - card: "#fffdfa", - bg: "#fdf9f4", - danger: "#cf4040", - success: "#10b981", - infoSurface: "#e8f0fe", - infoBorder: "#c3d4f9", - infoFg: "#1d4ed8", - tipSurface: "#faf3d9", - tipBorder: "#e8d49b", - tipFg: "#a37e29", - successSurface: "#d6f3e6", - successBorder: "#9ad9bd", - successFg: "#065f46", - alertSurface: "#fdeccb", - alertBorder: "#f0c674", - alertFg: "#92400e", - dangerSurface: "#fdecec", - dangerBorder: "#f5b5b5", - dangerFg: "#b91c1c", + secondary: "#e1efe5", // --secondary oklch(0.94 0.02 155) + text: "#221812", // --foreground oklch(0.22 0.02 50) + subtext: "#3a2a20", // --secondary-foreground oklch(0.30 0.03 50) + muted: "#6d6059", // --muted-foreground oklch(0.50 0.02 50) + border: "#e6e0d9", // --border oklch(0.91 0.012 75) + card: "#fffdfa", // --card oklch(0.995 0.004 75) + bg: "#fdf9f4", // --background oklch(0.985 0.008 75) + danger: "#cf4040", // --destructive oklch(0.58 0.18 25) + success: "#10b981", // --color-status-success + // info → --color-info-* (base #3b82f6) + infoSurface: "rgba(59, 130, 246, 0.12)", + infoBorder: "rgba(59, 130, 246, 0.3)", + infoFg: "#295fb7", + // tip → --color-accent-* (brand gold scale) + tipSurface: "#f3eacb", // accent-100 + tipBorder: "#e8d49b", // accent-200 + tipFg: "#846522", // accent-600 + // success callout → --color-success-* (base #10b981) + successSurface: "rgba(16, 185, 129, 0.14)", + successBorder: "rgba(16, 185, 129, 0.32)", + successFg: "#025f44", + // alert → --color-warning-* (base #f59e0b) + alertSurface: "rgba(245, 158, 11, 0.14)", + alertBorder: "rgba(245, 158, 11, 0.35)", + alertFg: "#904106", + // danger callout → --color-danger-* (base #f43f5e) + dangerSurface: "rgba(244, 63, 94, 0.14)", + dangerBorder: "rgba(244, 63, 94, 0.32)", + dangerFg: "#980c2e", chevron: "#a89e93", }; +// Dark — exact conversions of app.css `.dark`. The web's dark theme is a cool +// blue-navy (hue ~265); mirrored here 1:1. Callout surfaces use the heavier +// dark opacities from `.dark`, foregrounds its `color-mix(… , white)`. export const darkColors: Palette = { - brand: "#74b8c0", + brand: "#74b8c0", // --primary oklch(0.74 0.07 205) action: "#74b8c0", - secondary: "#263129", - text: "#e2e8f1", - subtext: "#b6bfcb", - muted: "#8091a8", - border: "#2a3346", - card: "#111a2e", - bg: "#091123", - danger: "#f66d67", - success: "#34d399", - infoSurface: "#16243f", - infoBorder: "#2c4a7a", - infoFg: "#93b4f5", - tipSurface: "#2a2410", - tipBorder: "#5a4a1e", + secondary: "#263129", // --secondary oklch(0.30 0.02 155) + text: "#e2e8f1", // --foreground oklch(0.93 0.013 256) + subtext: "#b6bfcb", // --secondary-foreground oklch(0.90 0.015 256) + muted: "#8091a8", // --muted-foreground oklch(0.65 0.04 257) + border: "#293143", // --border white @10% over --card + card: "#111a2e", // --card oklch(0.22 0.042 265) + bg: "#091123", // --background oklch(0.18 0.04 265) + danger: "#f66d67", // --destructive oklch(0.70 0.17 25) + success: "#34d399", // --color-status-success (dark tint) + infoSurface: "rgba(59, 130, 246, 0.18)", + infoBorder: "rgba(59, 130, 246, 0.4)", + infoFg: "#98c8fd", + tipSurface: "rgba(232, 197, 116, 0.14)", + tipBorder: "rgba(232, 197, 116, 0.3)", tipFg: "#e8c574", - successSurface: "#0f2a20", - successBorder: "#1e5a44", - successFg: "#6ee7b7", - alertSurface: "#2e2410", - alertBorder: "#6a4e1e", - alertFg: "#f0c674", - dangerSurface: "#2e1416", - dangerBorder: "#5a2a2c", - dangerFg: "#f3a6a3", + successSurface: "rgba(16, 185, 129, 0.18)", + successBorder: "rgba(16, 185, 129, 0.42)", + successFg: "#78e8bb", + alertSurface: "rgba(245, 158, 11, 0.18)", + alertBorder: "rgba(245, 158, 11, 0.42)", + alertFg: "#fcd55a", + dangerSurface: "rgba(244, 63, 94, 0.18)", + dangerBorder: "rgba(244, 63, 94, 0.42)", + dangerFg: "#fea9b3", chevron: "#6b7689", }; From e7dc21d9dc2703e0e31fa0f728c80929e3afd736 Mon Sep 17 00:00:00 2001 From: Wifsimster Date: Fri, 17 Jul 2026 12:03:42 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(mobile):=20doc-conformity=20=E2=80=94?= =?UTF-8?q?=20opt-in=20reminders,=20B4=20tunnel=20guard,=20billing=20comme?= =?UTF-8?q?nt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of apps/mobile vs docs/ surfaced three net gaps (billing/Play Store conformity was otherwise clean): - RGPD §7: the phone evening reminder defaulted ON (opt-out). Flip eveningPhone default to false — reminders are opt-in, matching the account preferences. Parent enables via dashboard prompt / companion toggle / Réglages. - Business rule B4 (no notification 16h30–21h): the local notification channel had no tunnel guard (unlike server push via isTunnelHourIn), so the 20h30 evening reminder fired inside the window. Add clampOutOfTunnel() — a reminder configured in the tunnel is pushed to its 21h00 close. Applied to both schedule functions. - config.ts: WEB_URL comment falsely claimed a "Stripe subscription flow"; no code does that (Play forbids external payment steering). Corrected to reflect its real use (web feature pages, read-only). Docs updated (phase4-companion.md) to match. pnpm typecheck → 0 errors. Note: apps/mobile has no test runner, so clampOutOfTunnel is exported pure for later unit testing. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/mobile/src/hooks/use-phone-reminders.ts | 9 +++--- apps/mobile/src/lib/config.ts | 7 ++++- apps/mobile/src/lib/notifications.ts | 30 ++++++++++++++++++-- docs/phase4-companion.md | 14 +++++---- 4 files changed, 47 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/hooks/use-phone-reminders.ts b/apps/mobile/src/hooks/use-phone-reminders.ts index 9f551835..2cf5442a 100644 --- a/apps/mobile/src/hooks/use-phone-reminders.ts +++ b/apps/mobile/src/hooks/use-phone-reminders.ts @@ -16,12 +16,13 @@ export type PhoneReminderPrefs = { eveningPhone: boolean; }; -// Evening defaults ON to preserve the historically always-scheduled evening -// check-in (the decisive reason the app is native). Morning is a new, opt-in -// capability so it starts OFF. +// Both default OFF: reminders are opt-in (RGPD §7 — consent, not opt-out), the +// same principle the account preferences follow (all reminder opt-ins default +// to false). The parent turns the evening reminder on from the dashboard +// prompt, the companion Matin/Soir toggle, or Réglages. export const PHONE_REMINDER_DEFAULTS: PhoneReminderPrefs = { morningPhone: false, - eveningPhone: true, + eveningPhone: false, }; /** Reads the stored phone-reminder opt-ins, falling back to the defaults. */ diff --git a/apps/mobile/src/lib/config.ts b/apps/mobile/src/lib/config.ts index e18bfad9..e44d39e8 100644 --- a/apps/mobile/src/lib/config.ts +++ b/apps/mobile/src/lib/config.ts @@ -20,5 +20,10 @@ export const COMPANION_MODE = extra.companionMode === true; /** Base URL of the deployed Hono API (the same backend the web app uses). */ export const API_URL = extra.apiUrl ?? "https://toko.app"; -/** Base URL of the web app, used to open the Stripe subscription flow. */ +/** + * Base URL of the web app. Used to open web-only feature pages in the browser + * (rapport, insights, rewards, discord). NOT a payment/checkout link: Google + * Play forbids steering to external payment, so the abonnement is never linked + * from the app — premium is read-only here (see project_mobile_billing). + */ export const WEB_URL = extra.webUrl ?? "https://toko.app"; diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts index 6d3d21c5..cb905495 100644 --- a/apps/mobile/src/lib/notifications.ts +++ b/apps/mobile/src/lib/notifications.ts @@ -40,6 +40,27 @@ function parseHhmm(time: string): { hour: number; minute: number } | null { return { hour: hour!, minute: minute! }; } +// Business rule B4 (docs/business-rules.md): no notification during the evening +// "tunnel" 16h30–21h00 except emergencies. The server push honours this via +// isTunnelHourIn; the OS-scheduled *local* reminder has no such filter, so we +// guard it here — otherwise the default 20h30 evening reminder would fire right +// inside the forbidden window. A reminder configured in the tunnel is pushed to +// its close (21h00): the moment the hard hours end, exactly where the "point du +// soir" belongs. +const TUNNEL_START_MIN = 16 * 60 + 30; // 16h30 inclusive +const TUNNEL_END_MIN = 21 * 60; // 21h00 exclusive + +export function clampOutOfTunnel(hour: number, minute: number): { + hour: number; + minute: number; +} { + const mins = hour * 60 + minute; + if (mins >= TUNNEL_START_MIN && mins < TUNNEL_END_MIN) { + return { hour: 21, minute: 0 }; + } + return { hour, minute }; +} + /** Requests OS permission + sets up the Android notification channel. */ export async function ensureNotificationPermissions(): Promise { if (!Device.isDevice) return false; @@ -78,6 +99,8 @@ export async function scheduleMorningReminder( const granted = await ensureNotificationPermissions(); if (!granted) return; + ({ hour, minute } = clampOutOfTunnel(hour, minute)); + await Notifications.cancelScheduledNotificationAsync(MORNING_ID).catch( () => undefined, ); @@ -106,8 +129,9 @@ export async function cancelMorningReminder(): Promise { } /** - * Schedules (or replaces) the daily evening check-in reminder. Defaults to - * 20h30, inside the 16h30–21h00 window from the offline-strategy business rule. + * Schedules (or replaces) the daily evening check-in reminder. The configured + * time is clamped out of the 16h30–21h00 tunnel (rule B4) before scheduling — + * a time inside the window fires at 21h00 instead. */ export async function scheduleEveningReminder( hour = 20, @@ -116,6 +140,8 @@ export async function scheduleEveningReminder( const granted = await ensureNotificationPermissions(); if (!granted) return; + ({ hour, minute } = clampOutOfTunnel(hour, minute)); + await Notifications.cancelScheduledNotificationAsync(EVENING_ID).catch( () => undefined, ); diff --git a/docs/phase4-companion.md b/docs/phase4-companion.md index dea9cbee..f872e94c 100644 --- a/docs/phase4-companion.md +++ b/docs/phase4-companion.md @@ -14,8 +14,9 @@ Le port Expo contient déjà l'essentiel du compagnon : - **Notifications locales exactes** (`src/lib/notifications.ts`) : canal Android, permissions, `scheduleMorningReminder` / `scheduleEveningReminder` (triggers `DAILY` de l'OS — survivent au Doze, hors-ligne). Câblées via - `use-phone-reminders` (le soir est ON par défaut, « la raison décisive du - natif »). **C'est le différenciateur vs le web push, et il est fait.** + `use-phone-reminders` (**opt-in, désactivés par défaut** — RGPD §7 ; le parent + les active depuis l'encart du dashboard, le toggle Matin/Soir du compagnon, ou + Réglages). **C'est le différenciateur vs le web push, et il est fait.** - **Partage de session** avec le compte web (Better Auth `authClient`). - **Routines** (avec `timeOfDay` matin/soir/…), **timer-animal** (`TimerScreen`) et **compagnons** (`CompanionCollectionScreen`). @@ -39,8 +40,9 @@ compagnon. Chaque onglet Matin / Soir affiche en tête un **interrupteur de rappel** pour sa demi-journée (`CompanionReminderToggle`) : le compagnon n'a pas d'écran Réglages, donc c'est là que le parent active/désactive la notification locale et -déclenche la demande de permission OS. L'heure vient des préférences du compte -(le soir est ON par défaut). +déclenche la demande de permission OS. Les rappels sont **opt-in (désactivés par +défaut, RGPD)** ; l'heure vient des préférences du compte, bornée hors de la +fenêtre tunnel 16h30–21h (rule B4). Le reste (journal, symptômes, rapport, médicaments, abonnement…) **reste sur le web** — l'app est 100 % gratuite, zéro achat in-app, conformément à la stratégie. @@ -51,8 +53,8 @@ Le différenciateur du natif — les rappels matin/soir exacts — est câblé a **niveau racine authentifié** (`useReminderSync`, monté dans `App.tsx`), pas sur `HomeScreen`. Le compagnon ne monte jamais `HomeScreen` ; sans ce déplacement, ses rappels n'auraient jamais été programmés. La réconciliation -reste idempotente et honore les mêmes opt-ins (soir ON par défaut) et horaires -de compte. +reste idempotente et honore les mêmes opt-ins (désactivés par défaut) et +horaires de compte. Les **taps de notification** sont routés vers les onglets du compagnon via `companionLinking` (`src/navigation/linking.ts`) : le rappel du matin ouvre