feat(web): embed frontend shell
This commit is contained in:
parent
7597a167f8
commit
188aba2df7
38 changed files with 3643 additions and 35 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -3,6 +3,9 @@ build/
|
|||
builddir/
|
||||
meson-logs/
|
||||
meson-private/
|
||||
/web/node_modules/
|
||||
/web/dist/
|
||||
/web/coverage/
|
||||
|
||||
# Android / Gradle
|
||||
.gradle/
|
||||
|
|
|
|||
24
README.md
24
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
6
meson_options.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
option(
|
||||
'web',
|
||||
type: 'feature',
|
||||
value: 'auto',
|
||||
description: 'Build and embed the Trainlog Web frontend',
|
||||
)
|
||||
37
tools/build_web_frontend.py
Normal file
37
tools/build_web_frontend.py
Normal 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())
|
||||
104
tools/generate_web_assets.py
Normal file
104
tools/generate_web_assets.py
Normal 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())
|
||||
54
tools/test_generate_web_assets.py
Normal file
54
tools/test_generate_web_assets.py
Normal 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())
|
||||
|
|
@ -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
22
tui/src/web_assets.h
Normal 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
|
||||
17
tui/src/web_assets_disabled.c
Normal file
17
tui/src/web_assets_disabled.c
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
14
web/index.html
Normal 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
2418
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
web/package.json
Normal file
26
web/package.json
Normal 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
25
web/src/api/health.ts
Normal 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
82
web/src/app/App.test.tsx
Normal 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
45
web/src/app/App.tsx
Normal 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
19
web/src/app/routes.ts
Normal 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
20
web/src/app/useRoute.ts
Normal 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]
|
||||
}
|
||||
12
web/src/components/EmptyState.tsx
Normal file
12
web/src/components/EmptyState.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
web/src/components/Footer.tsx
Normal file
32
web/src/components/Footer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
web/src/components/Header.tsx
Normal file
35
web/src/components/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
web/src/components/Tile.tsx
Normal file
23
web/src/components/Tile.tsx
Normal 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
10
web/src/main.tsx
Normal 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>)
|
||||
31
web/src/routes/DashboardPage.tsx
Normal file
31
web/src/routes/DashboardPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
web/src/routes/PlaceholderPage.tsx
Normal file
29
web/src/routes/PlaceholderPage.tsx
Normal 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
11
web/src/test/setup.ts
Normal 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
100
web/src/theme/app.css
Normal 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
36
web/src/theme/tokens.css
Normal 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
1
web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
22
web/tsconfig.app.json
Normal file
22
web/tsconfig.app.json
Normal 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
7
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
16
web/tsconfig.node.json
Normal file
16
web/tsconfig.node.json
Normal 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
16
web/vite.config.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
Loading…
Reference in a new issue