feat(web): embed frontend shell

This commit is contained in:
fy59 2026-09-16 10:07:18 +02:00
parent 7597a167f8
commit 188aba2df7
38 changed files with 3643 additions and 35 deletions

3
.gitignore vendored
View file

@ -3,6 +3,9 @@ build/
builddir/
meson-logs/
meson-private/
/web/node_modules/
/web/dist/
/web/coverage/
# Android / Gradle
.gradle/

View file

@ -169,6 +169,27 @@ Desktop builds require:
headers;
- Python 3 for generated sources, validators, import/export helpers, and tests.
The embedded Web frontend additionally uses Node and npm at build time only.
Prepare its exact locked dependencies explicitly, then enable the release
frontend build:
```bash
cd web
npm ci
npm run typecheck
npm test
cd ..
meson setup build -Dweb=enabled
meson compile -C build
```
Meson never runs `npm install` or downloads packages. The default `web=auto`
embeds the frontend when Node, npm, and the prepared `web/node_modules` are
available; otherwise it preserves a desktop-only build and `trainlog -w`
reports that frontend support is absent. Release builds must use
`-Dweb=enabled`. `-Dweb=disabled` is the explicit desktop-only choice. Node,
npm, `node_modules`, and `web/dist` are not runtime dependencies.
Android builds require JDK 17, an Android SDK supporting the configured API
levels, and the Gradle wrapper committed in this repository. People installing
the APK do not need Java, Gradle, or the Android SDK.
@ -231,7 +252,8 @@ As of 2026-09-15:
- `APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2`;
- `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` pending a real Drive plus
Android-triggered bidirectional smoke test.
- `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`.
- `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` and
`TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`.
The latest executable result belongs in
[current state](docs/current_state.md), not in multiple README narratives.

View file

@ -207,8 +207,8 @@ the process event-loop thread. A concrete IPv4 `sockaddr_in` binds only
handlers only set a `sig_atomic_t` flag. The loop then stops libmicrohttpd,
returns to the process owner and lets it close the database.
The only implemented route is read-only `GET /api/v1/health`; `/` and every
unknown route return 404, and other methods on health return 405. The adapter
The read-only technical route remains `GET /api/v1/health`; unknown API routes
return JSON 404 and never fall through to the SPA. The adapter
bounds concurrent/per-IP connections at 32, listen backlog at 32, per-connection
memory at 16 KiB, decoded request headers at 8 KiB, request bodies at 4 KiB and
idle time at 10 seconds. It validates `Host` against `127.0.0.1` and the active
@ -217,6 +217,29 @@ and no permissive CORS header. Assets, telemetry and remote resources are
absent. Before the first mutation route, origin validation and explicit
CSRF protection remain mandatory in addition to these transport bounds.
`WEB_FRONTEND_SHELL_V1=PASS/FROZEN` adds the sibling `web/` React 19,
TypeScript and Vite application. A History API router owned by Trainlog handles
the five fixed shell destinations without another runtime dependency. `/`,
`/analyse`, `/programmes`, `/seances`, and `/exercices` receive the embedded
`index.html`; exact generated asset URLs receive only their manifest bytes.
Unknown assets and traversal attempts are 404. `HEAD` is supported for UI
assets; other asset methods are 405. API dispatch remains separate.
Vite writes only into the Meson build directory. A bounded deterministic
Python generator sorts at most 128 known files, rejects files over 4 MiB or a
manifest over 16 MiB, assigns MIME and SHA-256 ETag metadata, then emits static
C arrays linked into `trainlog`. Runtime serving performs exact URL lookup and
never reads a filesystem path. HTML uses `no-cache`; hash-named Vite assets use
one-year immutable caching. The CSP permits only same-origin scripts, styles,
connections and images, with no `unsafe-inline` or `unsafe-eval`.
The Meson feature option `web=auto|enabled|disabled` separates provisioning
from compilation. Meson never installs npm packages. `auto` embeds assets only
when Node, npm and prepared `web/node_modules` exist; `enabled` makes those
build prerequisites mandatory and is required for releases; `disabled`
produces a desktop-only binary whose Web launch fails explicitly. An embedded
binary requires neither Node/npm nor `web/dist` at runtime.
Every Web page retains one application shell:
- Header navigation: Dashboard, Analyse, Programmes, Séances, Exercices;
@ -235,7 +258,11 @@ the architecture. React, TypeScript, Vite, and Apache ECharts are the intended
frontend stack. Node and npm are build-only dependencies; installed Trainlog
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 grid library remains open pending a dedicated spike.
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.
The Dashboard is a canonical 12-column grid containing exactly seven V1 tiles:

View file

@ -11,9 +11,10 @@ closed incidents belong in [reviews](reviews/) and the
Android is the field companion: it captures training and body data, preserves
the active draft, receives AI proposals, triggers synchronization, and shows
quick summaries. The C17/Notcurses TUI is the administration, inspection,
maintenance, import/export, and technical-tooling surface. The documented but
not yet implemented local Web sibling will own analysis, visualization, and
program/session preparation through Trainlog Core.
maintenance, import/export, and technical-tooling surface. The local Web
sibling now provides its embedded application shell; its future business
surfaces will own analysis, visualization, and program/session preparation
through Trainlog Core.
Trainlog Core owns business truth. The desktop SQLite database remains the
canonical local source of truth and long-term history; no interface owns a
@ -40,6 +41,7 @@ parallel implementation of its rules.
| Dashboard Core read model | `WEB_DASHBOARD_CORE_READ_MODEL_V1=PASS/FROZEN` |
| 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` |
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
Desktop and Android schema numbers are independent. Neither changes the frozen
@ -137,10 +139,12 @@ the result. No Dashboard HTTP endpoint exists yet.
`TRAINLOG_WEB_V1` architecture, API independence, local-network boundary,
browser shell, Dashboard, layout ownership, build/runtime separation, and
security invariants are canonical. The CLI and local HTTP infrastructure are
implemented: `-w`/`--web`, optional `--port`, exact IPv4 loopback binding and
the technical `/api/v1/health` route. No frontend, embedded asset, Dashboard
route, business mutation, browser launch or layout persistence exists yet.
security invariants are canonical. The CLI/local HTTP infrastructure and
embedded frontend shell are implemented: `-w`/`--web`, optional `--port`, exact
IPv4 loopback binding, `/api/v1/health`, React navigation and the persistent
Header/Body/Footer shell. Production assets are generated from the npm lockfile
and linked into the binary. No Dashboard business route, real metric, grid
engine, business mutation, browser launch or layout persistence exists yet.
## Data semantics
@ -176,6 +180,11 @@ The CLI/HTTP infrastructure tranche passes **67/67 normal Meson tests**, adding
pure CLI parsing, process-level help/version checks and an isolated loopback
HTTP lifecycle/security suite. The same suite is required under ASan/UBSan.
The frontend shell tranche passes **70/70 normal and ASan/UBSan Meson tests**,
including TypeScript typecheck, 10 Vitest component tests, deterministic asset
generation bounds, real embedded-asset HTTP coverage, SPA/API separation and
runtime execution without Node/npm or `web/dist`.
`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
@ -188,8 +197,9 @@ smoke test are not automated. The latter is why
## Active limitations
- `TRAINLOG_WEB_V1` has only its Core prerequisite and CLI/HTTP infrastructure;
the next cursor is the frontend shell. No Dashboard HTTP route exists.
- `TRAINLOG_WEB_V1` has its Core prerequisite, CLI/HTTP infrastructure and
embedded frontend shell. The next cursor begins the explicitly partitioned
Dashboard V1 work; no Dashboard HTTP route or real Web metric exists yet.
- `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

@ -25,19 +25,20 @@ Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are
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 exists, but no frontend or business endpoint exists.
CLI/HTTP infrastructure and embedded frontend shell exist, but no Dashboard
business endpoint or real Web metric exists.
## Current cursor
```text
CURRENT_OPERATIONAL_CURSOR=WEB_FRONTEND_SHELL_V1
CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_V1
```
`WEB_CLI_HTTP_INFRASTRUCTURE_V1=PASS/FROZEN`. `trainlog -w` and `--web` now
dispatch through the common process owner to a bounded, single-threaded
loopback HTTP adapter after one database open. Only `/api/v1/health` exists.
The next slice is the frontend shell and embedded production assets; it does
not authorize Dashboard business routes or new metrics.
`WEB_FRONTEND_SHELL_V1=PASS/FROZEN`. `trainlog -w` serves the embedded React,
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.
## TRAINLOG_WEB_V1 implementation gate
@ -48,7 +49,7 @@ WEB_DASHBOARD_CHARACTERIZATION_V1
-> WEB_DASHBOARD_CORE_READ_MODEL_V1 [PASS/FROZEN]
-> WEB_TUI_READ_MODEL_ADOPTION_V1 [PASS/FROZEN]
-> WEB_CLI_HTTP_INFRASTRUCTURE_V1 [PASS/FROZEN]
-> WEB_FRONTEND_SHELL_V1
-> WEB_FRONTEND_SHELL_V1 [PASS/FROZEN]
-> WEB_DASHBOARD_V1
```
@ -64,6 +65,20 @@ single serialized Core/SQLite owner, build-only Node/npm, and no change to
business SQLite or frozen exchange formats. Dashboard Activité and Progression
mathematics remain specification gates before their read models.
`WEB_DASHBOARD_V1` must advance through independently reviewable slices:
```text
missing Dashboard API/read models
-> grid-library spike and bounded grid engine
-> separately versioned layout persistence
-> real-data tile delivery
-> real-data visualization delivery
```
The slice order may be refined by the next contract, but it must preserve the
existing Core ownership, keep visual layout outside business SQLite and frozen
formats, and specify Activité/Progression before implementing those metrics.
## Next
### Gym catalog V1

View file

@ -27,6 +27,34 @@ SIGTERM shutdown and explicit failure on an already occupied port. It never
uses the Internet. A separate real-launch smoke uses an isolated XDG directory,
performs the health request and stops the production binary with SIGINT.
`WEB_FRONTEND_SHELL_V1` adds TypeScript typechecking and 10 Vitest/jsdom
component tests for the persistent shell, five client destinations, active
navigation, browser history, unavailable factual fields, successful/failed/
invalid health responses and accessible landmarks. Production Vite output is
then generated and embedded for the C HTTP test, which additionally verifies
HTML root and SPA routes, JS/CSS MIME, HTML and immutable cache policies,
SHA-256 ETag, strict CSP, HEAD, unknown assets, traversal rejection and the
invariant that unknown `/api/` paths never receive `index.html`.
The generator contract test proves byte-for-byte deterministic output, stable
path ordering and the 128-asset bound. Runtime smoke coverage starts the linked
binary with Node/npm absent from `PATH`, removes any dependency on `web/dist`,
and checks root, health and SIGINT. A separate `-Dweb=disabled` build verifies
the explicit diagnostic instead of pretending that a frontend exists.
Frontend preparation and direct validation are:
```bash
cd web
npm ci
npm run typecheck
npm test
npm run build
```
Release-oriented Meson builds must use `-Dweb=enabled`; Meson never provisions
or downloads npm dependencies.
## 2. Frozen Trainlog JSON v1
Run:

6
meson_options.txt Normal file
View file

@ -0,0 +1,6 @@
option(
'web',
type: 'feature',
value: 'auto',
description: 'Build and embed the Trainlog Web frontend',
)

View file

@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Build the locked Trainlog frontend into a Meson-owned directory."""
from __future__ import annotations
import pathlib
import shutil
import subprocess
import sys
def main() -> int:
if len(sys.argv) != 5:
raise SystemExit("usage: build_web_frontend.py NPM WEB_ROOT DIST STAMP")
npm = pathlib.Path(sys.argv[1])
web_root = pathlib.Path(sys.argv[2]).resolve()
dist = pathlib.Path(sys.argv[3]).resolve()
stamp = pathlib.Path(sys.argv[4]).resolve()
if not (web_root / "package-lock.json").is_file():
raise SystemExit("package-lock.json absent")
if not (web_root / "node_modules").is_dir():
raise SystemExit("web/node_modules absent; exécutez 'npm ci' dans web/")
if dist.exists():
shutil.rmtree(dist)
subprocess.run(
[str(npm), "run", "build", "--", "--outDir", str(dist), "--emptyOutDir"],
cwd=web_root,
check=True,
)
if not (dist / "index.html").is_file():
raise SystemExit("Vite n'a pas produit index.html")
stamp.write_text("trainlog-web-build-v1\n", encoding="ascii")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Generate a deterministic, bounded C manifest from a Vite dist tree."""
from __future__ import annotations
import hashlib
import pathlib
import sys
MAX_ASSETS = 128
MAX_ASSET_BYTES = 4 * 1024 * 1024
MAX_TOTAL_BYTES = 16 * 1024 * 1024
MIME = {
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".ico": "image/x-icon",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
}
def c_string(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def byte_array(name: str, data: bytes) -> str:
lines = []
for offset in range(0, len(data), 12):
chunk = data[offset : offset + 12]
lines.append(" " + ", ".join(f"0x{value:02x}" for value in chunk) + ",")
return f"static const unsigned char {name}[] = {{\n" + "\n".join(lines) + "\n};\n"
def main() -> int:
if len(sys.argv) != 3:
raise SystemExit("usage: generate_web_assets.py DIST OUTPUT_C")
root = pathlib.Path(sys.argv[1]).resolve()
output = pathlib.Path(sys.argv[2])
files = sorted(path for path in root.rglob("*") if path.is_file())
if not files or len(files) > MAX_ASSETS:
raise SystemExit(f"nombre d'assets invalide: {len(files)}")
assets: list[tuple[str, str, bytes, str, bool]] = []
total = 0
for path in files:
relative = path.relative_to(root)
if any(part in ("", ".", "..") for part in relative.parts):
raise SystemExit(f"chemin d'asset invalide: {relative}")
mime = MIME.get(path.suffix.lower())
if mime is None:
raise SystemExit(f"type d'asset non autorisé: {relative}")
data = path.read_bytes()
if len(data) > MAX_ASSET_BYTES:
raise SystemExit(f"asset trop grand: {relative}")
total += len(data)
if total > MAX_TOTAL_BYTES:
raise SystemExit("taille totale des assets dépassée")
url = "/" + relative.as_posix()
etag = '"sha256-' + hashlib.sha256(data).hexdigest() + '"'
immutable = relative.parts[0] == "assets"
assets.append((url, mime, data, etag, immutable))
if not any(url == "/index.html" for url, *_ in assets):
raise SystemExit("index.html absent du manifeste")
parts = [
"/* Generated deterministically by tools/generate_web_assets.py. */\n",
'#include "web_assets.h"\n\n',
"#include <string.h>\n\n",
]
for index, (_, _, data, _, _) in enumerate(assets):
parts.append(byte_array(f"trainlog_web_data_{index}", data))
parts.append("\n")
parts.append("static const TrainlogWebAsset trainlog_web_assets[] = {\n")
for index, (url, mime, data, etag, immutable) in enumerate(assets):
parts.append(
f" {{{c_string(url)}, {c_string(mime)}, trainlog_web_data_{index}, "
f"{len(data)}U, {c_string(etag)}, {'true' if immutable else 'false'}}},\n"
)
parts.append("};\n\n")
parts.append(
"bool trainlog_web_assets_available(void) { return true; }\n\n"
"const TrainlogWebAsset *trainlog_web_asset_find(const char *path)\n"
"{\n"
" size_t index;\n"
" if (path == NULL) return NULL;\n"
" for (index = 0U; index < sizeof(trainlog_web_assets) /\n"
" sizeof(trainlog_web_assets[0]); ++index)\n"
" if (strcmp(path, trainlog_web_assets[index].path) == 0)\n"
" return &trainlog_web_assets[index];\n"
" return NULL;\n"
"}\n\n"
"const TrainlogWebAsset *trainlog_web_index_asset(void)\n"
"{\n"
" return trainlog_web_asset_find(\"/index.html\");\n"
"}\n"
)
output.write_text("".join(parts), encoding="utf-8", newline="\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Contract tests for the bounded deterministic Web asset generator."""
from __future__ import annotations
import pathlib
import subprocess
import sys
import tempfile
def run(generator: pathlib.Path, root: pathlib.Path, output: pathlib.Path) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(generator), str(root), str(output)],
check=False,
text=True,
capture_output=True,
)
def main() -> int:
if len(sys.argv) != 2:
raise SystemExit("usage: test_generate_web_assets.py GENERATOR")
generator = pathlib.Path(sys.argv[1]).resolve()
with tempfile.TemporaryDirectory(prefix="trainlog-web-assets-") as temporary:
base = pathlib.Path(temporary)
dist = base / "dist"
(dist / "assets").mkdir(parents=True)
(dist / "index.html").write_text("<script src='/assets/z.js'></script>", encoding="utf-8")
(dist / "assets" / "z.js").write_text("export const z=1;", encoding="utf-8")
(dist / "assets" / "a.css").write_text(":root{color:#cdd6f4}", encoding="utf-8")
first = base / "first.c"
second = base / "second.c"
if run(generator, dist, first).returncode != 0 or run(generator, dist, second).returncode != 0:
return 1
first_bytes = first.read_bytes()
if first_bytes != second.read_bytes():
return 1
text = first_bytes.decode("utf-8")
if text.index('"/assets/a.css"') > text.index('"/assets/z.js"'):
return 1
excessive = base / "excessive"
excessive.mkdir()
(excessive / "index.html").write_text("ok", encoding="utf-8")
for index in range(128):
(excessive / f"asset-{index:03d}.js").write_text("0", encoding="ascii")
if run(generator, excessive, base / "excessive.c").returncode == 0:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,4 +1,5 @@
cc = meson.get_compiler('c')
fs = import('fs')
sqlite3_dep = dependency('sqlite3', required: true)
uuid_dep = dependency('uuid', required: true)
@ -16,6 +17,86 @@ endif
m_dep = cc.find_library('m', required: true)
trainlog_include = include_directories('include')
trainlog_private_include = include_directories('src')
web_option = get_option('web')
node = find_program('node', required: web_option.enabled())
npm = find_program('npm', required: web_option.enabled())
web_dependencies_ready = fs.is_dir(meson.project_source_root() / 'web/node_modules')
if web_option.enabled() and not web_dependencies_ready
error('Web enabled but web/node_modules is absent; run npm ci in web/')
endif
web_assets_enabled = not web_option.disabled() and node.found() and npm.found() and web_dependencies_ready
if web_option.auto() and not web_assets_enabled
warning('Web frontend disabled in auto mode; install Node/npm and run npm ci in web/')
endif
if web_assets_enabled
frontend_sources = files(
'../web/package.json',
'../web/package-lock.json',
'../web/tsconfig.json',
'../web/tsconfig.app.json',
'../web/tsconfig.node.json',
'../web/vite.config.ts',
'../web/index.html',
'../web/src/main.tsx',
'../web/src/vite-env.d.ts',
'../web/src/api/health.ts',
'../web/src/app/App.tsx',
'../web/src/app/routes.ts',
'../web/src/app/useRoute.ts',
'../web/src/components/EmptyState.tsx',
'../web/src/components/Footer.tsx',
'../web/src/components/Header.tsx',
'../web/src/components/Tile.tsx',
'../web/src/routes/DashboardPage.tsx',
'../web/src/routes/PlaceholderPage.tsx',
'../web/src/theme/app.css',
'../web/src/theme/tokens.css',
)
web_dist = meson.current_build_dir() / 'web-dist'
web_frontend_build = custom_target(
'web_frontend_build',
input: meson.project_source_root() / 'web/package-lock.json',
output: 'web-frontend.stamp',
depend_files: frontend_sources,
command: [
find_program('python3'),
meson.project_source_root() / 'tools/build_web_frontend.py',
npm,
meson.project_source_root() / 'web',
web_dist,
'@OUTPUT@',
],
)
web_assets_source = custom_target(
'web_assets_generated',
input: web_frontend_build,
output: 'web_assets_generated.c',
depend_files: meson.project_source_root() / 'tools/generate_web_assets.py',
command: [
find_program('python3'),
meson.project_source_root() / 'tools/generate_web_assets.py',
web_dist,
'@OUTPUT@',
],
)
test(
'web_frontend_typecheck',
npm,
args: ['run', 'typecheck'],
workdir: meson.project_source_root() / 'web',
)
test(
'web_frontend',
npm,
args: ['test'],
workdir: meson.project_source_root() / 'web',
)
else
web_assets_source = files('src/web_assets_disabled.c')
endif
equipment_catalog_generated = custom_target(
'equipment_catalog_generated',
@ -154,6 +235,7 @@ trainlog_tui_sources = files(
'src/tui.c',
'src/web_server.c',
)
trainlog_tui_sources += web_assets_source
trainlog_exe = executable(
'trainlog',
@ -163,6 +245,7 @@ trainlog_exe = executable(
notcurses_dep,
microhttpd_dep,
],
include_directories: trainlog_private_include,
c_args: strict_c_args,
install: true,
)
@ -233,8 +316,9 @@ test('cli', test_cli)
test_web_server = executable(
'test_web_server',
['tests/test_web_server.c', 'src/web_server.c'],
['tests/test_web_server.c', 'src/web_server.c', web_assets_source],
dependencies: [trainlog_core_dep, microhttpd_dep],
include_directories: trainlog_private_include,
c_args: strict_c_args,
)
test('web_server', test_web_server, timeout: 30)
@ -248,6 +332,14 @@ test_web_headers = executable(
c_args: strict_c_args,
)
test('web_headers', test_web_headers)
test(
'web_asset_generator',
find_program('python3'),
args: [
meson.project_source_root() / 'tools/test_generate_web_assets.py',
meson.project_source_root() / 'tools/generate_web_assets.py',
],
)
test_training_knowledge = executable(
'test_training_knowledge',

22
tui/src/web_assets.h Normal file
View file

@ -0,0 +1,22 @@
#ifndef TRAINLOG_WEB_ASSETS_H
#define TRAINLOG_WEB_ASSETS_H
#include <stdbool.h>
#include <stddef.h>
typedef struct TrainlogWebAsset {
const char *path;
const char *mime;
const unsigned char *bytes;
size_t size;
const char *etag;
bool immutable;
} TrainlogWebAsset;
/* CONTRACT: generated assets have static process lifetime. Lookups are exact
* canonical URL matches; callers never derive filesystem paths at runtime. */
bool trainlog_web_assets_available(void);
const TrainlogWebAsset *trainlog_web_asset_find(const char *path);
const TrainlogWebAsset *trainlog_web_index_asset(void);
#endif

View file

@ -0,0 +1,17 @@
#include "web_assets.h"
bool trainlog_web_assets_available(void)
{
return false;
}
const TrainlogWebAsset *trainlog_web_asset_find(const char *path)
{
(void)path;
return NULL;
}
const TrainlogWebAsset *trainlog_web_index_asset(void)
{
return NULL;
}

View file

@ -10,6 +10,8 @@
#include <string.h>
#include <sys/select.h>
#include "web_assets.h"
#define TRAINLOG_HTTP_CONNECTION_LIMIT 32U
#define TRAINLOG_HTTP_PER_IP_LIMIT 32U
#define TRAINLOG_HTTP_CONNECTION_MEMORY_LIMIT (16U * 1024U)
@ -35,7 +37,7 @@ static void web_diagnostic(char *output, size_t capacity, const char *message)
}
static enum MHD_Result queue_json(struct MHD_Connection *connection,
unsigned int status, const char *body)
unsigned int status, const char *body, const char *allow)
{
struct MHD_Response *response;
enum MHD_Result result;
@ -56,9 +58,8 @@ static enum MHD_Result queue_json(struct MHD_Connection *connection,
MHD_destroy_response(response);
return MHD_NO;
}
if (status == MHD_HTTP_METHOD_NOT_ALLOWED &&
MHD_add_response_header(response, MHD_HTTP_HEADER_ALLOW, "GET") !=
MHD_YES) {
if (allow != NULL && MHD_add_response_header(response,
MHD_HTTP_HEADER_ALLOW, allow) != MHD_YES) {
MHD_destroy_response(response);
return MHD_NO;
}
@ -67,6 +68,49 @@ static enum MHD_Result queue_json(struct MHD_Connection *connection,
return result;
}
static enum MHD_Result queue_asset(struct MHD_Connection *connection,
const TrainlogWebAsset *asset)
{
static const char CSP[] = "default-src 'self'; script-src 'self'; "
"style-src 'self'; connect-src 'self'; img-src 'self'; "
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'";
const char *cache = asset->immutable
? "public, max-age=31536000, immutable" : "no-cache";
struct MHD_Response *response = MHD_create_response_from_buffer(asset->size,
(void *)asset->bytes, MHD_RESPMEM_PERSISTENT);
enum MHD_Result result;
if (response == NULL) return MHD_NO;
if (MHD_add_response_header(response, MHD_HTTP_HEADER_CONTENT_TYPE,
asset->mime) != MHD_YES ||
MHD_add_response_header(response,
MHD_HTTP_HEADER_X_CONTENT_TYPE_OPTIONS, "nosniff") != MHD_YES ||
MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL,
cache) != MHD_YES ||
MHD_add_response_header(response, MHD_HTTP_HEADER_ETAG,
asset->etag) != MHD_YES ||
MHD_add_response_header(response, "Content-Security-Policy", CSP) !=
MHD_YES ||
MHD_add_response_header(response, MHD_HTTP_HEADER_CONNECTION,
"close") != MHD_YES) {
MHD_destroy_response(response);
return MHD_NO;
}
result = MHD_queue_response(connection, MHD_HTTP_OK, response);
MHD_destroy_response(response);
return result;
}
static bool is_ui_route(const char *url)
{
static const char *const routes[] = {
"/", "/analyse", "/programmes", "/seances", "/exercices"
};
size_t index;
for (index = 0U; index < sizeof(routes) / sizeof(routes[0]); ++index)
if (strcmp(url, routes[index]) == 0) return true;
return false;
}
static bool valid_host(const TrainlogWebContext *context, const char *host)
{
char expected[32];
@ -103,7 +147,10 @@ static enum MHD_Result handle_request(void *closure,
TrainlogWebContext *context = closure;
TrainlogHttpRequestState *state = *request_closure;
const union MHD_ConnectionInfo *header_info;
const TrainlogWebAsset *asset;
const char *host;
bool is_get;
bool is_head;
(void)version;
if (state == NULL) {
state = calloc(1U, sizeof(*state));
@ -127,22 +174,39 @@ static enum MHD_Result handle_request(void *closure,
if (header_info == NULL || header_info->header_size >
TRAINLOG_HTTP_HEADER_LIMIT)
return queue_json(connection, MHD_HTTP_REQUEST_HEADER_FIELDS_TOO_LARGE,
"{\"error\":\"headers_too_large\"}\n");
"{\"error\":\"headers_too_large\"}\n", NULL);
if (state->body_too_large)
return queue_json(connection, MHD_HTTP_CONTENT_TOO_LARGE,
"{\"error\":\"body_too_large\"}\n");
"{\"error\":\"body_too_large\"}\n", NULL);
host = MHD_lookup_connection_value(connection, MHD_HEADER_KIND,
MHD_HTTP_HEADER_HOST);
if (!valid_host(context, host))
return queue_json(connection, MHD_HTTP_BAD_REQUEST,
"{\"error\":\"invalid_host\"}\n");
if (strcmp(url, "/api/v1/health") != 0)
"{\"error\":\"invalid_host\"}\n", NULL);
is_get = strcmp(method, MHD_HTTP_METHOD_GET) == 0;
is_head = strcmp(method, MHD_HTTP_METHOD_HEAD) == 0;
if (strcmp(url, "/api/v1/health") == 0) {
if (!is_get)
return queue_json(connection, MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n", "GET");
return queue_json(connection, MHD_HTTP_OK, HEALTH, NULL);
}
/* INVARIANT: API paths never fall through to the SPA index. */
if (strncmp(url, "/api/", 5U) == 0)
return queue_json(connection, MHD_HTTP_NOT_FOUND,
"{\"error\":\"not_found\"}\n");
if (strcmp(method, MHD_HTTP_METHOD_GET) != 0)
"{\"error\":\"not_found\"}\n", NULL);
if (strstr(url, "..") != NULL)
return queue_json(connection, MHD_HTTP_NOT_FOUND,
"{\"error\":\"not_found\"}\n", NULL);
asset = is_ui_route(url) ? trainlog_web_index_asset()
: trainlog_web_asset_find(url);
if (asset == NULL)
return queue_json(connection, MHD_HTTP_NOT_FOUND,
"{\"error\":\"not_found\"}\n", NULL);
if (!is_get && !is_head)
return queue_json(connection, MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n");
return queue_json(connection, MHD_HTTP_OK, HEALTH);
"{\"error\":\"method_not_allowed\"}\n", "GET, HEAD");
return queue_asset(connection, asset);
}
static void request_completed(void *closure, struct MHD_Connection *connection,
@ -176,6 +240,11 @@ int trainlog_web_server_run(TrainlogDatabase *database, uint16_t port,
int result = 0;
if (database == NULL || stop_requested == NULL) return -1;
web_diagnostic(diagnostic, diagnostic_capacity, "");
if (!trainlog_web_assets_available()) {
web_diagnostic(diagnostic, diagnostic_capacity,
"support frontend absent de ce binaire");
return -1;
}
(void)memset(&bind_address, 0, sizeof(bind_address));
bind_address.sin_family = AF_INET;
bind_address.sin_port = htons(port);

View file

@ -15,6 +15,7 @@
#include "trainlog/database.h"
#include "trainlog/web_server.h"
#include "web_assets.h"
#define CHECK(value) do { if (!(value)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \
@ -97,10 +98,36 @@ static bool exchange(uint16_t port, const char *request, char *response,
return true;
}
static bool asset_path(const char *html, const char *suffix, char *output,
size_t capacity)
{
const char *start = html;
size_t suffix_length = strlen(suffix);
while ((start = strstr(start, "/assets/")) != NULL) {
const char *end = start;
size_t length;
while (*end != '\0' && *end != '"' && *end != '\'' && *end != '<' &&
*end != '>') ++end;
length = (size_t)(end - start);
if (length >= suffix_length &&
memcmp(end - suffix_length, suffix, suffix_length) == 0 &&
length < capacity) {
(void)memcpy(output, start, length);
output[length] = '\0';
return true;
}
start = end;
}
return false;
}
static bool test_http_contract(TrainlogDatabase *database)
{
char response[32768];
char large_request[12000];
char request[512];
char javascript_path[256];
char stylesheet_path[256];
uint16_t port;
pid_t child;
int status;
@ -130,7 +157,45 @@ static bool test_http_contract(TrainlogDatabase *database)
"{\"api_version\":1,\"status\":\"ok\",\"product\":\"trainlog\","
"\"version\":\"0.1.2\"}") != NULL);
CHECK(exchange(port, "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 200") != NULL);
CHECK(strstr(response, "Content-Type: text/html; charset=utf-8") != NULL);
CHECK(strstr(response, "Cache-Control: no-cache") != NULL);
CHECK(strstr(response, "Content-Security-Policy: default-src 'self'") != NULL);
CHECK(strstr(response, "unsafe-inline") == NULL);
CHECK(strstr(response, "unsafe-eval") == NULL);
CHECK(strstr(response, "<title>Trainlog</title>") != NULL);
CHECK(asset_path(response, ".js", javascript_path, sizeof(javascript_path)));
CHECK(asset_path(response, ".css", stylesheet_path, sizeof(stylesheet_path)));
(void)snprintf(request, sizeof(request),
"GET %s HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", javascript_path);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 200") != NULL);
CHECK(strstr(response, "Content-Type: text/javascript; charset=utf-8") != NULL);
CHECK(strstr(response,
"Cache-Control: public, max-age=31536000, immutable") != NULL);
CHECK(strstr(response, "ETag: \"sha256-") != NULL);
(void)snprintf(request, sizeof(request),
"GET %s HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", stylesheet_path);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "Content-Type: text/css; charset=utf-8") != NULL);
CHECK(exchange(port,
"GET /analyse HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, sizeof(response)) && strstr(response, "<title>Trainlog</title>") != NULL);
CHECK(exchange(port,
"GET /api/v1/unknown HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 404") != NULL &&
strstr(response, "<title>Trainlog</title>") == NULL);
CHECK(exchange(port,
"GET /assets/unknown.js HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 404") != NULL);
CHECK(exchange(port,
"GET /assets/../index.html HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 404") != NULL);
(void)snprintf(request, sizeof(request),
"HEAD %s HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", javascript_path);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 200") != NULL &&
strstr(response, "Content-Type: text/javascript; charset=utf-8") != NULL);
CHECK(exchange(port,
"POST /api/v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 405") != NULL);
@ -180,7 +245,14 @@ int main(void)
bool passed;
if (trainlog_database_open(":memory:", &database) != TRAINLOG_STATUS_OK)
return 1;
passed = test_http_contract(database) && test_port_in_use(database);
if (!trainlog_web_assets_available()) {
volatile sig_atomic_t stop = 0;
char diagnostic[256];
passed = trainlog_web_server_run(database, 8080U, &stop, diagnostic,
sizeof(diagnostic)) != 0 && strstr(diagnostic, "frontend") != NULL;
} else {
passed = test_http_contract(database) && test_port_in_use(database);
}
trainlog_database_close(database);
return passed ? 0 : 1;
}

14
web/index.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#11111b" />
<title>Trainlog</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2418
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

26
web/package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "trainlog-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"build": "tsc -b && vite build",
"test": "vitest run",
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.1.1",
"jsdom": "30.0.1",
"typescript": "7.0.2",
"vite": "8.3.0",
"vitest": "5.0.1"
}
}

25
web/src/api/health.ts Normal file
View file

@ -0,0 +1,25 @@
export interface Health {
api_version: 1
status: 'ok'
product: 'trainlog'
version: string
}
function isHealth(value: unknown): value is Health {
if (typeof value !== 'object' || value === null) return false
const candidate = value as Record<string, unknown>
return candidate.api_version === 1 && candidate.status === 'ok' &&
candidate.product === 'trainlog' && typeof candidate.version === 'string' &&
candidate.version.length > 0
}
export async function fetchHealth(signal?: AbortSignal): Promise<Health> {
const response = await fetch('/api/v1/health', {
headers: { Accept: 'application/json' },
signal,
})
if (!response.ok) throw new Error(`health HTTP ${response.status}`)
const value: unknown = await response.json()
if (!isHealth(value)) throw new Error('health JSON invalide')
return value
}

82
web/src/app/App.test.tsx Normal file
View file

@ -0,0 +1,82 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { App } from './App'
const health = {
api_version: 1,
status: 'ok',
product: 'trainlog',
version: '0.1.2',
}
function mockHealth(value: unknown = health) {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(value),
}))
}
describe('shell Trainlog', () => {
it('rend le shell, les sept états vides et le health sans données fictives', async () => {
mockHealth()
render(<App />)
expect(screen.getByRole('banner')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Aller au contenu' })).toHaveAttribute('href', '#main-content')
expect(screen.getByRole('navigation', { name: 'Navigation principale' })).toBeInTheDocument()
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
expect(screen.getAllByText('INDISPONIBLE')).toHaveLength(7)
expect(screen.getByLabelText('Utilisateur')).toHaveTextContent('—')
expect(screen.getByLabelText('Dernière séance')).toHaveTextContent('Indisponible')
expect(screen.getByLabelText('Dernière zone')).toHaveTextContent('Indisponible')
expect(document.body).not.toHaveTextContent('fy59')
expect(document.body).not.toHaveTextContent('DOS · BICEPS')
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Backend connecté · v0.1.2'))
})
it.each([
['Analyse', '/analyse'],
['Programmes', '/programmes'],
['Séances', '/seances'],
['Exercices', '/exercices'],
['Dashboard', '/'],
])('navigue vers %s sans remonter le shell', async (label, path) => {
mockHealth()
render(<App />)
const header = screen.getByRole('banner')
const footer = screen.getByRole('contentinfo')
fireEvent.click(screen.getByRole('link', { name: label }))
expect(window.location.pathname).toBe(path)
expect(screen.getByRole('heading', { level: 1, name: label })).toBeInTheDocument()
expect(screen.getByRole('link', { name: label })).toHaveAttribute('aria-current', 'page')
expect(screen.getByRole('banner')).toBe(header)
expect(screen.getByRole('contentinfo')).toBe(footer)
})
it('suit la navigation arrière du navigateur', () => {
mockHealth()
render(<App />)
fireEvent.click(screen.getByRole('link', { name: 'Analyse' }))
window.history.pushState(null, '', '/programmes')
fireEvent.popState(window)
expect(screen.getByRole('heading', { level: 1, name: 'Programmes' })).toBeInTheDocument()
})
it('présente un échec health discret et accessible', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network')))
render(<App />)
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Backend indisponible'))
})
it('rejette un JSON health invalide', async () => {
mockHealth({ status: 'ok' })
render(<App />)
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Backend indisponible'))
})
it('expose les cinq destinations dans une navigation clavier sémantique', () => {
mockHealth()
render(<App />)
expect(screen.getAllByRole('link').filter((link) => link.closest('nav'))).toHaveLength(5)
expect(screen.getByRole('link', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'page')
})
})

45
web/src/app/App.tsx Normal file
View file

@ -0,0 +1,45 @@
import { useEffect, useState } from 'react'
import { fetchHealth, type Health } from '../api/health'
import { Footer } from '../components/Footer'
import { Header } from '../components/Header'
import { DashboardPage } from '../routes/DashboardPage'
import { PlaceholderPage } from '../routes/PlaceholderPage'
import type { RouteId } from './routes'
import { useRoute } from './useRoute'
const placeholderContent: Record<Exclude<RouteId, 'dashboard'>, [string, string, string]> = {
analysis: ['Analyse', 'COMPRENDRE', 'Les analyses détaillées apparaîtront ici lorsque leurs read models Core seront disponibles.'],
programs: ['Programmes', 'PRÉPARER', 'La préparation des programmes utilisera des command services Core explicites.'],
sessions: ['Séances', 'ORGANISER', 'Les séances préparées et terminées seront présentées sans confondre plan et réalisé.'],
exercises: ['Exercices', 'EXPLORER', 'Le catalogue canonique des exercices sera consultable depuis cette page.'],
}
export function App() {
const [route, navigate] = useRoute()
const [health, setHealth] = useState<Health | null>(null)
const [healthPending, setHealthPending] = useState(true)
const [healthFailed, setHealthFailed] = useState(false)
useEffect(() => {
const controller = new AbortController()
fetchHealth(controller.signal).then((value) => {
setHealth(value)
setHealthFailed(false)
}).catch(() => setHealthFailed(true)).finally(() => setHealthPending(false))
return () => controller.abort()
}, [])
const content = route.id === 'dashboard' ? <DashboardPage /> :
<PlaceholderPage title={placeholderContent[route.id][0]}
eyebrow={placeholderContent[route.id][1]}
description={placeholderContent[route.id][2]} />
return (
<div className="app-shell">
<a className="skip-link" href="#main-content">Aller au contenu</a>
<Header activeRoute={route} onNavigate={navigate} />
<main className="app-main" id="main-content">{content}</main>
<Footer health={health} healthPending={healthPending} healthFailed={healthFailed} />
</div>
)
}

19
web/src/app/routes.ts Normal file
View file

@ -0,0 +1,19 @@
export type RouteId = 'dashboard' | 'analysis' | 'programs' | 'sessions' | 'exercises'
export interface AppRoute {
id: RouteId
path: string
label: string
}
export const routes: readonly AppRoute[] = [
{ id: 'dashboard', path: '/', label: 'Dashboard' },
{ id: 'analysis', path: '/analyse', label: 'Analyse' },
{ id: 'programs', path: '/programmes', label: 'Programmes' },
{ id: 'sessions', path: '/seances', label: 'Séances' },
{ id: 'exercises', path: '/exercices', label: 'Exercices' },
]
export function routeFromPath(pathname: string): AppRoute {
return routes.find((route) => route.path === pathname) ?? routes[0]
}

20
web/src/app/useRoute.ts Normal file
View file

@ -0,0 +1,20 @@
import { useEffect, useState } from 'react'
import { routeFromPath, type AppRoute } from './routes'
export function useRoute(): [AppRoute, (path: string) => void] {
const [route, setRoute] = useState(() => routeFromPath(window.location.pathname))
useEffect(() => {
const onPopState = () => setRoute(routeFromPath(window.location.pathname))
window.addEventListener('popstate', onPopState)
return () => window.removeEventListener('popstate', onPopState)
}, [])
const navigate = (path: string) => {
if (window.location.pathname !== path) window.history.pushState(null, '', path)
setRoute(routeFromPath(path))
window.scrollTo({ top: 0, behavior: 'instant' })
}
return [route, navigate]
}

View file

@ -0,0 +1,12 @@
interface EmptyStateProps {
message: string
}
export function EmptyState({ message }: EmptyStateProps) {
return (
<div className="empty-state">
<span className="empty-glyph" aria-hidden="true">—</span>
<p>{message}</p>
</div>
)
}

View file

@ -0,0 +1,32 @@
import type { Health } from '../api/health'
interface FooterProps {
health: Health | null
healthPending: boolean
healthFailed: boolean
}
export function Footer({ health, healthPending, healthFailed }: FooterProps) {
const backendLabel = healthPending ? 'Connexion en cours' :
healthFailed ? 'Backend indisponible' : `Backend connecté · v${health?.version ?? '—'}`
return (
<footer className="status-footer">
<section className="status-block status-user" aria-label="Utilisateur">
<span className="status-label">UTILISATEUR</span>
<span className="status-value">—</span>
</section>
<section className="status-block status-session" aria-label="Dernière séance">
<span className="status-label">DERNIÈRE SÉANCE</span>
<span className="status-value">Indisponible</span>
</section>
<section className="status-block status-zone" aria-label="Dernière zone">
<span className="status-label">DERNIÈRE ZONE</span>
<span className="status-value">Indisponible</span>
</section>
<span className={`backend-state${healthFailed ? ' is-error' : ''}`} role="status">
<span className="backend-dot" aria-hidden="true" />{backendLabel}
</span>
</footer>
)
}

View file

@ -0,0 +1,35 @@
import type { MouseEvent } from 'react'
import { routes, type AppRoute } from '../app/routes'
interface HeaderProps {
activeRoute: AppRoute
onNavigate: (path: string) => void
}
export function Header({ activeRoute, onNavigate }: HeaderProps) {
const follow = (event: MouseEvent<HTMLAnchorElement>, path: string) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
event.preventDefault()
onNavigate(path)
}
return (
<header className="app-header">
<a className="brand" href="/" onClick={(event) => follow(event, '/')}
aria-label="Trainlog — Dashboard">
<span className="brand-mark" aria-hidden="true">TL</span>
<span>TRAINLOG</span>
</a>
<nav className="main-nav" aria-label="Navigation principale">
{routes.map((route) => (
<a key={route.id} href={route.path}
className={activeRoute.id === route.id ? 'nav-link is-active' : 'nav-link'}
aria-current={activeRoute.id === route.id ? 'page' : undefined}
onClick={(event) => follow(event, route.path)}>
{route.label}
</a>
))}
</nav>
</header>
)
}

View file

@ -0,0 +1,23 @@
import type { ReactNode } from 'react'
interface TileProps {
title: string
eyebrow?: string
children: ReactNode
className?: string
}
export function Tile({ title, eyebrow, children, className = '' }: TileProps) {
return (
<article className={`tile ${className}`.trim()}>
<div className="tile-header">
<div>
{eyebrow && <p className="eyebrow">{eyebrow}</p>}
<h2>{title}</h2>
</div>
<span className="tile-state">INDISPONIBLE</span>
</div>
<div className="tile-content">{children}</div>
</article>
)
}

10
web/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './app/App'
import './theme/tokens.css'
import './theme/app.css'
const root = document.getElementById('root')
if (root === null) throw new Error('Racine Trainlog absente')
createRoot(root).render(<StrictMode><App /></StrictMode>)

View file

@ -0,0 +1,31 @@
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
export function DashboardPage() {
return (
<section className="page" aria-labelledby="page-title">
<div className="page-heading">
<div><p className="eyebrow">VUE D’ENSEMBLE</p><h1 id="page-title">Dashboard</h1></div>
<p className="page-intro">Vos repères d’entraînement, sans extrapolation ni donnée inventée.</p>
</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>
</section>
)
}

View file

@ -0,0 +1,29 @@
import { EmptyState } from '../components/EmptyState'
import { Tile } from '../components/Tile'
interface PlaceholderPageProps {
title: string
eyebrow: string
description: string
}
export function PlaceholderPage({ title, eyebrow, description }: PlaceholderPageProps) {
return (
<section className="page" aria-labelledby="page-title">
<div className="page-heading">
<div><p className="eyebrow">{eyebrow}</p><h1 id="page-title">{title}</h1></div>
<p className="page-intro">{description}</p>
</div>
<div className="tile-grid placeholder-grid">
<Tile title={`${title} Trainlog`} eyebrow="BIENTÔT DISPONIBLE" className="tile-feature">
<EmptyState message="Cette interface sera activée lorsque les services Core correspondants seront exposés." />
</Tile>
<aside className="principle-panel">
<span className="principle-index">01</span>
<div><p className="eyebrow">PRINCIPE</p><h2>Données réelles uniquement</h2>
<p>Trainlog affiche des faits persistés ou des calculs déterministes documentés.</p></div>
</aside>
</div>
</section>
)
}

11
web/src/test/setup.ts Normal file
View file

@ -0,0 +1,11 @@
import '@testing-library/jest-dom/vitest'
import { afterEach, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
Object.defineProperty(window, 'scrollTo', { value: vi.fn(), writable: true })
afterEach(() => {
cleanup()
vi.restoreAllMocks()
window.history.replaceState(null, '', '/')
})

100
web/src/theme/app.css Normal file
View file

@ -0,0 +1,100 @@
.app-shell {
min-height: 100vh;
overflow-x: hidden;
display: grid;
grid-template-rows: auto 1fr auto;
background:
radial-gradient(circle at 92% 12%, rgb(180 190 254 / 7%), transparent 25rem),
var(--crust);
}
.skip-link { position: fixed; z-index: 20; top: .75rem; left: .75rem; padding: .65rem .85rem; color: var(--crust); background: var(--lavender); border-radius: .45rem; font-weight: 700; text-decoration: none; transform: translateY(-200%); }
.skip-link:focus { transform: translateY(0); }
.app-header {
position: sticky;
top: 0;
z-index: 10;
min-height: 4.5rem;
padding: .75rem var(--shell-gutter);
display: flex;
align-items: center;
gap: clamp(1.5rem, 4vw, 5rem);
background: rgb(17 17 27 / 92%);
border-bottom: 1px solid rgb(69 71 90 / 55%);
backdrop-filter: blur(16px);
}
.brand { display: flex; align-items: center; gap: .7rem; text-decoration: none; font-weight: 800; letter-spacing: .13em; }
.brand-mark { width: 2rem; height: 2rem; display: grid; place-items: center; color: var(--crust); background: var(--lavender); border-radius: .55rem; font: 700 .7rem var(--font-mono); letter-spacing: -.04em; }
.main-nav { min-width: 0; display: flex; align-items: center; gap: .25rem; overflow-x: auto; scrollbar-width: none; }
.main-nav::-webkit-scrollbar { display: none; }
.nav-link { position: relative; padding: .7rem .85rem; color: var(--subtext-0); text-decoration: none; font-size: .91rem; font-weight: 570; white-space: nowrap; transition: color 140ms ease, background 140ms ease; border-radius: .55rem; }
.nav-link:hover { color: var(--text); background: rgb(49 50 68 / 48%); }
.nav-link.is-active { color: var(--lavender); }
.nav-link.is-active::after { content: ""; position: absolute; left: .85rem; right: .85rem; bottom: .22rem; height: 2px; background: var(--lavender); border-radius: 2px; }
.app-main { width: 100%; padding: clamp(2.25rem, 5vw, 5rem) var(--shell-gutter) clamp(3rem, 7vw, 7rem); }
.page { width: min(100%, 92rem); min-width: 0; margin: 0 auto; }
.page-heading { margin-bottom: clamp(2rem, 4vw, 3.5rem); display: flex; justify-content: space-between; align-items: end; gap: 2rem; }
.eyebrow { margin: 0 0 .55rem; color: var(--lavender); font: 700 .68rem/1.2 var(--font-mono); letter-spacing: .16em; text-transform: uppercase; }
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; }
.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%); }
.tile-wide { grid-column: span 6; }
.tile-feature { grid-column: span 8; min-height: 25rem; }
.tile-header { min-width: 0; display: flex; flex-wrap: wrap; align-items: start; justify-content: space-between; gap: 1rem; }
.tile-header h2 { margin-bottom: 0; }
.tile-state { flex: none; color: var(--subtext-0); font: 600 .6rem var(--font-mono); letter-spacing: .1em; padding: .42rem .58rem; background: var(--surface-0); border-radius: 99rem; }
.tile-content { flex: 1; display: grid; place-items: center; }
.empty-state { max-width: 22rem; color: var(--subtext-0); text-align: center; line-height: 1.5; }
.empty-state p { margin: .75rem 0 0; overflow-wrap: anywhere; }
.empty-glyph { display: inline-grid; width: 2.4rem; height: 2.4rem; place-items: center; color: var(--lavender); background: rgb(180 190 254 / 9%); border-radius: 50%; font: 600 1rem var(--font-mono); }
.principle-panel { grid-column: span 4; min-height: 25rem; padding: 1.6rem; display: flex; flex-direction: column; justify-content: space-between; color: var(--subtext-0); background: var(--mantle); border-radius: var(--radius); }
.principle-panel h2 { font-size: clamp(1.3rem, 2vw, 1.8rem); }
.principle-panel p:last-child { line-height: 1.65; }
.principle-index { color: var(--surface-1); font: 700 4rem/1 var(--font-mono); letter-spacing: -.1em; }
.status-footer { position: sticky; bottom: 0; z-index: 10; min-height: 3.75rem; padding: .65rem var(--shell-gutter); display: grid; grid-template-columns: 1fr 1.25fr 1.25fr auto; align-items: center; gap: 1.5rem; background: rgb(24 24 37 / 96%); border-top: 1px solid rgb(69 71 90 / 62%); backdrop-filter: blur(16px); }
.status-block { min-width: 0; display: flex; flex-direction: column; gap: .16rem; }
.status-label { color: var(--surface-1); font: 700 .58rem var(--font-mono); letter-spacing: .12em; }
.status-value { overflow: hidden; color: var(--subtext-0); font-size: .78rem; text-overflow: ellipsis; white-space: nowrap; }
.backend-state { display: flex; align-items: center; gap: .42rem; color: var(--green); font: 600 .65rem var(--font-mono); white-space: nowrap; }
.backend-state.is-error { color: var(--red); }
.backend-dot { width: .45rem; height: .45rem; background: currentColor; border-radius: 50%; }
@media (max-width: 70rem) {
.tile { grid-column: span 6; }
.tile-feature { grid-column: span 8; }
.principle-panel { grid-column: span 4; }
.status-footer { grid-template-columns: 1fr 1fr 1fr; }
.backend-state { grid-column: 1 / -1; justify-self: end; }
}
@media (max-width: 48rem) {
.app-header { align-items: stretch; flex-direction: column; gap: .55rem; padding-block: .7rem .45rem; }
.brand { align-self: start; }
.main-nav { margin-inline: calc(var(--shell-gutter) * -1); padding-inline: var(--shell-gutter); }
.page-heading { align-items: start; flex-direction: column; gap: 1rem; }
.tile-grid { display: flex; flex-direction: column; }
.tile, .tile-wide, .tile-feature, .principle-panel { width: 100%; grid-column: auto; }
.tile { min-height: 14rem; }
.principle-panel { min-height: 17rem; }
.status-footer { position: static; grid-template-columns: 1fr 1fr; gap: .8rem 1.25rem; }
.status-zone { grid-column: 1 / -1; }
.backend-state { grid-column: 1 / -1; justify-self: start; }
}
@media (max-width: 28rem) {
.status-footer { grid-template-columns: 1fr; }
.status-zone, .backend-state { grid-column: auto; }
h1 { font-size: 2.3rem; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
}

36
web/src/theme/tokens.css Normal file
View file

@ -0,0 +1,36 @@
:root {
color-scheme: dark;
--crust: #11111b;
--mantle: #181825;
--base: #1e1e2e;
--surface-0: #313244;
--surface-1: #45475a;
--text: #cdd6f4;
--subtext-0: #a6adc8;
--lavender: #b4befe;
--mauve: #cba6f7;
--blue: #89b4fa;
--sapphire: #74c7ec;
--green: #a6e3a1;
--yellow: #f9e2af;
--peach: #fab387;
--red: #f38ba8;
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
--shell-gutter: clamp(1rem, 3vw, 3rem);
--radius: 1rem;
font-family: var(--font-sans);
color: var(--text);
background: var(--crust);
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* { box-sizing: border-box; }
html, body, #root { min-width: 20rem; min-height: 100%; margin: 0; }
body { min-height: 100vh; background: var(--crust); }
button, a { font: inherit; }
a { color: inherit; }
::selection { color: var(--crust); background: var(--lavender); }
:focus-visible { outline: 2px solid var(--lavender); outline-offset: 4px; border-radius: .25rem; }

1
web/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

22
web/tsconfig.app.json Normal file
View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}

7
web/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

16
web/tsconfig.node.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true
},
"include": ["vite.config.ts"]
}

16
web/vite.config.ts Normal file
View file

@ -0,0 +1,16 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [react()],
build: {
assetsDir: 'assets',
sourcemap: false,
reportCompressedSize: false,
},
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
css: true,
},
})