Header now renders brand / status / actions as three flex children
instead of brand / controls, using .jn-header-brand's margin-right:auto
plus .jn-header's gap in place of justify-content:space-between. This
lets the mobile stylesheet wrap actions onto row one (with brand) and
push status to a full-width second row via order + flex-basis, fixing
header overflow on narrow viewports without changing desktop layout.
- Class dividers with .jn-sched-divider to hide on mobile
- Class days container with .jn-days to grid-wrap on mobile
- Class day buttons with .jn-day-btn and remove flex layout
- Keeps state-dependent styles (background, color, etc) inline
- Resolves overflow on ~331px mobile panel
Remove inline display and gridTemplateColumns from header and log row divs,
delegating layout control to responsive CSS classes. Add minWidth: 0 and
overflowWrap: 'anywhere' to message span to allow text wrapping on mobile.
Replace two inline grid styles in Dashboard.tsx with className references
to .jn-row-actions and .jn-row-guests from responsive.css. This enables
the existing media queries to control layout on mobile devices without
the inline styles overriding them.
- Line 193: gridTemplateColumns '360px 1fr' → .jn-row-actions
- Line 213: gridTemplateColumns '400px 1fr' → .jn-row-guests
Introduce responsive.css with the jn- layout classes (shell, header,
dashboard rows, scheduler days, activity log) and a single 900px
max-width breakpoint. Base rules are verbatim transcriptions of the
inline styles they replace, keeping desktop layout unchanged. Apply
the first class, .jn-shell, to AppShell's outer container.
VITE_STUB_API=1 npm run dev now renders the full dashboard against
in-memory fixtures (config/status/guests/logs) with no backend, and
freezes Date to 2026-07-09T21:30:00Z for reproducible screenshots.
Statically guarded in main.tsx so Vite drops it from production
builds entirely.
add() is the call handed the Apprise URL, so an exception from it is the
one most likely to quote a secret back. It sat outside the try, so it
escaped unscrubbed into the cycle's exception log.
When the backend is unreachable, fetch throws before an ApiError is built
and the fallback copy said 'Couldn't save changes' for an action that
saved nothing. Give the test its own key.
Delivery failure is a result, not a transport error: the request itself
succeeded. Always answer 200 with one entry per channel, so the UI can
show which channel failed and why. Drops the 400 (no channels) and 502
(delivery failed) responses.
The capture only sees the real failure reason because Apprise dispatches a
single server in the calling thread. Nothing enforced that link; a future
change batching URLs into one engine would silently blank every reason.
Concurrent send_test()/send_run_result() calls on different threads could
attribute one channel's failure reason to another, since the apprise log
capture handler was not thread-scoped. Also close two secret-leak gaps:
scrub each channel's own URL before anything else (covers ntfy, which has
no credential field) and try the colon-safe percent-encoded form of a
secret so tokens containing '/' are fully redacted.
Apprise collapses every channel into one bool, so a failed notification
could only ever say 'delivery failed'. Drive one engine per channel and
capture Apprise's log records for that send, so the reason (DNS failure,
connection refused, 401) is attributed to the channel that produced it.
Secrets are scrubbed from the reason before it leaves the service.
Run notifications log a warning per failed channel; previously a channel
that stopped working was silent.
* fix(docker): build the SPA on node 24 to match CI
The frontend CI job runs on node 24 (npm 11) while the image built the SPA
on node:22-slim (npm 10). The two npm majors disagree on whether a violated
*optional* peer dependency is fatal: npm 11 warns, npm 10 fails with ERESOLVE.
So `npm ci` could pass every CI job and still fail `docker build` on the very
same lockfile — which is exactly what happened on the typescript 7 PR, where
i18next declares `peerOptional typescript@"^5 || ^6"`. CI was green; the image
build was not.
Align the build stage to node:24-slim so CI validates the same toolchain the
shipped image uses. Vite 8 requires node >=22.12, so 24 is in range.
Note this makes the image build as lenient as CI: a violated optional peer no
longer stops the build. That is the correct trade — the two environments agreeing
matters more than one of them tripping on type-only metadata — but it does remove
the tripwire that surfaced the typescript 7 conflict.
* ci: fail on node major drift between Dockerfile and ci.yml
The Dockerfile now carries a "keep this in sync with ci.yml" comment, and
comments drift. Assert it instead: parse the node major from the SPA build
stage and from node-version, and fail the build when they disagree.
Verified the guard actually fails, not just passes: drift (22 vs 24) errors,
a ci.yml declaring two different majors errors, and an unparseable Dockerfile
errors with an explanation rather than dying silently under `set -e`.
* chore(deps-dev): bump typescript from 5.9.3 to 6.0.3
Redone on current main rather than merging the Dependabot branch, which
was 62 commits behind and predated the tsconfig change and the frontend
test harness — its CI run proved nothing about today's tree.
Verified locally: tsc --noEmit clean, vite build succeeds, 18/18 tests
pass, npm audit --omit=dev --audit-level=high reports 0 vulnerabilities.
* ci(dependabot): group peer-coupled packages so majors can go green
react/react-dom, vite/@vitejs/*, and i18next/react-i18next are coupled by
peer dependencies. Bumping one half across a major without the other fails
npm ci with ERESOLVE, so the solo PRs Dependabot opened for each half could
never pass CI — four of five open PRs were blocked this way.
Group each family ahead of the catch-all with no update-types filter, so it
moves as one unit at every version level. The catch-all keeps its minor+patch
scope, preserving the original intent that a security patch never rides in
with an untested framework major.
Raise the npm PR limit 5 -> 10: the cap was exactly hit, which silently stops
Dependabot opening the companion half of a pair.
i18n is listed before react so that react-i18next cannot be claimed by the
react group regardless of how patterns are matched.
secrets.compare_digest() rejects non-ASCII str with TypeError, turning
the unauthenticated /api/dashboard endpoint's 401 path into a 500 when
an attacker sends a non-ASCII ?key= or X-API-Key. Compare as UTF-8
bytes instead, and add a regression test.
Also fix the Homepage integration snippet (UI + docs) referencing the
non-existent /favicon.svg; the app serves its icon at
/assets/joulenap-icon.svg.
Also fix test_notify's no-channels test, which replaced the whole config
(including auth) and tripped the new session/password-hash check; it now
preserves the auth section while resetting notifications.