From 3973a60233b8b872e58241573a721ac9080701c6 Mon Sep 17 00:00:00 2001 From: fy59 Date: Wed, 16 Sep 2026 13:56:21 +0200 Subject: [PATCH] feat(web): freeze interactive dashboard grid --- docs/architecture.md | 20 +-- docs/current_state.md | 11 ++ docs/design/web_dashboard_grid_v1.md | 86 +++++++++++ docs/roadmap.md | 12 +- docs/tests.md | 9 ++ tui/meson.build | 2 + web/package-lock.json | 112 +++++++++++++- web/package.json | 3 +- web/src/components/Tile.tsx | 18 ++- web/src/dashboard/DashboardGrid.test.tsx | 64 ++++++++ web/src/dashboard/DashboardGrid.tsx | 145 ++++++++++++++++++ web/src/dashboard/dashboardLayout.test.ts | 82 ++++++++++ web/src/dashboard/dashboardLayout.ts | 175 ++++++++++++++++++++++ web/src/main.tsx | 2 + web/src/routes/DashboardPage.tsx | 22 +-- web/src/test/setup.ts | 1 + web/src/theme/app.css | 31 ++++ 17 files changed, 753 insertions(+), 42 deletions(-) create mode 100644 docs/design/web_dashboard_grid_v1.md create mode 100644 web/src/dashboard/DashboardGrid.test.tsx create mode 100644 web/src/dashboard/DashboardGrid.tsx create mode 100644 web/src/dashboard/dashboardLayout.test.ts create mode 100644 web/src/dashboard/dashboardLayout.ts diff --git a/docs/architecture.md b/docs/architecture.md index 4486d25..ed3097f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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: diff --git a/docs/current_state.md b/docs/current_state.md index bb63c7d..34ab0bb 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -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. diff --git a/docs/design/web_dashboard_grid_v1.md b/docs/design/web_dashboard_grid_v1.md new file mode 100644 index 0000000..c204b4d --- /dev/null +++ b/docs/design/web_dashboard_grid_v1.md @@ -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: + +- +- +- diff --git a/docs/roadmap.md b/docs/roadmap.md index 71934ec..c0887ac 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 ``` diff --git a/docs/tests.md b/docs/tests.md index 4ca430c..a49c84f 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -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: diff --git a/tui/meson.build b/tui/meson.build index fa569b9..1c1b632 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -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', diff --git a/web/package-lock.json b/web/package-lock.json index a568e66..0a104a4 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 2f32dc0..187417d 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/components/Tile.tsx b/web/src/components/Tile.tsx index 0881dcb..f46b74a 100644 --- a/web/src/components/Tile.tsx +++ b/web/src/components/Tile.tsx @@ -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 } -export function Tile({ title, eyebrow, children, className = '' }: TileProps) { +export function Tile({ title, eyebrow, children, className = '', size = 'medium', editable = false, onKeyDown }: TileProps) { return ( -
-
+
+
{eyebrow &&

{eyebrow}

}

{title}

diff --git a/web/src/dashboard/DashboardGrid.test.tsx b/web/src/dashboard/DashboardGrid.test.tsx new file mode 100644 index 0000000..34a2693 --- /dev/null +++ b/web/src/dashboard/DashboardGrid.test.tsx @@ -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() + 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() + 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() + 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() + 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() + 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) + }) +}) diff --git a/web/src/dashboard/DashboardGrid.tsx b/web/src/dashboard/DashboardGrid.tsx new file mode 100644 index 0000000..91da14e --- /dev/null +++ b/web/src/dashboard/DashboardGrid.tsx @@ -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> = { + '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(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT)) + const [editing, setEditing] = useState(false) + const [announcement, setAnnouncement] = useState('') + const beforeEditing = useRef(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, 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 ( +
+
+ {!editing ? ( + + ) : ( + <> +

Flèches : déplacer · Maj + flèches : redimensionner

+ {!desktop &&

Revenez sur un écran large pour modifier la grille canonique.

} + + + + + )} +
+

{announcement}

+
+ + {TILE_IDS.map((id) => { + const content = dashboardTiles[id] + const dimensions = visibleLayout.find((tile) => tile.id === id) ?? DEFAULT_DASHBOARD_LAYOUT[0] + return ( +
+ onTileKeyDown(event, id)} + > + + +
+ ) + })} +
+
+
+ ) +} diff --git a/web/src/dashboard/dashboardLayout.test.ts b/web/src/dashboard/dashboardLayout.test.ts new file mode 100644 index 0000000..4ec665f --- /dev/null +++ b/web/src/dashboard/dashboardLayout.test.ts @@ -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([]) + }) +}) diff --git a/web/src/dashboard/dashboardLayout.ts b/web/src/dashboard/dashboardLayout.ts new file mode 100644 index 0000000..133a005 --- /dev/null +++ b/web/src/dashboard/dashboardLayout.ts @@ -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> = { + '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(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() + 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) +} diff --git a/web/src/main.tsx b/web/src/main.tsx index db0946a..7d14fc4 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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' diff --git a/web/src/routes/DashboardPage.tsx b/web/src/routes/DashboardPage.tsx index cdcc9f3..897c4b7 100644 --- a/web/src/routes/DashboardPage.tsx +++ b/web/src/routes/DashboardPage.tsx @@ -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() {

VUE D’ENSEMBLE

Dashboard

-
- {dashboardTiles.map(([title, eyebrow, message], index) => ( - - - - ))} -
+ ) } diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index a3f7252..cd796f8 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -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() diff --git a/web/src/theme/app.css b/web/src/theme/app.css index ff85ae6..23dbedd 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -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; } }