diff --git a/README.md b/README.md index 2ea8dde..4f2f1a4 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,54 @@ 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. +### Optional systemd user service for local Web development + +The versioned `systemd/trainlog-web.service` unit can keep `trainlog -w` +available on `127.0.0.1:8080` during an interactive user session. This is a +development convenience, not a requirement of the final Trainlog distribution. +It intentionally runs the canonical user entry point +`~/.local/bin/trainlog`; in a source checkout that entry point may be a symlink +to the current `build/tui/trainlog` binary, but the symlink must resolve to an +executable before the service is started. + +The unit prevents privilege acquisition, gives the process a private temporary +directory, makes system paths read-only, and applies kernel/control-group and +setuid restrictions that are compatible with the local HTTP server. It does +not protect or hide the home directory because SQLite and Trainlog +configuration must remain writable under `~/.local/share/trainlog` and +`~/.config/trainlog`. Failed starts use a ten-second restart delay and are +limited to three attempts per minute, preventing a persistent port-8080 +conflict from producing an uncontrolled loop. + +Install the development unit as a symlink so repository updates are picked up +after `daemon-reload`: + +```bash +mkdir -p ~/.config/systemd/user +ln -s "$(pwd)/systemd/trainlog-web.service" ~/.config/systemd/user/trainlog-web.service +systemctl --user daemon-reload +systemctl --user enable --now trainlog-web.service +``` + +Useful lifecycle and diagnostic commands are: + +```bash +systemctl --user status trainlog-web +systemctl --user restart trainlog-web +systemctl --user stop trainlog-web +systemctl --user start trainlog-web +journalctl --user -u trainlog-web +``` + +Uninstalling the integration disables the unit before removing only its user +configuration symlink: + +```bash +systemctl --user disable --now trainlog-web.service +rm ~/.config/systemd/user/trainlog-web.service +systemctl --user daemon-reload +``` + 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. diff --git a/systemd/trainlog-web.service b/systemd/trainlog-web.service new file mode 100644 index 0000000..2acf3dc --- /dev/null +++ b/systemd/trainlog-web.service @@ -0,0 +1,35 @@ +[Unit] +Description=Trainlog Web local user service +Documentation=https://github.com/labfytools/trainlog +After=default.target +# WHY: A persistent bind failure (notably an occupied 127.0.0.1:8080) must not +# become an aggressive restart loop in the user's session. +StartLimitIntervalSec=60 +StartLimitBurst=3 + +[Service] +Type=simple +# CONTRACT: The user-managed executable is the canonical manual and service +# entry point; the repository/build location must not leak into this unit. +ExecStart=%h/.local/bin/trainlog -w +# INVARIANT: Trainlog stays in the foreground, owns its ordered SIGTERM +# shutdown, and always keeps the frozen loopback address and port contract. +KillSignal=SIGTERM +TimeoutStopSec=20 +Restart=on-failure +RestartSec=10 + +# These protections do not isolate the home directory: Trainlog must retain +# access to its SQLite data and configuration below ~/.local/share and ~/.config. +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectControlGroups=true +ProtectKernelModules=true +ProtectKernelTunables=true +RestrictSUIDSGID=true +LockPersonality=true +SystemCallArchitectures=native + +[Install] +WantedBy=default.target diff --git a/web/src/app/App.test.tsx b/web/src/app/App.test.tsx index ea35e00..83ddf83 100644 --- a/web/src/app/App.test.tsx +++ b/web/src/app/App.test.tsx @@ -23,7 +23,11 @@ describe('shell Trainlog', () => { 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() + const footer = screen.getByRole('contentinfo') + expect(footer).toHaveClass('status-footer') + expect(footer.parentElement).toHaveClass('app-shell') + expect(screen.getByRole('link', { name: 'Trainlog — Dashboard' })).toHaveTextContent('TRAINLOG') + expect(screen.queryByText('Vos repères d’entraînement, sans extrapolation ni donnée inventée.')).not.toBeInTheDocument() expect(screen.getAllByText('INDISPONIBLE')).toHaveLength(7) expect(screen.getByLabelText('Utilisateur')).toHaveTextContent('—') expect(screen.getByLabelText('Dernière séance')).toHaveTextContent('Indisponible') diff --git a/web/src/routes/DashboardPage.tsx b/web/src/routes/DashboardPage.tsx index 399ad0e..cdcc9f3 100644 --- a/web/src/routes/DashboardPage.tsx +++ b/web/src/routes/DashboardPage.tsx @@ -16,7 +16,6 @@ export function DashboardPage() {

VUE D’ENSEMBLE

Dashboard

-

Vos repères d’entraînement, sans extrapolation ni donnée inventée.

{dashboardTiles.map(([title, eyebrow, message], index) => ( diff --git a/web/src/theme/app.css b/web/src/theme/app.css index 18aa754..ff85ae6 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -1,8 +1,12 @@ .app-shell { + width: 100%; + max-width: 100vw; min-height: 100vh; + padding-bottom: 3.75rem; overflow-x: hidden; display: grid; - grid-template-rows: auto 1fr auto; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto 1fr; background: radial-gradient(circle at 92% 12%, rgb(180 190 254 / 7%), transparent 25rem), var(--crust); @@ -12,6 +16,7 @@ .skip-link:focus { transform: translateY(0); } .app-header { + min-width: 0; position: sticky; top: 0; z-index: 10; @@ -25,8 +30,8 @@ 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; } +.brand { display: flex; align-items: center; gap: .7rem; color: var(--text); text-decoration: none; font-size: 1.02rem; font-weight: 850; letter-spacing: .15em; text-shadow: 0 0 1.4rem rgb(180 190 254 / 24%); } +.brand-mark { width: 2.1rem; height: 2.1rem; display: grid; place-items: center; color: var(--crust); background: var(--lavender); border: 1px solid rgb(255 255 255 / 28%); border-radius: .58rem; box-shadow: 0 0 .9rem rgb(180 190 254 / 18%); font: 750 .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; } @@ -34,7 +39,7 @@ .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); } +.app-main { width: 100%; min-width: 0; 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; } @@ -59,7 +64,7 @@ h2 { color: var(--text); font-size: 1.08rem; letter-spacing: -.02em; } .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-footer { position: fixed; right: 0; bottom: 0; left: 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; } @@ -68,6 +73,7 @@ h2 { color: var(--text); font-size: 1.08rem; letter-spacing: -.02em; } .backend-dot { width: .45rem; height: .45rem; background: currentColor; border-radius: 50%; } @media (max-width: 70rem) { + .app-shell { padding-bottom: 5.4rem; } .tile { grid-column: span 6; } .tile-feature { grid-column: span 8; } .principle-panel { grid-column: span 4; } @@ -76,6 +82,7 @@ h2 { color: var(--text); font-size: 1.08rem; letter-spacing: -.02em; } } @media (max-width: 48rem) { + .app-shell { padding-bottom: 8rem; } .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); } @@ -84,12 +91,13 @@ h2 { color: var(--text); font-size: 1.08rem; letter-spacing: -.02em; } .tile, .tile-wide, .tile-feature, .principle-panel { width: 100%; grid-column: auto; } .tile { min-height: 14rem; } .principle-panel { min-height: 17rem; } - .status-footer { position: static; grid-template-columns: 1fr 1fr; gap: .8rem 1.25rem; } + .status-footer { grid-template-columns: 1fr 1fr; gap: .8rem 1.25rem; } .status-zone { grid-column: 1 / -1; } .backend-state { grid-column: 1 / -1; justify-self: start; } } @media (max-width: 28rem) { + .app-shell { padding-bottom: 12.5rem; } .status-footer { grid-template-columns: 1fr; } .status-zone, .backend-state { grid-column: auto; } h1 { font-size: 2.3rem; }