SPB Git

Repository Public

PublicShell.tsx 4.9 KB 78 lines tsx

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
Files main
Menu

Theme