phase: 128_sync_label_mobile
Build and Push Containers / build-and-push-app (push) Successful in 2m32s
Build and Push Containers / build-and-push-db (push) Successful in 12s

Everything verified and 100% green. The earlier rejection was a report-format issue, not a test failure — all work is in the working tree. Final report:

**Phase 128 final pass — verified (all 3 tasks complete; no code defects found)**
- Verified: `#sync-label` parts markup, `setSyncLabel` writer, part builders (A4 untruncated title/aria-live), flex-row CSS + mobile media query — all match design 1:1.
- `uv run pytest --cov=app --cov-report=term`: 3024 passed; TOTAL coverage **99%** (>90% gate).
- `uv run pytest tests/e2e/test_sync_label_mobile.py -v --no-cov`: 4 passed (119s, isolated).
- `uv run ruff check .`: clean; `uv run pyright`: 0 errors.
- Criteria: (1) mobile 390×844 label in-viewport, count visible in import+summaries — ✅ E2E; (2) desktop strings/cap/title unchanged — ✅ E2E+unit; (3) E2E isolated green — ✅; (4) full suite green — ✅.
- Docs: README.md fixed 5 pre-existing gaps (PDF phase-126 section + `pdf` token, hidden-folders toggle ph.105, document dates ph.106, private git-source masked tokens ph.121, doc-draft Discard ph.115); AGENTS.md verified consistent — no changes.
- Notable: double-`-q` (addopts `-q` + CLI `-q`) suppresses pytest's summary line — standard `-qq` behavior, not a bug; exit codes confirm green.
- Next pending phase: `129_sync_chunk_progress`.
This commit is contained in:
2026-09-27 23:58:57 -04:00
parent 6e654df387
commit 1139f007cf
34 changed files with 2203 additions and 173 deletions
+121 -43
View File
@@ -300,6 +300,34 @@
* The glyph is static SVG (aria-hidden — the alt text is the
* accessible content); the box + img are properties only, never
* innerHTML with document-derived data (the house rule).
*
* Phase 128 (task 01) — the sync label becomes PARTS: the running
* label's string is split into a shrinks-to-fit MAIN part (prefix +
* the file/folder path — the part CSS may ellipsize) and a pinned
* COUNT part ("(n/m)" — the position, NEVER clipped), so on a phone
* the file path ellipsizes to fit while the count stays on-screen
* (the phase-64 bug: with max-width lifted, the inline-block sized to
* its full content and the count ran off-screen). #sync-label now
* ships TWO child spans — .sync-label-main (the initial idle text
* lives there so the pre-JS label renders) + .sync-label-count
* (hidden + empty by default) — and the label builders no longer
* return one string: fmtSyncLabel / fmtSyncPhaseLabel return the
* PARTS ({ main, count } — the same decision tree, the same byte-
* identical copy), and ONE writer — setSyncLabel(mainText,
* countText) — lands them: mainText → the main part's textContent,
* countText → the count part's textContent with the hidden toggle
* (absent count → hidden + empty), then the UNTRUNCATED full label
* — mainText + (countText ? " " + countText : "") — onto the button
* title + #sync-result (A4 — the screen reader hears the whole
* thing; the visible span carries main + count, the space between
* them is the CSS gap). Every other #sync-label writer — the idle /
* settle labels ("Sync sources", "Synced HH:MM"), the 202/409 click,
* the initSyncButton re-attach — goes through setSyncLabel(main)
* (count hidden). The full-string shapes are byte-identical to the
* pre-task labels ("Syncing…", "Syncing… <file>",
* "Syncing… <file> (n/m)", "Importing", "Writing KB overview…",
* "Summarizing folders… <folder> (n/m)"); textContent only — no HTML
* strings (the house rule).
*/
import { fetchIsAdmin } from "./header.js";
@@ -598,12 +626,17 @@ export async function mount(root) {
* upload the same way; a terminal upload is a no-op there (the
* boot-time loadTree() already shows the current catalog).
*
* Elements: #sync-btn (the button), #sync-label (the text),
* #sync-icon (the spinner icon), #sync-result (aria-live result
* line), #sync-error-banner / #sync-error-text (error banner).
* Elements: #sync-btn (the button), #sync-label (the text —
* Phase 128 (task 01): the PARTS — .sync-label-main, the
* shrinks-to-fit prefix + file/folder, + .sync-label-count, the
* pinned "(n/m)" position (hidden when absent)), #sync-icon (the
* spinner icon), #sync-result (aria-live result line),
* #sync-error-banner / #sync-error-text (error banner).
*/
const syncBtn = root.querySelector("#sync-btn");
const syncLabel = syncBtn ? syncBtn.querySelector(".sync-label") : null;
const syncLabelMain = syncLabel ? syncLabel.querySelector(".sync-label-main") : null;
const syncLabelCount = syncLabel ? syncLabel.querySelector(".sync-label-count") : null;
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
const syncResult = root.querySelector("#sync-result");
const syncErrorBanner = root.querySelector("#sync-error-banner");
@@ -632,45 +665,86 @@ export async function mount(root) {
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/* Phase 64 (task 04): the live-file label. `kind` picks the prefix —
* "sync" → "Syncing…", "upload" → "Importing" (the background run's
* word, A3). The current file — the status endpoint's full
* source/relative/path (A4) — is appended while one is being processed;
* the BARE prefix shows during the clone/pull (sync), before any file
* is indexed. Phase 90: the upload run is unpack + register only (no
* scan), so its status never carries a file or counts — the
* "Importing" label is always the bare one. The counts appear only
* once the import has started (total > 0). CSS ellipsizes the button
* label; the same untruncated text goes to the button title +
* #sync-result (the aria-live announcer). Phase 98 (task 02): the
* SYNC job's post-import phases (overview / summaries) take their
* own label via fmtSyncPhaseLabel (below) — this builder keeps that
/* Phase 128 (task 01): the label PARTS writer — the SINGLE point
* that writes #sync-label. mainText lands in the MAIN part
* (.sync-label-main — the shrinks-to-fit prefix + file/folder, the
* part CSS ellipsizes); countText lands in the COUNT part
* (.sync-label-count — the pinned "(n/m)" position, NEVER clipped):
* present → its textContent + hidden=false, absent → emptied +
* hidden=true (the count span is hidden whenever there is no
* "(n/m)" to show). Then the UNTRUNCATED full label —
* mainText + (countText ? " " + countText : "") — rides the button
* title + #sync-result (the aria-live announcer) exactly as before
* (A4 — the screen reader hears the whole thing; the visible span
* carries main + count, the space between them is the CSS gap).
* textContent only — the house rule, no HTML strings. Every label
* state goes through here (the running builders, the idle / settle
* labels, the 202/409 click, the re-attach). */
function setSyncLabel(mainText, countText = "") {
if (syncLabelMain) syncLabelMain.textContent = mainText;
if (syncLabelCount) {
if (countText) {
syncLabelCount.textContent = countText;
syncLabelCount.hidden = false;
} else {
syncLabelCount.textContent = "";
syncLabelCount.hidden = true;
}
}
const full = countText ? `${mainText} ${countText}` : mainText;
if (syncBtn) syncBtn.title = full; // A4: the untruncated label rides the title, always
if (syncResult) syncResult.textContent = full; // A4: the announcer hears the whole thing
}
/* Phase 64 (task 04) + Phase 128 (task 01, the PARTS rework): the
* live-file label. `kind` picks the prefix — "sync" → "Syncing…",
* "upload" → "Importing" (the background run's word, A3). The
* current file — the status endpoint's full source/relative/path
* (A4) — lands in the MAIN part while one is being processed; the
* BARE prefix shows during the clone/pull (sync), before any file
* is indexed. Phase 90: the upload run is unpack + register only
* (no scan), so its status never carries a file or counts — the
* "Importing" label is always the bare one. The counts — the PINNED
* count part, never clipped (Phase 128) — appear only once the
* import has started (total > 0). The builders return the PARTS
* ({ main, count }); setSyncLabel writes them (the untruncated full
* string — main + " " + count — rides the button title +
* #sync-result, A4). Phase 98 (task 02): the SYNC job's
* post-import phases (overview / summaries) take their own label
* via fmtSyncPhaseLabel (below) — this builder keeps that
* prelude/import fall-through byte-identical and serves the UPLOAD
* job unchanged (its status has no phase — D2). */
function fmtSyncLabel(kind, currentFile, done, total) {
const prefix = kind === "upload" ? "Importing" : "Syncing…";
let label = currentFile ? `${prefix} ${currentFile}` : prefix;
if (total > 0) label += ` (${done}/${total})`;
return label;
const main = currentFile ? `${prefix} ${currentFile}` : prefix;
const count = total > 0 ? `(${done}/${total})` : "";
return { main, count };
}
/* Phase 98 (task 02, D2): the SYNC job's phase-aware running label
* — the post-import span where the file count sits still (the
* owner's "the number pauses for a really long time"): "overview"
* → "Writing KB overview…"; "summaries" → "Summarizing folders…
* <source/folder> (n/m)" — the folder part is omitted while
* current_summary is null (the phase's first poll); anything else
* (the null prelude, "import") → the phase-64 fmtSyncLabel
* fall-through, byte-identical (same builder, same fields, same
* order). The UPLOAD job never passes a status (its endpoint has no
* phase — D2) and keeps the bare fmtSyncLabel label. The untruncated
* result rides the button title + #sync-result like every running
* label (A4). */
/* Phase 98 (task 02, D2) + Phase 128 (task 01, the PARTS rework):
* the SYNC job's phase-aware running label — the post-import span
* where the file count sits still (the owner's "the number pauses
* for a really long time"): "overview" → "Writing KB overview…"
* (no count part); "summaries" → main "Summarizing folders…
* <source/folder>" + count "(n/m)" — the folder part is omitted
* while current_summary is null (the phase's first poll) and the
* COUNT IS ALWAYS EMITTED in this phase (the hook always carries a
* total); anything else (the null prelude, "import") → the
* phase-64 fmtSyncLabel parts, byte-identical (same builder, same
* fields, same order). The UPLOAD job never passes a status (its
* endpoint has no phase — D2) and keeps the bare fmtSyncLabel
* parts. The untruncated full label rides the button title +
* #sync-result like every running label (A4 — setSyncLabel). */
function fmtSyncPhaseLabel(status) {
if (status.phase === "overview") return "Writing KB overview…";
if (status.phase === "overview") {
return { main: "Writing KB overview…", count: "" };
}
if (status.phase === "summaries") {
const folder = status.current_summary ? ` ${status.current_summary}` : "";
return `Summarizing folders…${folder} (${status.summaries_done}/${status.summaries_total})`;
return {
main: `Summarizing folders…${folder}`,
count: `(${status.summaries_done}/${status.summaries_total})`,
};
}
// null phase (the prelude) or "import" — today's byte-identical
// sync label (the phase-64 contract, unchanged).
@@ -702,26 +776,26 @@ export async function mount(root) {
* is phase-aware (fmtSyncPhaseLabel: "Writing KB overview…" /
* "Summarizing folders… <folder> (n/m)"); the UPLOAD job and the
* 202/409 click (no status yet) keep the bare fmtSyncLabel label.
* Same mechanics as before (disabled, aria-busy, spinning icon, no
* is-error) plus: the FULL untruncated label on the button title
* (set for EVERY running label — the phase-64 file-only rule is
* adjusted for the phase labels) and in #sync-result (the aria-live
* announcer reads it; CSS ellipsizes the button's label span only).
* Phase 128 (task 01): the builders return the PARTS and the single
* writer (setSyncLabel) lands them — the main part (CSS-ellipsized),
* the pinned count part (hidden when absent), and the FULL
* untruncated label on the button title + #sync-result (A4 — set
* for EVERY running label; CSS ellipsizes the main part only). Same
* never-stale mechanics as before (disabled, aria-busy, spinning
* icon, no is-error).
*/
function enterSyncRunningState(kind, currentFile, done, total, status) {
if (!syncBtn) return;
syncBtn.disabled = true;
syncBtn.setAttribute("aria-busy", "true");
const label =
const { main, count } =
kind === "sync" && status
? fmtSyncPhaseLabel(status)
: fmtSyncLabel(kind, currentFile, done, total);
syncBtn.title = label; // A4: the untruncated label rides the title, always
setSyncLabel(main, count); // the parts + the A4 untruncated title / announcer
syncBtn.setAttribute("aria-label", "Sync sources");
syncBtn.classList.remove("is-error");
if (syncIcon) syncIcon.classList.add("is-spinning");
if (syncLabel) syncLabel.textContent = label;
if (syncResult) syncResult.textContent = label;
}
function settleSyncButton(label) {
@@ -732,7 +806,11 @@ export async function mount(root) {
syncBtn.setAttribute("aria-label", "Sync sources");
syncBtn.classList.remove("is-error");
if (syncIcon) syncIcon.classList.remove("is-spinning");
if (syncLabel) syncLabel.textContent = label;
// Phase 128 (task 01): the idle / settle labels ("Sync sources",
// "Synced HH:MM") go through the parts writer — count hidden
// (absent), the label's full string rides the title + #sync-result
// (A4, as with every label state).
setSyncLabel(label);
}
function showSyncError(detail) {