feat(sync): fail fast with a modal when a model is unavailable
TODO.md L4: with a dead model endpoint the sync discovered it only mid-import, after slow clones — and a tooltip on the button is not a readable error. - app/rag/llm.py: ModelUnavailableError + check_models(llm) — a tiny pre-sync probe (one short embedding + one 1-token-scale completion) that fails naming the unavailable model (embed first, then the summary model); the sync sanitizer still masks credentials. - app/api/sync.py: the probe is step 1 of _run_sync — before source resolution and before any clone_or_pull; a model failure is just another 'failed' state (no new endpoint, A10/A12 untouched). - frontend/assets/header.js: applySyncFailure now also opens the module-owned error modal (every page carrying #sync-btn, zero page-markup changes): lazily built backdrop + role=alertdialog panel, error text via textContent, close via button / Esc / backdrop, focus in-and-out to #sync-btn (with a body→#sync-btn fallback — the run's disabled button drops focus to <body>). - frontend/assets/styles.css: the modal on the phase-08 error palette (z-index above the header, .is-open open/close, reduced-motion stilling, 44px close target). - Tests: probe unit tests (both up / embed down / summary down / custom model names), sync integration (fail-fast before any clone, probe-before-effective_sources ordering, credential masking, healthy regression), the phase-41 source pins, and the story E2E (two module apps on distinct ports — dead endpoint on a closed loopback port vs session mock: ≤10 s fail-fast + modal contract, all three dismissal paths with focus out to #sync-btn, button title/.is-error + Sources banner untouched, healthy phase-32 lifecycle regression to 'Synced HH:MM'). E2E (isolation): test_sync_model_down.py 4/4, test_sync_button.py 3/3, test_git_sources_admin.py 6/6, test_local_directory_sources.py 3/3; unit+integration 721 passed, app/ coverage 99%; ruff + pyright clean.
This commit is contained in:
@@ -381,6 +381,84 @@ html::after {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Phase 41 (2026-08-27, TODO.md L4): the module-owned sync failure
|
||||
modal — lazily built by header.js and appended to <body>, so every
|
||||
page carrying #sync-btn gets it with zero page-markup changes.
|
||||
Phase-08 error palette (PLAN §7.2): the panel sits on the error
|
||||
surface --err-bg with a 1px --err-line border (the amber
|
||||
--accent-line stays deflection-only), its title in --ink (≈14.5:1
|
||||
on --err-bg) and the error text in --err-ink (9.1:1 on --err-bg).
|
||||
The backdrop dims the page with --bg at 82% — no blur (the phase-08
|
||||
no-blur anchor). Stacking: z-index 1000, the same overlay contract
|
||||
as the doc-modal — above the sticky header (20) and the skip-link
|
||||
(100). Open/close via .is-open (visibility/opacity — the closed
|
||||
modal is unfocusable and inert); the close button keeps the global
|
||||
3px :focus-visible outline and the 44px touch floor. */
|
||||
.sync-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: rgba(10, 14, 23, 0.82);
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
.sync-modal-backdrop.is-open {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
.sync-modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
background: var(--err-bg);
|
||||
border: 1px solid var(--err-line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
/* right padding clears the absolutely-positioned close button */
|
||||
padding: 1.1rem 3rem 1.25rem 1.25rem;
|
||||
}
|
||||
#sync-modal-title {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 1.1rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
#sync-modal-error {
|
||||
margin: 0 0 1rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.9rem;
|
||||
color: var(--err-ink);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sync-modal-close {
|
||||
position: absolute;
|
||||
top: 0.3rem;
|
||||
right: 0.3rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--err-line);
|
||||
background: transparent;
|
||||
color: var(--err-ink);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sync-modal-close:hover { background: rgb(239 68 68 / 0.15); }
|
||||
/* No motion under reduced motion (same opt-out pattern as the
|
||||
phase-25 background layers and the doc-modal backdrop). */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sync-modal-backdrop { transition: none; }
|
||||
}
|
||||
|
||||
/* "Tuning" toggle (phase 15): ghost pill like New chat + a mono count
|
||||
badge (brand-ink on brand-soft ≈6.9:1). The label is visually-hidden
|
||||
(not removed) below 640px so the accessible name keeps the word.
|
||||
|
||||
Reference in New Issue
Block a user