phase: 99_kb_tree_table_and_back_nav
All verification is complete and green. Final report: **Phase 99 — final verification pass (all 3 tasks already in `complete/`)** - Verified the one-line Description clamp: `.kb-desc-cell` flex wrapper + `.kb-desc-text` ellipsis triad + `nowrap` column + in-cell Edit button + hover `title` + untouched level block (source diffs match D1; unit pins present) - Verified the back-button breadcrumb nav: `applyTarget(target, push)` state-only `pushState`, no-duplicate gate, popstate adopt/reset, `bor:view-refresh` alignment before `loadTree()`, anonymous gate listener-free, router.js untouched (D2) - `uv run pytest --cov=app`: **2180 passed**, coverage **99%** (>90% gate) - `uv run ruff check .`: clean; `uv run pyright`: **0 errors, 0 warnings** - `uv run pytest tests/e2e/test_kb_tree_nav.py -v --no-cov` (isolated, DB up): **4/4 passed** (back-walk, jump-then-back, fresh-nav/active-reclick, measured clamp incl. row-height parity ±4px, computed styles, full text in DOM/title/level block) - Regression suites isolated: `test_kb_tree.py` 8✓, `test_ls_tree_drilldown.py` 3✓, `test_navbar_refresh.py` 7✓, plus `test_sync_summary_visibility.py` 3✓ (marker pin updated to `kb-desc-text kb-summary-pending` — the sanctioned class-pair change) and `test_edit_summaries.py` 4✓ - Criteria 1–4: **met** (each as above). Criterion 5 (atomic commit): left to the harness per executor rules — no `git add`/`commit` run; all changes left in the working tree - No defects found in prior phases; no deviations. Stray `__pycache__/test_zz_smoke_tree_ui.*.pyc` is a leftover (no `.py` on disk) — inert, not touched - Next pending phase: **none** — `todo/` will be empty once the harness moves this phase
This commit is contained in:
+154
-19
@@ -203,6 +203,65 @@
|
||||
* surface passes its pending copy via pendingText — the row's
|
||||
* "Summary pending" marker, the level's D4 note — and its
|
||||
* tooltip via pendingTitle, the row only).
|
||||
*
|
||||
* Phase 99 (task 01, D1) — the ONE-LINE Description clamp: the
|
||||
* owner's long stored descriptions no longer stretch the catalog
|
||||
* rows. A row's Description cell is now ONE flex wrapper
|
||||
* (`.kb-desc-cell` — inside the <td>, which gains no new class) holding
|
||||
* the text span (`.kb-desc-text` — flex: 1 1 auto + min-width: 0 (the
|
||||
* flex item may shrink), overflow: hidden + text-overflow: ellipsis +
|
||||
* white-space: nowrap: the first line that fits, ellipsized — the row
|
||||
* height is ONE line, independent of the description's length) and the
|
||||
* ALWAYS-present Edit button (`.kb-summary-edit`, flex: 0 0 auto — it
|
||||
* never wraps to a second line; its spacing comes from the wrapper's
|
||||
* gap, its old margin-left is gone). The clamp is VISUAL only: the
|
||||
* full text stays in the DOM (the accessible name is unchanged) and
|
||||
* rides the span's title (the hover escape hatch, the house pattern) —
|
||||
* set for real description text whenever non-empty; the phase-98
|
||||
* marker keeps its OWN D4 title (never overridden), the empty cell
|
||||
* carries none. The editor's open/close swaps happen INSIDE the
|
||||
* wrapper (wireDescriptionEdit's row container IS the wrapper — the
|
||||
* <td> always keeps it; the level block's container stays
|
||||
* .kb-level-body), and closeEditor toggles ONLY the
|
||||
* kb-summary-pending class (the base classes survive) while re-deriving
|
||||
* the clamped span's title from the stored text (a stale pre-edit
|
||||
* title cannot survive a save). The level block (`.kb-level p`) keeps
|
||||
* the FULL unclamped description — the escape hatch at the top when
|
||||
* you click in.
|
||||
*
|
||||
* Phase 99 (task 02, D2) — the browser back/forward walks the
|
||||
* breadcrumb: the drill state IS the history state. Every user-
|
||||
* initiated drill (source-row click, folder-link click, breadcrumb-
|
||||
* segment click — all through goTo) goes through applyTarget(target,
|
||||
* true): when the target DIFFERS from the current level, it FIRST
|
||||
* pushes a STATE-ONLY entry — history.pushState({ view: "rag", kb:
|
||||
* target }, "") — then sets current + re-renders. The push is the
|
||||
* STATE only: the second arg is "" and there is no third — the URL
|
||||
* STAYS PUT (no new route, the phase-76 deep-link surface is
|
||||
* untouched; the shell's pathname is the only route contract). A
|
||||
* re-click of the current level pushes NOTHING (no duplicate history
|
||||
* entries). Back/forward: the window popstate (armed ONCE at mount,
|
||||
* admin branch only — the anonymous gate installs no listeners) ADOPTS
|
||||
* the popped entry's state.kb via the no-push variant (applyTarget(kb,
|
||||
* false) — the browser owns its own entries, so adopt/reset never
|
||||
* push); an entry WITHOUT a kb (the boot entry, the router's view
|
||||
* entries, any foreign state) RESETS the drill to the TOP level
|
||||
* (applyTarget({ source: null, folder: null }, false)). Back at the
|
||||
* top level pops the router's view entry — the view leaves, exactly
|
||||
* as today (the router's pathname-based switch, UNCHANGED). The
|
||||
* re-show (bor:view-refresh) aligns the same way BEFORE the re-fetch:
|
||||
* a kb-carrying top entry (the active-link re-click pushed NOTHING —
|
||||
* the drilled entry is still on top) KEEPS the drill; a kb-less entry
|
||||
* (a fresh nav visit) starts at the top level. Boot pushes NOTHING
|
||||
* (the browser's own entry is the base — the phase-76 boot contract),
|
||||
* and the never-stale resetVanishedLocation is UNCHANGED (it resets
|
||||
* current WITHOUT pushing when a re-fetch finds the level gone — it
|
||||
* follows whatever the history state says; no new entry). The router
|
||||
* module is UNTOUCHED: its popstate reads the pathname only, and a
|
||||
* state-only push never changes it (so rag-internal entries never
|
||||
* trigger a view switch; for a foreign entry the router has already
|
||||
* switched views, and this listener's render of the now-hidden view
|
||||
* is harmless).
|
||||
*/
|
||||
|
||||
import { fetchIsAdmin } from "./header.js";
|
||||
@@ -235,7 +294,8 @@ function mkBtn(cls, label) {
|
||||
}
|
||||
|
||||
/* The shared description editor. `container` holds ONLY the
|
||||
* description UI (the row's <td>, or the level block's .kb-level-body)
|
||||
* description UI (the row cell's .kb-desc-cell wrapper — Phase 99
|
||||
* (task 01) — or the level block's .kb-level-body)
|
||||
* — the swap is a bare replaceChildren on it, so any persistent
|
||||
* sibling (the level's <h2>) is untouched. `getTarget()` returns the
|
||||
* current { node, source, folder } at open/save time: a row's is a
|
||||
@@ -282,16 +342,25 @@ function wireDescriptionEdit({
|
||||
* the pre-edit state, including the marker); neither → the empty
|
||||
* text. The Edit button is available again. A cleared node empties
|
||||
* the text and, for the level block, hides the whole block via
|
||||
* onCleared. */
|
||||
* onCleared.
|
||||
* Phase 99 (task 01): the class line toggles ONLY kb-summary-pending
|
||||
* (the base classes survive — the row's span keeps .kb-desc-text, the
|
||||
* clamp; the level's <p> keeps none), and the CLAMPED row span's
|
||||
* title is re-derived from the stored text (the D1 hover escape hatch
|
||||
* — a stale pre-edit title cannot survive a save; the level's <p> is
|
||||
* unclamped — it shows the full text and keeps its no-title
|
||||
* behavior). */
|
||||
function closeEditor(message) {
|
||||
const target = getTarget();
|
||||
const node = target ? target.node : null;
|
||||
const stored = node && typeof node.summary === "string" ? node.summary : "";
|
||||
const pending = node !== null && stored === "" && node.summary_pending;
|
||||
const value = pending && pendingText ? pendingText : stored;
|
||||
textEl.className = pending ? "kb-summary-pending" : "";
|
||||
textEl.classList.toggle("kb-summary-pending", pending);
|
||||
if (pending) {
|
||||
if (pendingTitle) textEl.title = pendingTitle;
|
||||
} else if (stored && textEl.classList.contains("kb-desc-text")) {
|
||||
textEl.title = stored; // D1: full text on hover — the clamp is visual only
|
||||
} else {
|
||||
textEl.removeAttribute("title");
|
||||
}
|
||||
@@ -387,30 +456,48 @@ function wireDescriptionEdit({
|
||||
* the "Summary pending" text + the D4 title; text + color, never color
|
||||
* alone), and the empty cell (neither — the ls rule, unchanged). The
|
||||
* Edit button is UNCHANGED in all three (a manual save creates the row
|
||||
* and clears the marker in place — the editor's success path). */
|
||||
* and clears the marker in place — the editor's success path).
|
||||
* Phase 99 (task 01, D1): the cell is ONE flex row — the <td> holds a
|
||||
* single `div.kb-desc-cell` wrapper with the text span (`.kb-desc-text`
|
||||
* — flexes + ellipsizes, so the row height is ONE line, independent of
|
||||
* the description's length) and the Edit button (`.kb-summary-edit` —
|
||||
* flex: 0 0 auto, it never wraps). The clamp is VISUAL only: the full
|
||||
* text stays in the DOM and rides the span's title (the hover escape
|
||||
* hatch, set for real description text whenever non-empty — the marker
|
||||
* keeps its OWN D4 title, the empty cell carries none). The editor's
|
||||
* open/close swaps happen INSIDE the wrapper (the container is the
|
||||
* wrapper — the <td> always keeps it). */
|
||||
function makeDescCell(node, source, folder, label) {
|
||||
const td = document.createElement("td");
|
||||
/* Phase 99 (task 01, D1): the ONE flex wrapper — the text flexes +
|
||||
* ellipsizes, the button stays fixed (see the docstring above). */
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "kb-desc-cell";
|
||||
const text = document.createElement("span");
|
||||
text.className = "kb-desc-text";
|
||||
if (node && node.summary) {
|
||||
text.textContent = node.summary; // stored description — text node, never innerHTML
|
||||
text.title = node.summary; // full text on hover — the clamp is visual only (D1)
|
||||
} else if (node && node.summary_pending) {
|
||||
// Phase 98 (D4): the summary is due but not stored yet — the marker.
|
||||
text.className = "kb-summary-pending";
|
||||
// Phase 98 (D4): the summary is due but not stored yet — the marker
|
||||
// keeps its OWN title (the hover rule never overrides it).
|
||||
text.classList.add("kb-summary-pending");
|
||||
text.textContent = "Summary pending";
|
||||
text.title = "No stored description yet — the next sync will generate one.";
|
||||
} else {
|
||||
text.textContent = ""; // neither stored nor pending — the empty cell (the ls rule)
|
||||
text.textContent = ""; // neither stored nor pending — the empty cell (the ls rule, no title)
|
||||
}
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "kb-summary-edit";
|
||||
btn.textContent = "Edit";
|
||||
btn.setAttribute("aria-label", `Edit description: ${label}`);
|
||||
td.append(text, btn);
|
||||
wrap.append(text, btn);
|
||||
td.append(wrap);
|
||||
wireDescriptionEdit({
|
||||
editBtn: btn,
|
||||
textEl: text,
|
||||
container: td,
|
||||
container: wrap, // Phase 99 (task 01): the swaps fill the WRAPPER (the <td> keeps it)
|
||||
getTarget: () => ({ node, source, folder }),
|
||||
// Phase 98 (D4): the row's pending display (closeEditor restores
|
||||
// it on a cancel; a save clears the marker in place).
|
||||
@@ -1097,12 +1184,32 @@ export async function mount(root) {
|
||||
return span;
|
||||
}
|
||||
|
||||
/* The drill navigation (client-side, no fetch, no URL change). */
|
||||
function goTo(target) {
|
||||
/* The drill navigation (phase 99, task 02, D2 — client-side, no
|
||||
* fetch; the drill state IS the history state). `applyTarget(target,
|
||||
* push)`: when `push` is true the target DIFFERS from the current
|
||||
* level (a re-click of the current row/segment pushes NO duplicate
|
||||
* entry), it FIRST records a STATE-ONLY entry — history.pushState(
|
||||
* { view: "rag", kb: target }, "") — the second arg "" + no third:
|
||||
* the URL stays the shell's pathname (no new route, the phase-76
|
||||
* deep-link surface is untouched) — then sets `current` +
|
||||
* re-renders. Back/forward (popstate) and the re-show alignment
|
||||
* (bor:view-refresh) adopt with push=false: they never push (the
|
||||
* browser owns those entries). */
|
||||
function applyTarget(target, push) {
|
||||
if (push && (target.source !== current.source || target.folder !== current.folder)) {
|
||||
history.pushState({ view: "rag", kb: target }, "");
|
||||
}
|
||||
current = { source: target.source, folder: target.folder };
|
||||
renderLevel();
|
||||
}
|
||||
|
||||
/* Every user-initiated drill (source row, folder link, breadcrumb
|
||||
* segment) — push a history entry, then drill (D2, phase 99 task
|
||||
* 02). */
|
||||
function goTo(target) {
|
||||
applyTarget(target, true);
|
||||
}
|
||||
|
||||
/* ONE table for every level (phase 97): at the TOP level the rows
|
||||
* are the SOURCES themselves (the ls() equivalence — name, recursive
|
||||
* count, the stored (source, "") description). textContent only. */
|
||||
@@ -1358,13 +1465,41 @@ export async function mount(root) {
|
||||
return;
|
||||
}
|
||||
if (gateEl) gateEl.hidden = true;
|
||||
/* Phase 77 (task 02) + phase 97 (task 04): a user-initiated
|
||||
re-show of this already-mounted view makes the router dispatch
|
||||
bor:view-refresh on the section — re-load the catalog tree then
|
||||
(loadTree is race-tokened: only the newest load touches the
|
||||
DOM). Armed ONLY here, after the whoami gate passed: anonymous
|
||||
shows the gate and must never fetch /api/docs/tree (the
|
||||
phase-16 soft rule the story E2E pins). */
|
||||
root.addEventListener("bor:view-refresh", () => loadTree());
|
||||
/* Phase 77 (task 02) + phase 97 (task 04) + phase 99 (task 02,
|
||||
D2): a user-initiated re-show of this already-mounted view
|
||||
makes the router dispatch bor:view-refresh on the section —
|
||||
FIRST align the drill state with the history state (a
|
||||
kb-carrying entry KEEPS the drill — the active-link re-click
|
||||
pushed nothing, the drilled entry is still on top; a kb-less
|
||||
entry — a fresh nav visit — starts at the TOP level), THEN
|
||||
re-load the catalog tree (loadTree is race-tokened: only the
|
||||
newest load touches the DOM). Armed ONLY here, after the whoami
|
||||
gate passed: anonymous shows the gate and must never fetch
|
||||
/api/docs/tree (the phase-16 soft rule the story E2E pins). */
|
||||
root.addEventListener("bor:view-refresh", () => {
|
||||
const kb = history.state && history.state.kb;
|
||||
if (kb) applyTarget(kb, false);
|
||||
else applyTarget({ source: null, folder: null }, false);
|
||||
loadTree();
|
||||
});
|
||||
/* Phase 99 (task 02, D2): the browser back/forward walks the
|
||||
breadcrumb — a popstate ADOPTS the popped entry's kb via the
|
||||
no-push variant (the browser owns its entries — adopt/reset
|
||||
never push) or, for a kb-LESS entry (the boot entry, the
|
||||
router's view entries, any foreign state), RESETS the drill to
|
||||
the TOP level. The router's own popstate (registered earlier,
|
||||
at shell boot) reads the pathname only: rag-internal entries
|
||||
never change it, so its listener stays a no-op and THIS
|
||||
listener's render is the visible one; for a foreign entry it
|
||||
has already switched the visible view, so this render is a
|
||||
harmless hidden one. Back at the top level pops the router's
|
||||
view entry — the view leaves, exactly as today. Armed ONCE
|
||||
here, admin branch only: the anonymous gate installs no
|
||||
listeners. */
|
||||
window.addEventListener("popstate", (event) => {
|
||||
const kb = event.state && event.state.kb;
|
||||
if (kb) applyTarget(kb, false);
|
||||
else applyTarget({ source: null, folder: null }, false);
|
||||
});
|
||||
loadTree();
|
||||
}
|
||||
|
||||
@@ -1982,11 +1982,31 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
.kb-folders-table { min-width: 480px; }
|
||||
.kb-folders-table td:nth-child(2) { font-family: var(--font); font-size: inherit; max-width: none; }
|
||||
.kb-folders-table td:nth-child(3) {
|
||||
white-space: normal;
|
||||
white-space: nowrap; /* phase 99 (D1): one line — the clamp's budget is the column width */
|
||||
min-width: 18rem;
|
||||
max-width: 44rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
/* Phase 99 (task 01, D1): the Description cell is ONE flex row — the
|
||||
text span flexes + ellipsizes (the row height is one line, independent
|
||||
of the description's length), the Edit button stays fixed (flex: 0 0
|
||||
auto — it never wraps to a second line). The gap replaces the button's
|
||||
old margin-left (the context-spacing rule below keeps only the level
|
||||
block's margin-top). The clamp is visual only: the full text stays in
|
||||
the DOM + on the span's title (hover). */
|
||||
.kb-desc-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.kb-desc-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0; /* lets the flex item shrink — what engages the ellipsis (the .sync-label pattern) */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.folder-link {
|
||||
color: var(--brand-ink); /* 9.0:1 on --surface (the .doc-link pair, row variant) */
|
||||
text-decoration: none;
|
||||
@@ -2024,9 +2044,9 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
}
|
||||
.kb-summary-edit:hover { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand); }
|
||||
.kb-summary-edit[hidden] { display: none; } /* the hidden attr must beat the display above */
|
||||
/* Context spacing: the row cell's button sits after the description
|
||||
text; the level block's sits below the <p>. */
|
||||
.kb-folders-table .kb-summary-edit { margin-left: 0.4rem; }
|
||||
/* Context spacing: the row cell's button spacing comes from the
|
||||
.kb-desc-cell gap (phase 99, task 01 — the margin-left is gone);
|
||||
the level block's sits below the <p>. */
|
||||
.kb-level-body .kb-summary-edit { margin-top: 0.5rem; }
|
||||
.kb-summary-editor {
|
||||
display: block;
|
||||
@@ -2041,6 +2061,11 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
}
|
||||
/* Phase 99 (task 01): the open editor fills the .kb-desc-cell flex
|
||||
wrapper — as a flex item it needs the explicit grow + min-width: 0
|
||||
(the width: 100% base above still serves the level block's non-flex
|
||||
.kb-level-body). */
|
||||
.kb-desc-cell .kb-summary-editor { flex: 1 1 auto; min-width: 0; }
|
||||
.kb-summary-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user