feat(web): freeze interactive dashboard grid

This commit is contained in:
fy59 2026-09-16 13:56:21 +02:00
parent 6f34233751
commit 3973a60233
17 changed files with 753 additions and 42 deletions

View file

@ -262,9 +262,9 @@ must not require either. All production assets are local, built ahead of time,
and incorporated into the `trainlog` binary from the sibling root `web/`
directory. The shell implements semantic Header/Body/Footer landmarks,
keyboard-visible focus, responsive tile scaffolding and reduced-motion rules.
Absent user/session/zone facts remain visibly unavailable. The grid library
remains open pending a dedicated spike; ECharts remains deferred until a real
visualization exists.
Absent user/session/zone facts remain visibly unavailable. The bounded grid
engine uses `react-grid-layout` 2.2.4 behind a Trainlog-owned model, adapter and
validator; ECharts remains deferred until a real visualization exists.
The Dashboard is a canonical 12-column grid containing exactly seven V1 tiles:
@ -276,12 +276,14 @@ The Dashboard is a canonical 12-column grid containing exactly seven V1 tiles:
6. Répartition musculaire;
7. Cardio / récupération.
V1 supports drag and drop, horizontal and vertical resizing, tile-specific
minimum/maximum sizes, automatic reorganization, responsive projection,
persistence, and restoration of the Trainlog default. Dragging and resizing
are enabled only by the explicit `Modifier l'agencement` mode. A larger tile
may reveal more information, but tile size never changes a metric's business
definition.
`WEB_DASHBOARD_GRID_V1=PASS/FROZEN` supports drag and drop, horizontal and
vertical resizing, tile-specific minimum/maximum sizes, automatic
reorganization, responsive projection, persistence in the next tranche, and
restoration of the Trainlog default. Dragging and resizing are enabled only by
the explicit `Modifier l'agencement` mode. A larger tile may reveal more
information, but tile size never changes a metric's business definition. Until
`WEB_DASHBOARD_LAYOUT_V1`, saving accepts a valid draft only in current React
memory and reload restores the default.
The single persisted source layout is the canonical desktop 12-column layout:

View file

@ -42,6 +42,7 @@ parallel implementation of its rules.
| TUI Dashboard adoption | `WEB_TUI_READ_MODEL_ADOPTION_V1=PASS/FROZEN` |
| Web CLI/HTTP infrastructure | `WEB_CLI_HTTP_INFRASTRUCTURE_V1=PASS/FROZEN` |
| Web frontend shell | `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` |
| Web Dashboard grid | `WEB_DASHBOARD_GRID_V1=PASS/FROZEN` |
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
Desktop and Android schema numbers are independent. Neither changes the frozen
@ -191,6 +192,13 @@ The Dashboard data-contract tranche passes **71/71 normal and ASan/UBSan
Meson tests**, including the bounded Core/JSON target, HTTP endpoint coverage
and 12 Vitest tests for parsing and factual Footer integration.
`WEB_DASHBOARD_GRID_V1=PASS/FROZEN` uses `react-grid-layout` 2.2.4 behind an
independent Trainlog model/validator. Its 12-column desktop layout has seven
stable tile IDs, bounded per-tile dimensions, vertical collision compaction,
explicit edit/cancel/reset/session-only-save behavior, mouse drag/two-axis
resize, an announced keyboard alternative, and derived six/one-column
responsive projections. No layout is persisted yet.
`TRAINLOG_I18N_V0_1_1=PASS` is covered by desktop presentation, persistence,
formatting, layout-invariance and source-derived text-boundary tests, plus
Android resource-parity, language-owner, typed sync-presentation, and
@ -206,6 +214,9 @@ smoke test are not automated. The latter is why
- `WEB_DASHBOARD_DATA_CONTRACT_V1=PASS/FROZEN`: the bounded Core snapshot,
`/api/v1/dashboard`, explicit unavailable states and factual Footer data are
implemented. The seven tile bodies intentionally retain shell placeholders.
- `WEB_DASHBOARD_GRID_V1=PASS/FROZEN`: layout changes live only in current
React memory. Versioned disk/API persistence is the next tranche,
`WEB_DASHBOARD_LAYOUT_V1`.
- `APP_SHELL_V1` still awaits the recorded human visual/accessibility review.
- AI proposal exchange still awaits one real Drive plus Android-triggered
bidirectional smoke test.

View file

@ -0,0 +1,86 @@
# Web Dashboard grid V1
Status: `WEB_DASHBOARD_GRID_V1=PASS/FROZEN`
## Library spike
The spike was performed on 2026-09-16 against React 19.3.0, TypeScript 7 and
Vite 8.3.0. Package metadata and upstream documentation were checked before
installation.
| Candidate | Version checked | Fit | Decision |
|---|---:|---|---|
| `react-grid-layout` | 2.2.4 | MIT; React peer `>=16.3`; TypeScript v2 implementation; 12-column layouts, drag, two-axis resize, per-item min/max, configurable enablement, vertical compaction, responsive primitives and serializable layouts. Active 2026 releases. Mouse interaction is mature but has no complete keyboard grid editor. | Selected, with a Trainlog keyboard layer and independent model/validator. |
| `gridstack` | 13.3.0 | MIT; active maintenance; framework-neutral drag, resize, responsive columns and constraints. Its package is substantially larger and React integration remains an imperative wrapper around a DOM engine rather than a native typed React state boundary. | Rejected for this React-owned surface. |
| `@dnd-kit/core` | 6.3.1 | MIT; strong sensors and keyboard support, but it is a drag/drop toolkit rather than a heterogeneous dashboard-grid engine. It supplies neither resize nor the required collision/compaction policy; upstream guidance requires a custom sorting strategy for mixed grid sizes. | Rejected because Trainlog would have to build the missing engine. |
`react-grid-layout` has no external service, runtime telemetry, CDN or backend
dependency. Trainlog imports the v2 API directly and does not expose the
library's `{i,w,h}` representation outside its adapter.
## Trainlog model and invariants
The authoritative frontend shape is:
```text
TileLayout { id, x, y, width, height }
```
The seven stable IDs are `next-session`, `activity`, `progression`,
`last-session`, `max-records`, `muscle-distribution`, and `cardio-recovery`.
The desktop layout alone is canonical and uses 12 columns. The adapter maps it
to and from the library shape. A separate Trainlog validator requires exactly
those IDs, integral coordinates and dimensions, each tile's min/max bounds,
`x >= 0`, `y >= 0`, `x + width <= 12`, `y <= 200`, and no overlap.
The deterministic default is:
| Tile | x | y | width | height | min/max width | min/max height |
|---|---:|---:|---:|---:|---:|---:|
| Prochaine séance | 0 | 0 | 5 | 4 | 4–8 | 3–7 |
| Activité | 5 | 0 | 7 | 4 | 5–12 | 3–7 |
| Progression | 0 | 4 | 8 | 5 | 6–12 | 4–9 |
| Dernière séance | 8 | 4 | 4 | 3 | 3–7 | 3–6 |
| Records / MAX | 8 | 7 | 4 | 3 | 3–8 | 3–7 |
| Répartition musculaire | 0 | 9 | 6 | 5 | 5–12 | 4–9 |
| Cardio / récupération | 6 | 10 | 6 | 4 | 5–12 | 4–8 |
Vertical compaction is deterministic; overlap is forbidden and collisions
push/reflow other items. Library output is accepted into React state only after
conversion and Trainlog validation.
## Interaction and responsive contract
Normal mode disables drag and resize and emits no resize handles. Explicit
`Modifier l'agencement` mode enables them on desktop. `Annuler` restores the
entry snapshot, `Réinitialiser` restores the Trainlog default in the edit
draft, and `Enregistrer pour cette session` accepts the draft only in current
React memory. Reload deliberately restores the default: persistence belongs to
`WEB_DASHBOARD_LAYOUT_V1`.
Focused tiles support Arrow keys for movement and Shift+Arrow keys for resize.
Focus is visible and a polite live region announces logical coordinates and
dimensions. Mouse resize exposes east, south and south-east handles. Reduced
motion disables grid transitions.
Widths below 1100 px derive a six-column projection; widths below 700 px derive
a stable one-column projection. These projections preserve canonical ordering,
remain non-authoritative and never update the desktop layout. Editing at those
breakpoints is deliberately disabled with an explicit explanation.
The existing `/api/v1/dashboard` fetch and factual Footer remain unchanged.
Tile bodies retain placeholders; logical `compact`, `medium`, and `large`
presentation levels depend only on tile dimensions and add no business facts.
## Bundle impact
Vite production output before the dependency was 229.07 kB JavaScript and
7.14 kB CSS. V1 output is 301.61 kB JavaScript and 13.42 kB CSS: an increase of
72.54 kB JavaScript and 6.28 kB CSS before transport compression. This is
accepted for the complete drag, resize, constraint and compaction engine.
Upstream references:
- <https://github.com/react-grid-layout/react-grid-layout>
- <https://github.com/gridstack/gridstack.js>
- <https://github.com/clauderic/dnd-kit>

View file

@ -26,12 +26,13 @@ implemented. Session Generator V1 is hidden pending V2. AI session-draft
exchange and APP_SHELL_V1 retain their explicit manual validation/review gates.
`TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`; its local-only
CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data
contract exist. Final tile rendering and layout remain future slices.
contract and the frozen interactive grid exist. Final tile rendering and layout
persistence remain future slices.
## Current cursor
```text
CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_GRID_V1
CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_LAYOUT_V1
```
`WEB_FRONTEND_SHELL_V1=PASS/FROZEN`. `trainlog -w` serves the embedded React,
@ -39,6 +40,9 @@ TypeScript and Vite shell with its five client routes, Catppuccin Mocha design
system, permanent Header/Footer, empty-data states and health status. The next
Dashboard tranche is partitioned below; this cursor does not authorize an
undifferentiated implementation of every Dashboard concern.
`WEB_DASHBOARD_GRID_V1=PASS/FROZEN` adds the validated 12-column desktop grid,
explicit edit mode, drag/resize, keyboard alternative and derived responsive
projections without persisting layout state.
## TRAINLOG_WEB_V1 implementation gate
@ -70,8 +74,8 @@ must consume them without reconstructing or redefining their mathematics.
```text
WEB_DASHBOARD_DATA_CONTRACT_V1 [PASS/FROZEN]
-> grid-library spike and bounded grid engine
-> separately versioned layout persistence
-> WEB_DASHBOARD_GRID_V1 [PASS/FROZEN]
-> WEB_DASHBOARD_LAYOUT_V1
-> real-data tile delivery
-> real-data visualization delivery
```

View file

@ -67,6 +67,15 @@ CSP, CORS or unknown-route behavior. Vitest validates strict parsing, real
Footer population, empty fallback, malformed snapshots and absence of invented
tile metrics; the seven tile bodies remain placeholders.
`WEB_DASHBOARD_GRID_V1` adds Vitest coverage for the seven stable identities,
default validity/non-overlap, every min/max constraint, independent rejection
of unknown/duplicate/out-of-grid/non-integral layouts, lossless library adapter
round trips, deterministic collision compaction, horizontal/vertical keyboard
resize, responsive six/one-column projections, normal-mode locking, absent
normal-mode handles, edit actions, cancel/reset/session-only save, visible
keyboard focus and live announcements. Existing shell tests continue to prove
the factual Footer, navigation and absence of invented data.
## 2. Frozen Trainlog JSON v1
Run:

View file

@ -51,6 +51,8 @@ if web_assets_enabled
'../web/src/components/Footer.tsx',
'../web/src/components/Header.tsx',
'../web/src/components/Tile.tsx',
'../web/src/dashboard/DashboardGrid.tsx',
'../web/src/dashboard/dashboardLayout.ts',
'../web/src/routes/DashboardPage.tsx',
'../web/src/routes/PlaceholderPage.tsx',
'../web/src/theme/app.css',

112
web/package-lock.json generated
View file

@ -9,7 +9,8 @@
"version": "0.0.0",
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0"
"react-dom": "19.3.0",
"react-grid-layout": "2.2.4"
},
"devDependencies": {
"@testing-library/jest-dom": "7.0.1",
@ -1200,6 +1201,15 @@
"node": ">=18"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
@ -1332,6 +1342,12 @@
"node": ">=12.0.0"
}
},
"node_modules/fast-equals": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
"integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==",
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -1399,9 +1415,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/jsdom": {
"version": "30.0.1",
@ -1717,6 +1731,18 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
@ -1784,6 +1810,15 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/obug": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
@ -1876,6 +1911,23 @@
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -1907,6 +1959,38 @@
"react": "^19.3.0"
}
},
"node_modules/react-draggable": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.1.tgz",
"integrity": "sha512-wa3tzfFnYt3yaZLuyU58fl1TNunfWfBekDgWhZA1+gb2jnp42wZ0ymuopR6M5kqDYmm4hKmzGlcKWjZf3Zb6RQ==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-grid-layout": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.4.tgz",
"integrity": "sha512-Eb57FsgOMYOfsUGrMI1ku/FFR+dPNPrE8qo+3hwZubpqVSy4GO9v52DeX50Tl3JDYAlCypP4rmw7Vrqk/zOIvA==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"fast-equals": "^4.0.3",
"prop-types": "^15.8.1",
"react-draggable": "^4.4.6",
"react-resizable": "^3.1.3",
"resize-observer-polyfill": "^1.5.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@ -1915,6 +1999,20 @@
"license": "MIT",
"peer": true
},
"node_modules/react-resizable": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz",
"integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==",
"license": "MIT",
"dependencies": {
"prop-types": "15.x",
"react-draggable": "^4.5.0"
},
"peerDependencies": {
"react": ">= 16.3",
"react-dom": ">= 16.3"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@ -1939,6 +2037,12 @@
"node": ">=0.10.0"
}
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"license": "MIT"
},
"node_modules/rolldown": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz",

View file

@ -10,7 +10,8 @@
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0"
"react-dom": "19.3.0",
"react-grid-layout": "2.2.4"
},
"devDependencies": {
"@testing-library/jest-dom": "7.0.1",

View file

@ -1,16 +1,26 @@
import type { ReactNode } from 'react'
import type { KeyboardEventHandler, ReactNode } from 'react'
import type { TileSize } from '../dashboard/dashboardLayout'
interface TileProps {
title: string
eyebrow?: string
children: ReactNode
className?: string
size?: TileSize
editable?: boolean
onKeyDown?: KeyboardEventHandler<HTMLElement>
}
export function Tile({ title, eyebrow, children, className = '' }: TileProps) {
export function Tile({ title, eyebrow, children, className = '', size = 'medium', editable = false, onKeyDown }: TileProps) {
return (
<article className={`tile ${className}`.trim()}>
<div className="tile-header">
<article
className={`tile tile-${size} ${className}`.trim()}
data-size={size}
tabIndex={editable ? 0 : undefined}
aria-label={editable ? `${title}, tuile modifiable` : undefined}
onKeyDown={onKeyDown}
>
<div className={editable ? 'tile-header tile-drag-handle' : 'tile-header'}>
<div>
{eyebrow && <p className="eyebrow">{eyebrow}</p>}
<h2>{title}</h2>

View file

@ -0,0 +1,64 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { DashboardGrid } from './DashboardGrid'
describe('grille interactive du Dashboard', () => {
it('est verrouillée par défaut sans poignées de redimensionnement', () => {
const { container } = render(<DashboardGrid />)
expect(screen.getByRole('button', { name: 'Modifier l’agencement' })).toBeInTheDocument()
expect(container.querySelector('.dashboard-layout')).not.toHaveClass('is-editing')
expect(container.querySelectorAll('.react-resizable-handle')).toHaveLength(0)
expect(screen.queryByRole('article', { name: /tuile modifiable/i })).not.toBeInTheDocument()
})
it('active drag, resize et les commandes transactionnelles en mode édition', () => {
const { container } = render(<DashboardGrid />)
fireEvent.click(screen.getByRole('button', { name: 'Modifier l’agencement' }))
expect(container.querySelector('.dashboard-layout')).toHaveClass('is-editing')
expect(screen.getByRole('button', { name: 'Annuler' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Réinitialiser' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Enregistrer pour cette session' })).toBeInTheDocument()
expect(container.querySelectorAll('.react-resizable-handle').length).toBeGreaterThan(0)
expect(screen.getByRole('article', { name: 'Progression, tuile modifiable' })).toHaveAttribute('tabindex', '0')
})
it('déplace et redimensionne une tuile au clavier avec annonce accessible', () => {
render(<DashboardGrid />)
fireEvent.click(screen.getByRole('button', { name: 'Modifier l’agencement' }))
const tile = screen.getByRole('article', { name: 'Progression, tuile modifiable' })
tile.focus()
expect(tile).toHaveFocus()
fireEvent.keyDown(tile, { key: 'ArrowDown' })
expect(screen.getByText(/Progression : colonne \d+, ligne \d+, largeur \d+, hauteur \d+\./)).toBeInTheDocument()
fireEvent.keyDown(tile, { key: 'ArrowRight', shiftKey: true })
expect(screen.getByText(/Progression :.*largeur 9/)).toBeInTheDocument()
})
it('Annuler restaure le layout d’entrée et Enregistrer conserve l’état React', () => {
const { container } = render(<DashboardGrid />)
const progression = () => container.querySelector('[data-testid="grid-item-progression"]') as HTMLElement
const initial = progression().style.width
fireEvent.click(screen.getByRole('button', { name: 'Modifier l’agencement' }))
fireEvent.keyDown(screen.getByRole('article', { name: 'Progression, tuile modifiable' }), { key: 'ArrowRight', shiftKey: true })
expect(progression().style.width).not.toBe(initial)
fireEvent.click(screen.getByRole('button', { name: 'Annuler' }))
expect(progression().style.width).toBe(initial)
fireEvent.click(screen.getByRole('button', { name: 'Modifier l’agencement' }))
fireEvent.keyDown(screen.getByRole('article', { name: 'Progression, tuile modifiable' }), { key: 'ArrowRight', shiftKey: true })
const edited = progression().style.width
fireEvent.click(screen.getByRole('button', { name: 'Enregistrer pour cette session' }))
expect(progression().style.width).toBe(edited)
expect(screen.getByRole('button', { name: 'Modifier l’agencement' })).toBeInTheDocument()
})
it('Réinitialiser restaure le défaut dans le brouillon', () => {
const { container } = render(<DashboardGrid />)
fireEvent.click(screen.getByRole('button', { name: 'Modifier l’agencement' }))
const tile = screen.getByRole('article', { name: 'Progression, tuile modifiable' })
fireEvent.keyDown(tile, { key: 'ArrowRight', shiftKey: true })
const modified = (container.querySelector('[data-testid="grid-item-progression"]') as HTMLElement).style.width
fireEvent.click(screen.getByRole('button', { name: 'Réinitialiser' }))
expect((container.querySelector('[data-testid="grid-item-progression"]') as HTMLElement).style.width).not.toBe(modified)
})
})

View file

@ -0,0 +1,145 @@
import { useMemo, useRef, useState, type KeyboardEvent } from 'react'
import GridLayout, { useContainerWidth, verticalCompactor, type Layout } from 'react-grid-layout'
import { EmptyState } from '../components/EmptyState'
import { Tile } from '../components/Tile'
import {
cloneLayout,
DEFAULT_DASHBOARD_LAYOUT,
fromGridLayout,
moveTileByKeyboard,
projectDashboardLayout,
resizeTileByKeyboard,
tileSize,
TILE_IDS,
toGridLayout,
type TileId,
type TileLayout,
} from './dashboardLayout'
const dashboardTiles: Readonly<Record<TileId, { title: string, eyebrow: string, message: string }>> = {
'next-session': { title: 'Prochaine séance', eyebrow: 'Planification', message: 'Aucune séance préparée disponible.' },
activity: { title: 'Activité', eyebrow: 'Régularité', message: 'Les données d’activité ne sont pas encore exposées.' },
progression: { title: 'Progression', eyebrow: 'Évolution', message: 'La métrique de progression reste à définir.' },
'last-session': { title: 'Dernière séance', eyebrow: 'Historique', message: 'Aucune séance terminée disponible.' },
'max-records': { title: 'Records / MAX', eyebrow: 'Performances', message: 'Aucun MAX mesuré disponible.' },
'muscle-distribution': { title: 'Répartition musculaire', eyebrow: 'Zones', message: 'La répartition par zone n’est pas encore disponible.' },
'cardio-recovery': { title: 'Cardio / récupération', eyebrow: 'Physiologie', message: 'Aucune donnée cardio ou récupération disponible.' },
}
function breakpoint(width: number): { columns: 12 | 6 | 1, rowHeight: number } {
if (width < 700) return { columns: 1, rowHeight: 56 }
if (width < 1100) return { columns: 6, rowHeight: 54 }
return { columns: 12, rowHeight: 52 }
}
export function DashboardGrid() {
const [layout, setLayout] = useState<TileLayout[]>(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT))
const [editing, setEditing] = useState(false)
const [announcement, setAnnouncement] = useState('')
const beforeEditing = useRef<TileLayout[]>(cloneLayout(DEFAULT_DASHBOARD_LAYOUT))
// The viewport-derived seed prevents a wide first frame from overflowing on
// phones before ResizeObserver has measured the actual grid container.
const initialWidth = Math.max(288, window.innerWidth - 32)
const { width, containerRef } = useContainerWidth({ initialWidth })
const effectiveWidth = width > 0 ? width : initialWidth
const view = breakpoint(effectiveWidth)
const desktop = view.columns === 12
const visibleLayout = useMemo(
() => projectDashboardLayout(layout, view.columns),
[layout, view.columns],
)
function enterEditMode() {
beforeEditing.current = cloneLayout(layout)
setEditing(true)
setAnnouncement('Mode modification activé. Utilisez les flèches pour déplacer une tuile et Maj plus flèche pour la redimensionner.')
}
function cancelEditing() {
setLayout(cloneLayout(beforeEditing.current))
setEditing(false)
setAnnouncement('Modifications annulées.')
}
function resetEditing() {
setLayout(cloneLayout(DEFAULT_DASHBOARD_LAYOUT))
setAnnouncement('Agencement par défaut restauré dans le brouillon.')
}
function saveEditing() {
setEditing(false)
setAnnouncement('Agencement appliqué pour cette session. Il reviendra à sa valeur par défaut après rechargement.')
}
function acceptLibraryLayout(next: Layout) {
if (!editing || !desktop) return
const converted = fromGridLayout(next)
if (converted !== null) setLayout(converted)
}
function onTileKeyDown(event: KeyboardEvent<HTMLElement>, id: TileId) {
if (!editing || !desktop || !event.key.startsWith('Arrow')) return
event.preventDefault()
const horizontal = event.key === 'ArrowLeft' ? -1 : event.key === 'ArrowRight' ? 1 : 0
const vertical = event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0
const next = event.shiftKey
? resizeTileByKeyboard(layout, id, horizontal, vertical)
: moveTileByKeyboard(layout, id, horizontal, vertical)
setLayout(next)
const tile = next.find((candidate) => candidate.id === id)
if (tile !== undefined) {
setAnnouncement(`${dashboardTiles[id].title} : colonne ${tile.x + 1}, ligne ${tile.y + 1}, largeur ${tile.width}, hauteur ${tile.height}.`)
}
}
const libraryLayout = toGridLayout(visibleLayout, editing && desktop, view.columns)
return (
<div className={`dashboard-layout${editing ? ' is-editing' : ''}`}>
<div className="layout-toolbar" aria-label="Agencement du Dashboard">
{!editing ? (
<button className="layout-action layout-action-primary" type="button" onClick={enterEditMode}>Modifier l’agencement</button>
) : (
<>
<p className="layout-help">Flèches : déplacer · Maj + flèches : redimensionner</p>
{!desktop && <p className="layout-breakpoint-note">Revenez sur un écran large pour modifier la grille canonique.</p>}
<button className="layout-action" type="button" onClick={cancelEditing}>Annuler</button>
<button className="layout-action" type="button" onClick={resetEditing}>Réinitialiser</button>
<button className="layout-action layout-action-primary" type="button" onClick={saveEditing}>Enregistrer pour cette session</button>
</>
)}
</div>
<p className="sr-only" aria-live="polite" aria-atomic="true">{announcement}</p>
<div ref={containerRef} className="dashboard-grid-frame" data-columns={view.columns}>
<GridLayout
width={effectiveWidth}
layout={libraryLayout}
gridConfig={{ cols: view.columns, rowHeight: view.rowHeight, margin: [16, 16], containerPadding: [0, 0], maxRows: 240 }}
dragConfig={{ enabled: editing && desktop, bounded: true, handle: '.tile-drag-handle', cancel: 'button, a, input, select, textarea', threshold: 3 }}
resizeConfig={{ enabled: editing && desktop, handles: editing && desktop ? ['e', 's', 'se'] : [] }}
compactor={verticalCompactor}
onLayoutChange={acceptLibraryLayout}
className="dashboard-grid"
>
{TILE_IDS.map((id) => {
const content = dashboardTiles[id]
const dimensions = visibleLayout.find((tile) => tile.id === id) ?? DEFAULT_DASHBOARD_LAYOUT[0]
return (
<div key={id} data-testid={`grid-item-${id}`}>
<Tile
title={content.title}
eyebrow={content.eyebrow}
size={tileSize(dimensions)}
editable={editing && desktop}
onKeyDown={(event) => onTileKeyDown(event, id)}
>
<EmptyState message={content.message} />
</Tile>
</div>
)
})}
</GridLayout>
</div>
</div>
)
}

View file

@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import {
DASHBOARD_COLUMNS,
DEFAULT_DASHBOARD_LAYOUT,
fromGridLayout,
moveTileByKeyboard,
projectDashboardLayout,
resizeTileByKeyboard,
TILE_CONSTRAINTS,
TILE_IDS,
toGridLayout,
validateDashboardLayout,
type TileLayout,
} from './dashboardLayout'
describe('contrat de layout Dashboard Trainlog', () => {
it('définit exactement sept IDs stables dans un layout par défaut valide', () => {
expect(DEFAULT_DASHBOARD_LAYOUT).toHaveLength(7)
expect(DEFAULT_DASHBOARD_LAYOUT.map((tile) => tile.id)).toEqual(TILE_IDS)
expect(validateDashboardLayout(DEFAULT_DASHBOARD_LAYOUT)).toEqual([])
})
it('respecte les contraintes de chaque tuile et les 12 colonnes', () => {
for (const tile of DEFAULT_DASHBOARD_LAYOUT) {
const constraint = TILE_CONSTRAINTS[tile.id]
expect(tile.width).toBeGreaterThanOrEqual(constraint.minWidth)
expect(tile.width).toBeLessThanOrEqual(constraint.maxWidth)
expect(tile.height).toBeGreaterThanOrEqual(constraint.minHeight)
expect(tile.height).toBeLessThanOrEqual(constraint.maxHeight)
expect(tile.x + tile.width).toBeLessThanOrEqual(DASHBOARD_COLUMNS)
}
})
it('convertit vers la bibliothèque et revient sans perte de données Trainlog', () => {
const library = toGridLayout(DEFAULT_DASHBOARD_LAYOUT, true)
expect(library.every((tile) => tile.isDraggable && tile.isResizable)).toBe(true)
expect(fromGridLayout(library)).toEqual(DEFAULT_DASHBOARD_LAYOUT)
})
it.each([
['ID inconnu', (layout: TileLayout[]) => { layout[0] = { ...layout[0], id: 'unknown' as TileLayout['id'] } }, 'unknown_id'],
['doublon', (layout: TileLayout[]) => { layout[1] = { ...layout[1], id: layout[0].id } }, 'duplicate_id'],
['dimension non entière', (layout: TileLayout[]) => { layout[0] = { ...layout[0], width: 4.5 } }, 'non_integer'],
['dimension hors contrainte', (layout: TileLayout[]) => { layout[0] = { ...layout[0], width: 1 } }, 'size_constraint'],
['position hors grille', (layout: TileLayout[]) => { layout[0] = { ...layout[0], x: 10 } }, 'grid_bounds'],
['chevauchement', (layout: TileLayout[]) => { layout[1] = { ...layout[1], x: 0 } }, 'overlap'],
])('rejette un layout invalide : %s', (_label, mutate, expected) => {
const layout = DEFAULT_DASHBOARD_LAYOUT.map((tile) => ({ ...tile }))
mutate(layout)
expect(validateDashboardLayout(layout).some((error) => error.startsWith(expected))).toBe(true)
})
it('projette tablette et téléphone sans modifier le canon desktop', () => {
const canonical = DEFAULT_DASHBOARD_LAYOUT.map((tile) => ({ ...tile }))
const tablet = projectDashboardLayout(canonical, 6)
const phone = projectDashboardLayout(canonical, 1)
expect(tablet.every((tile) => tile.x === 0 && tile.width <= 6)).toBe(true)
expect(phone.every((tile) => tile.x === 0 && tile.width === 1)).toBe(true)
expect(phone.map((tile) => tile.id)).toEqual(TILE_IDS)
expect(canonical).toEqual(DEFAULT_DASHBOARD_LAYOUT)
expect(projectDashboardLayout(canonical, 12)).toEqual(DEFAULT_DASHBOARD_LAYOUT)
})
it('déplace au clavier, résout la collision et compacte sans chevauchement', () => {
const moved = moveTileByKeyboard(DEFAULT_DASHBOARD_LAYOUT, 'last-session', -4, 0)
expect(validateDashboardLayout(moved)).toEqual([])
expect(moved).not.toEqual(DEFAULT_DASHBOARD_LAYOUT)
})
it('redimensionne horizontalement et verticalement en respectant min/max', () => {
let layout = resizeTileByKeyboard(DEFAULT_DASHBOARD_LAYOUT, 'progression', 1, 1)
expect(validateDashboardLayout(layout)).toEqual([])
const grown = layout.find((tile) => tile.id === 'progression')!
expect(grown.width).toBe(9)
expect(grown.height).toBe(6)
for (let count = 0; count < 20; count += 1) layout = resizeTileByKeyboard(layout, 'progression', 1, 1)
const bounded = layout.find((tile) => tile.id === 'progression')!
expect(bounded.width).toBe(TILE_CONSTRAINTS.progression.maxWidth)
expect(bounded.height).toBe(TILE_CONSTRAINTS.progression.maxHeight)
expect(validateDashboardLayout(layout)).toEqual([])
})
})

View file

@ -0,0 +1,175 @@
import { moveElement, verticalCompactor, type Layout as GridLayout } from 'react-grid-layout'
export const DASHBOARD_COLUMNS = 12
export const DASHBOARD_MAX_Y = 200
export const TILE_IDS = [
'next-session',
'activity',
'progression',
'last-session',
'max-records',
'muscle-distribution',
'cardio-recovery',
] as const
export type TileId = typeof TILE_IDS[number]
export interface TileLayout {
id: TileId
x: number
y: number
width: number
height: number
}
export interface TileConstraint {
minWidth: number
maxWidth: number
minHeight: number
maxHeight: number
}
export const TILE_CONSTRAINTS: Readonly<Record<TileId, TileConstraint>> = {
'next-session': { minWidth: 4, maxWidth: 8, minHeight: 3, maxHeight: 7 },
activity: { minWidth: 5, maxWidth: 12, minHeight: 3, maxHeight: 7 },
progression: { minWidth: 6, maxWidth: 12, minHeight: 4, maxHeight: 9 },
'last-session': { minWidth: 3, maxWidth: 7, minHeight: 3, maxHeight: 6 },
'max-records': { minWidth: 3, maxWidth: 8, minHeight: 3, maxHeight: 7 },
'muscle-distribution': { minWidth: 5, maxWidth: 12, minHeight: 4, maxHeight: 9 },
'cardio-recovery': { minWidth: 5, maxWidth: 12, minHeight: 4, maxHeight: 8 },
}
export const DEFAULT_DASHBOARD_LAYOUT: readonly TileLayout[] = [
{ id: 'next-session', x: 0, y: 0, width: 5, height: 4 },
{ id: 'activity', x: 5, y: 0, width: 7, height: 4 },
{ id: 'progression', x: 0, y: 4, width: 8, height: 5 },
{ id: 'last-session', x: 8, y: 4, width: 4, height: 3 },
{ id: 'max-records', x: 8, y: 7, width: 4, height: 3 },
{ id: 'muscle-distribution', x: 0, y: 9, width: 6, height: 5 },
{ id: 'cardio-recovery', x: 6, y: 10, width: 6, height: 4 },
] as const
const tileIdSet = new Set<string>(TILE_IDS)
function overlaps(a: TileLayout, b: TileLayout): boolean {
return a.x < b.x + b.width && a.x + a.width > b.x &&
a.y < b.y + b.height && a.y + a.height > b.y
}
/**
* CONTRACT: validation is owned by Trainlog and deliberately does not trust
* react-grid-layout. The same rules can therefore guard the future persisted
* dashboard-layout-v1.json without exposing a library-specific structure.
*/
export function validateDashboardLayout(value: readonly TileLayout[]): string[] {
const errors: string[] = []
const seen = new Set<string>()
if (value.length !== TILE_IDS.length) errors.push('tile_count')
value.forEach((tile, index) => {
if (!tileIdSet.has(tile.id)) errors.push(`unknown_id:${String(tile.id)}`)
if (seen.has(tile.id)) errors.push(`duplicate_id:${tile.id}`)
seen.add(tile.id)
const values = [tile.x, tile.y, tile.width, tile.height]
if (!values.every(Number.isInteger)) errors.push(`non_integer:${tile.id}`)
if (tile.x < 0 || tile.y < 0) errors.push(`negative_position:${tile.id}`)
if (tile.y > DASHBOARD_MAX_Y) errors.push(`y_limit:${tile.id}`)
const constraint = TILE_CONSTRAINTS[tile.id]
if (constraint !== undefined && (
tile.width < constraint.minWidth || tile.width > constraint.maxWidth ||
tile.height < constraint.minHeight || tile.height > constraint.maxHeight
)) errors.push(`size_constraint:${tile.id}`)
if (tile.x + tile.width > DASHBOARD_COLUMNS) errors.push(`grid_bounds:${tile.id}`)
for (let other = index + 1; other < value.length; other += 1) {
if (overlaps(tile, value[other])) errors.push(`overlap:${tile.id}:${value[other].id}`)
}
})
for (const id of TILE_IDS) {
if (!seen.has(id)) errors.push(`missing_id:${id}`)
}
return errors
}
export function cloneLayout(layout: readonly TileLayout[]): TileLayout[] {
return layout.map((tile) => ({ ...tile }))
}
export function toGridLayout(layout: readonly TileLayout[], editable: boolean, columns: 12 | 6 | 1 = 12): GridLayout {
return layout.map((tile) => {
const constraint = TILE_CONSTRAINTS[tile.id]
return {
i: tile.id,
x: tile.x,
y: tile.y,
w: tile.width,
h: tile.height,
minW: columns === 12 ? constraint.minWidth : tile.width,
maxW: columns === 12 ? constraint.maxWidth : tile.width,
minH: columns === 12 ? constraint.minHeight : tile.height,
maxH: columns === 12 ? constraint.maxHeight : tile.height,
isDraggable: editable,
isResizable: editable,
}
})
}
export function fromGridLayout(layout: GridLayout): TileLayout[] | null {
const converted = layout.map((tile) => ({
id: tile.i as TileId,
x: tile.x,
y: tile.y,
width: tile.w,
height: tile.h,
}))
return validateDashboardLayout(converted).length === 0 ? converted : null
}
/**
* INVARIANT: responsive layouts are projections only. Stable desktop order is
* preserved and the projected coordinates are never written to canonical state.
*/
export function projectDashboardLayout(layout: readonly TileLayout[], columns: 12 | 6 | 1): TileLayout[] {
if (columns === 12) return cloneLayout(layout)
const ordered = [...layout].sort((a, b) => a.y - b.y || a.x - b.x || TILE_IDS.indexOf(a.id) - TILE_IDS.indexOf(b.id))
let y = 0
return ordered.map((tile) => {
const projectedWidth = columns === 1 ? 1 : Math.min(columns, Math.max(3, Math.round(tile.width / 2)))
const projectedHeight = columns === 1 ? Math.max(3, Math.min(6, tile.height)) : tile.height
const projected = { ...tile, x: 0, y, width: projectedWidth, height: projectedHeight }
y += projectedHeight
return projected
})
}
export type TileSize = 'compact' | 'medium' | 'large'
export function tileSize(tile: TileLayout): TileSize {
if (tile.width >= 8 || tile.height >= 6) return 'large'
if (tile.width >= 5 || tile.height >= 4) return 'medium'
return 'compact'
}
function constrainGridLayout(layout: GridLayout): GridLayout {
return layout.map((item) => {
const id = item.i as TileId
const constraint = TILE_CONSTRAINTS[id]
const w = Math.min(constraint.maxWidth, Math.max(constraint.minWidth, item.w))
const h = Math.min(constraint.maxHeight, Math.max(constraint.minHeight, item.h))
return { ...item, w, h, x: Math.min(Math.max(0, item.x), DASHBOARD_COLUMNS - w), y: Math.max(0, item.y) }
})
}
export function moveTileByKeyboard(layout: readonly TileLayout[], id: TileId, dx: number, dy: number): TileLayout[] {
const grid = toGridLayout(layout, true)
const item = grid.find((candidate) => candidate.i === id)
if (item === undefined) return cloneLayout(layout)
const moved = moveElement(grid, item, item.x + dx, Math.max(0, item.y + dy), true, false, 'vertical', DASHBOARD_COLUMNS, false)
return fromGridLayout(verticalCompactor.compact(moved, DASHBOARD_COLUMNS)) ?? cloneLayout(layout)
}
export function resizeTileByKeyboard(layout: readonly TileLayout[], id: TileId, dw: number, dh: number): TileLayout[] {
const grid = toGridLayout(layout, true).map((item) => item.i === id ? { ...item, w: item.w + dw, h: item.h + dh } : item)
return fromGridLayout(verticalCompactor.compact(constrainGridLayout(grid), DASHBOARD_COLUMNS)) ?? cloneLayout(layout)
}

View file

@ -1,6 +1,8 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './app/App'
import 'react-grid-layout/css/styles.css'
import 'react-resizable/css/styles.css'
import './theme/tokens.css'
import './theme/app.css'

View file

@ -1,15 +1,4 @@
import { EmptyState } from '../components/EmptyState'
import { Tile } from '../components/Tile'
const dashboardTiles = [
['Prochaine séance', 'Planification', 'Aucune séance préparée disponible.'],
['Activité', 'Régularité', 'Les données d’activité ne sont pas encore exposées.'],
['Progression', 'Évolution', 'La métrique de progression reste à définir.'],
['Dernière séance', 'Historique', 'Aucune séance terminée disponible.'],
['Records / MAX', 'Performances', 'Aucun MAX mesuré disponible.'],
['Répartition musculaire', 'Zones', 'La répartition par zone n’est pas encore disponible.'],
['Cardio / récupération', 'Physiologie', 'Aucune donnée cardio ou récupération disponible.'],
] as const
import { DashboardGrid } from '../dashboard/DashboardGrid'
export function DashboardPage() {
return (
@ -17,14 +6,7 @@ export function DashboardPage() {
<div className="page-heading">
<div><p className="eyebrow">VUE D’ENSEMBLE</p><h1 id="page-title">Dashboard</h1></div>
</div>
<div className="tile-grid dashboard-preview">
{dashboardTiles.map(([title, eyebrow, message], index) => (
<Tile key={title} title={title} eyebrow={eyebrow}
className={index === 0 || index === 2 ? 'tile-wide' : ''}>
<EmptyState message={message} />
</Tile>
))}
</div>
<DashboardGrid />
</section>
)
}

View file

@ -3,6 +3,7 @@ import { afterEach, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
Object.defineProperty(window, 'scrollTo', { value: vi.fn(), writable: true })
Object.defineProperty(window, 'innerWidth', { value: 1440, writable: true })
afterEach(() => {
cleanup()

View file

@ -47,6 +47,34 @@ h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 0; font-size: clamp(2.25rem, 5vw, 4.5rem); line-height: .96; letter-spacing: -.055em; font-weight: 720; }
h2 { color: var(--text); font-size: 1.08rem; letter-spacing: -.02em; }
.page-intro { max-width: 34rem; margin-bottom: .15rem; color: var(--subtext-0); font-size: clamp(.95rem, 1.4vw, 1.08rem); line-height: 1.65; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.dashboard-layout { min-width: 0; }
.layout-toolbar { min-height: 2.7rem; margin: -1rem 0 1rem; display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: .65rem; }
.layout-action { min-height: 2.45rem; padding: .58rem .82rem; color: var(--subtext-0); background: var(--surface-0); border: 1px solid rgb(108 112 134 / 48%); border-radius: .55rem; font: 650 .74rem var(--font-mono); cursor: pointer; transition: color 140ms ease, border-color 140ms ease, background 140ms ease; }
.layout-action:hover { color: var(--text); border-color: var(--surface-2); background: var(--surface-1); }
.layout-action:focus-visible, .tile:focus-visible { outline: 2px solid var(--lavender); outline-offset: 3px; }
.layout-action-primary { color: var(--crust); background: var(--lavender); border-color: transparent; }
.layout-action-primary:hover { color: var(--crust); background: var(--blue); border-color: transparent; }
.layout-help, .layout-breakpoint-note { margin: 0 auto 0 0; color: var(--subtext-0); font: 550 .7rem/1.4 var(--font-mono); }
.layout-breakpoint-note { flex-basis: 100%; color: var(--yellow); }
.dashboard-grid-frame { min-width: 0; }
.dashboard-grid { min-width: 0; }
.dashboard-grid > .react-grid-item { min-width: 0; }
.dashboard-grid .tile { width: 100%; height: 100%; min-height: 0; }
.dashboard-layout.is-editing .dashboard-grid-frame { margin: -.5rem; padding: .5rem; border-radius: calc(var(--radius) + .3rem); background-image: radial-gradient(circle, rgb(180 190 254 / 16%) 1px, transparent 1px); background-size: 1.5rem 1.5rem; }
.dashboard-layout.is-editing .tile { border-color: rgb(180 190 254 / 38%); box-shadow: 0 1.2rem 3rem rgb(0 0 0 / 16%), inset 0 0 0 1px rgb(180 190 254 / 5%); }
.dashboard-layout.is-editing .tile-drag-handle { cursor: grab; }
.dashboard-layout.is-editing .react-draggable-dragging .tile-drag-handle { cursor: grabbing; }
.dashboard-grid .react-grid-placeholder { background: var(--lavender); border-radius: var(--radius); opacity: .16; }
.dashboard-grid .react-grid-item > .react-resizable-handle { display: none; width: 1.2rem; height: 1.2rem; opacity: .8; }
.dashboard-layout.is-editing .dashboard-grid .react-grid-item > .react-resizable-handle { display: block; }
.dashboard-grid .react-resizable-handle::after { width: .45rem; height: .45rem; border-color: var(--lavender); }
.dashboard-grid .react-resizable-handle-e { top: 50%; right: -.15rem; transform: translateY(-50%) rotate(135deg); }
.dashboard-grid .react-resizable-handle-s { bottom: -.15rem; left: 50%; transform: translateX(-50%) rotate(45deg); }
.tile-compact .empty-glyph { display: none; }
.tile-compact .empty-state p { margin-top: 0; font-size: .82rem; }
.tile-large .tile-content { padding: 1rem; }
.tile-grid { min-width: 0; display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 1rem; }
.tile { min-width: 0; grid-column: span 4; min-height: 17rem; padding: clamp(1.15rem, 2vw, 1.6rem); display: flex; flex-direction: column; background: var(--base); border: 1px solid rgb(69 71 90 / 38%); border-radius: var(--radius); box-shadow: 0 1.2rem 3rem rgb(0 0 0 / 12%); }
@ -91,6 +119,8 @@ h2 { color: var(--text); font-size: 1.08rem; letter-spacing: -.02em; }
.tile, .tile-wide, .tile-feature, .principle-panel { width: 100%; grid-column: auto; }
.tile { min-height: 14rem; }
.principle-panel { min-height: 17rem; }
.layout-toolbar { justify-content: flex-start; }
.layout-help { flex-basis: 100%; }
.status-footer { grid-template-columns: 1fr 1fr; gap: .8rem 1.25rem; }
.status-zone { grid-column: 1 / -1; }
.backend-state { grid-column: 1 / -1; justify-self: start; }
@ -105,4 +135,5 @@ h2 { color: var(--text); font-size: 1.08rem; letter-spacing: -.02em; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
.react-grid-item { transition: none !important; }
}