feat(frontend): shell web responsive pour viewport téléphone (#69)
Lots 1 et 2 du ticket #69 : rendre le client web (#13) utilisable sur un téléphone. Le client web n'a jamais eu de docks ni de fenêtres flottantes — il est déjà une colonne verticale unique — donc le lot 2 se réduit aux rangées qui débordaient réellement à 360px. - `100dvh` sur html/body/#root : `height: 100%` se résout sur le *large* viewport, donc la barre d'URL rétractable d'un navigateur mobile masquait le bas de l'app. Desktop inchangé (dynamic == large viewport). - `viewport-fit=cover` + padding `env(safe-area-inset-*)` sur le header et le workspace : l'app peut peindre sous une encoche sans perdre ses contrôles. Le zoom reste libre (accessibilité). - Padding responsive (`p-4 sm:p-6`) : 48px de gouttière sur 360px, c'était 13% de la largeur. - `AgentLiveRow` et la rangée de tâche de fond empilent leurs contrôles sur téléphone (nom / badges / actions) et retrouvent leur ligne unique dès `sm` — à 360px les boutons Cancel+Retry ne tenaient pas. - Code d'appairage : `inputMode="numeric"`, pas d'autocapitalisation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -2,7 +2,14 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<!-- `viewport-fit=cover` (#69) lets the web client paint under a notch and
|
||||||
|
exposes the `env(safe-area-inset-*)` values the shell pads with. Zoom is
|
||||||
|
deliberately left unrestricted (no `maximum-scale`/`user-scalable=no`):
|
||||||
|
pinch-zoom is an accessibility affordance. -->
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||||
|
/>
|
||||||
<title>IdeA</title>
|
<title>IdeA</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -53,7 +53,7 @@ export function PairingScreen({ session, onPaired }: PairingScreenProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center bg-canvas p-6">
|
<div className="flex h-full items-center justify-center overflow-y-auto bg-canvas p-4 sm:p-6">
|
||||||
<Panel className="w-full max-w-sm">
|
<Panel className="w-full max-w-sm">
|
||||||
<form onSubmit={submit} className="flex flex-col gap-4">
|
<form onSubmit={submit} className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@ -74,6 +74,12 @@ export function PairingScreen({ session, onPaired }: PairingScreenProps) {
|
|||||||
placeholder="p. ex. 4821-93"
|
placeholder="p. ex. 4821-93"
|
||||||
autoFocus
|
autoFocus
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
|
// #69 — phones: summon the numeric-ish keypad for a pairing code
|
||||||
|
// and keep the OS from "helpfully" capitalising/correcting it.
|
||||||
|
inputMode="numeric"
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
invalid={!!error}
|
invalid={!!error}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -49,9 +49,12 @@ export function WebApp({ session }: WebAppProps = {}) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-canvas text-content">
|
<div className="flex h-full flex-col bg-canvas text-content">
|
||||||
<header className="flex shrink-0 items-center justify-between border-b border-border px-6 py-3">
|
<header
|
||||||
|
data-testid="web-header"
|
||||||
|
className="flex shrink-0 items-center justify-between border-b border-border px-4 py-3 pt-[max(0.75rem,env(safe-area-inset-top))] pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))] sm:px-6"
|
||||||
|
>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<h1 className="text-lg font-semibold tracking-tight">IdeA</h1>
|
<h1 className="text-base font-semibold tracking-tight sm:text-lg">IdeA</h1>
|
||||||
<span className="rounded-md bg-raised px-1.5 py-0.5 text-[0.65rem] font-medium uppercase text-muted">
|
<span className="rounded-md bg-raised px-1.5 py-0.5 text-[0.65rem] font-medium uppercase text-muted">
|
||||||
web
|
web
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -75,7 +75,10 @@ export function WebWorkspace() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-4 overflow-y-auto p-6">
|
<div
|
||||||
|
data-testid="web-workspace"
|
||||||
|
className="flex h-full flex-col gap-4 overflow-y-auto p-4 pb-[max(1rem,env(safe-area-inset-bottom))] pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))] sm:p-6"
|
||||||
|
>
|
||||||
<ReconnectBanner />
|
<ReconnectBanner />
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-base font-semibold tracking-tight">Projets</h2>
|
<h2 className="text-base font-semibold tracking-tight">Projets</h2>
|
||||||
@ -223,7 +226,11 @@ function AgentLiveRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="py-2 first:pt-0 last:pb-0">
|
<li className="py-2 first:pt-0 last:pb-0">
|
||||||
<div className="flex items-center justify-between gap-2">
|
{/* #69 — at 360px the name + two badges + the button no longer fit on one
|
||||||
|
line, so the name takes its own row on phones and the status cluster
|
||||||
|
wraps under it. From `sm` up this collapses back to the original
|
||||||
|
single-line desktop layout. */}
|
||||||
|
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
|
||||||
<span className="min-w-0 truncate text-sm text-content">{agent.name}</span>
|
<span className="min-w-0 truncate text-sm text-content">{agent.name}</span>
|
||||||
<span className="flex shrink-0 items-center gap-1.5 text-xs">
|
<span className="flex shrink-0 items-center gap-1.5 text-xs">
|
||||||
<span className={cn("rounded-full px-2 py-0.5 font-medium", live ? "bg-success/15 text-success" : "bg-raised text-muted")}>
|
<span className={cn("rounded-full px-2 py-0.5 font-medium", live ? "bg-success/15 text-success" : "bg-raised text-muted")}>
|
||||||
@ -311,29 +318,36 @@ function WebBackgroundTaskRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="min-w-0 text-xs text-muted">
|
<li className="min-w-0 text-xs text-muted">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
{/* #69 — status + kind + Cancel + Retry overflow a phone row. On narrow
|
||||||
<span className={cn("shrink-0 rounded-full px-1.5 py-0.5 font-medium", taskStatusClass(task.status))}>
|
screens the status/kind pair keeps the first line and the two actions
|
||||||
{TASK_STATUS_LABEL[task.status]}
|
wrap onto a second, right-aligned one; `sm` restores the single row. */}
|
||||||
</span>
|
<div className="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
|
||||||
<span className="min-w-0 flex-1 truncate text-content">{task.kind}</span>
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<Button
|
<span className={cn("shrink-0 rounded-full px-1.5 py-0.5 font-medium", taskStatusClass(task.status))}>
|
||||||
size="sm"
|
{TASK_STATUS_LABEL[task.status]}
|
||||||
variant="ghost"
|
</span>
|
||||||
disabled={!canCancel || actionBusy}
|
<span className="min-w-0 flex-1 truncate text-content">{task.kind}</span>
|
||||||
loading={actionBusy}
|
</div>
|
||||||
onClick={() => void runAction((id) => workState.cancelBackgroundTask(id))}
|
<div className="flex shrink-0 items-center justify-end gap-1">
|
||||||
>
|
<Button
|
||||||
Cancel
|
size="sm"
|
||||||
</Button>
|
variant="ghost"
|
||||||
<Button
|
disabled={!canCancel || actionBusy}
|
||||||
size="sm"
|
loading={actionBusy}
|
||||||
variant="ghost"
|
onClick={() => void runAction((id) => workState.cancelBackgroundTask(id))}
|
||||||
disabled={!canRetry || actionBusy}
|
>
|
||||||
loading={actionBusy}
|
Cancel
|
||||||
onClick={() => void runAction((id) => workState.retryBackgroundTask(id))}
|
</Button>
|
||||||
>
|
<Button
|
||||||
Retry
|
size="sm"
|
||||||
</Button>
|
variant="ghost"
|
||||||
|
disabled={!canRetry || actionBusy}
|
||||||
|
loading={actionBusy}
|
||||||
|
onClick={() => void runAction((id) => workState.retryBackgroundTask(id))}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{message && <p role="alert" className="mt-1 text-danger">{message}</p>}
|
{message && <p role="alert" className="mt-1 text-danger">{message}</p>}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -60,12 +60,28 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* #69 — phone viewports. `height: 100%` resolves against the *large* viewport,
|
||||||
|
so a mobile browser's collapsing URL bar leaves the last ~60px of the app
|
||||||
|
under the toolbar and makes xterm's fit() overshoot. `100dvh` tracks the
|
||||||
|
*dynamic* viewport instead. Desktop is unaffected: there the dynamic and
|
||||||
|
large viewports are identical, so this resolves to the same height. */
|
||||||
|
@supports (height: 100dvh) {
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-color: var(--color-canvas);
|
background-color: var(--color-canvas);
|
||||||
color: var(--color-content);
|
color: var(--color-content);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
/* Phones: never rubber-band the whole document — the app owns its own
|
||||||
|
scroll containers, and an overscrolling body detaches the fixed shell. */
|
||||||
|
overscroll-behavior-y: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A consistent focus ring across the kit (keyboard accessibility). */
|
/* A consistent focus ring across the kit (keyboard accessibility). */
|
||||||
|
|||||||
Reference in New Issue
Block a user