Repository Public
1// Frame of the anonymous pages: follows the system colour scheme (never the saved application theme), offers the2// language switch, and carries no link into the application.3import { type InputHTMLAttributes, type ReactNode, useEffect, useId, useLayoutEffect } from 'react';4import { Art } from '../art.tsx';5import { paintChrome } from '../theme.ts';6import { getLocale, setLocale, useT } from '../i18n.ts';7import { cx } from '../ui.tsx';89function useSystemTheme(): void {10 useLayoutEffect(() => {11 const media = window.matchMedia('(prefers-color-scheme: dark)');12 const apply = () => { document.documentElement.classList.toggle('dark', media.matches); paintChrome(media.matches); };13 apply();14 media.addEventListener('change', apply); // registered after the application's own listener, so this one has the last word15 return () => media.removeEventListener('change', apply);16 }, []);17}1819export function PublicShell({ title, wide, children }: { title: string; wide?: boolean; children: ReactNode }) {20 const t = useT();21 useSystemTheme();22 useEffect(() => { document.title = title; }, [title]);23 const pad = { paddingLeft: 'max(1rem, env(safe-area-inset-left))', paddingRight: 'max(1rem, env(safe-area-inset-right))' };24 return (25 <div className="aurora flex min-h-full flex-col bg-bg text-ink">26 <div className="safe-t" />27 <div className={cx('mx-auto flex w-full items-center justify-between pt-3', wide ? 'max-w-5xl' : 'max-w-xl')} style={pad}>28 <span className="flex items-center gap-2 text-[15px] font-bold tracking-tight" aria-hidden="true"><PublicMark />{t('common.appName')}</span>29 <div role="group" aria-label={t('public.language')} className="glass inline-flex overflow-hidden rounded-full p-0.5 text-xs font-bold">30 {(['fr', 'en'] as const).map((l) => (31 <button key={l} type="button" lang={l} aria-pressed={getLocale() === l} onClick={() => setLocale(l)} aria-label={l === 'fr' ? 'Français' : 'English'}32 className={cx('h-9 min-w-11 rounded-full px-3 uppercase transition', getLocale() === l ? 'bg-ink text-bg' : 'text-ink-2 hover:bg-hover')}>{l}</button>33 ))}34 </div>35 </div>36 <main className={cx('anim-rise mx-auto w-full flex-1 py-5 sm:py-8', wide ? 'max-w-5xl' : 'max-w-xl')} style={pad}>{children}</main>37 <footer className="safe-b pt-2"><div className="h-5" /></footer>38 </div>39 );40}41function PublicMark() {42 return (43 <svg width="26" height="26" viewBox="0 0 64 64" aria-hidden="true" focusable="false">44 <defs><linearGradient id="spd-pm" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="#4f46e5" /><stop offset="1" stopColor="#9b3dea" /></linearGradient></defs>45 <rect width="64" height="64" rx="15" fill="url(#spd-pm)" />46 <path d="M14 30v-7a4 4 0 0 1 4-4h8.3a3 3 0 0 1 2.1.9L31.5 23H46a4 4 0 0 1 4 4v3z" fill="#fff" fillOpacity=".55" />47 <rect x="14" y="27" width="36" height="21" rx="5" fill="#fff" />48 <path d="M25.5 37.5l4.5 4.500 8.500-9" fill="none" stroke="#5b3fe6" strokeWidth="3.4" strokeLinecap="round" strokeLinejoin="round" />49 </svg>50 );51}5253/** A neutral full-card message (link unavailable, portal closed…). */54export function Notice({ icon, title, text, action, tone = 'neutral' }: { icon: string; title: string; text?: string; action?: ReactNode; tone?: 'neutral' | 'warn' }) {55 return (56 <section className="card flex flex-col items-center px-6 py-12 text-center" data-testid="notice">57 <Art name={icon} tone={tone === 'warn' ? 'warn' : 'neutral'} />58 <h1 className="mt-4 text-2xl font-bold tracking-tight text-ink">{title}</h1>59 {text ? <p className="mx-auto mt-2 max-w-sm text-[15px] leading-relaxed text-ink-2">{text}</p> : null}60 {action ? <div className="mt-6 flex justify-center">{action}</div> : null}61 </section>62 );63}6465/** 16 px inputs (no zoom on iOS), 48 px tall, error tied to the field and announced. */66export const pInput = 'h-[52px] w-full rounded-2xl border border-line-strong bg-surface px-3.5 text-base text-ink placeholder:text-ink-3 focus:border-accent focus:outline-none focus:ring-4 focus:ring-accent/15 disabled:opacity-60';67export function PField({ label, error, hint, optional, ...p }: InputHTMLAttributes<HTMLInputElement> & { label: string; error?: string | null; hint?: string; optional?: string }) {68 const id = useId();69 const desc = error || hint ? `${id}-d` : undefined;70 return (71 <div className="flex flex-col gap-1.5">72 <label htmlFor={id} className="text-sm font-medium text-ink">{label}{p.required ? <span aria-hidden="true" className="text-danger"> *</span> : optional ? <span className="font-normal text-ink-3"> · {optional}</span> : null}</label>73 <input id={id} aria-describedby={desc} aria-invalid={!!error || undefined} {...p} className={cx(pInput, error && 'border-danger')} />74 {error ? <p id={desc} role="alert" className="text-sm text-danger">{error}</p> : hint ? <p id={desc} className="text-xs text-ink-3">{hint}</p> : null}75 </div>76 );77}78