phase: 156_table_ux_polish
Everything is verified. All gates green on the current working tree. Final report: **Phase 156 — final verification pass (all 4 task files present in `complete/` — no outstanding work)** - **Verified (not re-implemented):** bounded-panel `.table-wrap` (`max-height: min(70vh, 64rem)`, `overflow: auto`, opaque sticky `th` via `var(--brand-soft)`), `.cell-clip` ellipsis rule + the five label families in the three renderers, the added aria-labels, unit pins (incl. `.md-table-wrap` byte-unchanged scope guard, `#git-sources-table-wrap` harmony — no divergent rule), dedicated E2E suite (sticky ×3 tables, horizontal contract, computed-style ellipsis + reachable full text, mobile hamburger + scroll + pin) - **Tests/lint/coverage:** `uv run pytest --cov=app --cov-report=term-missing` → **3570 passed, 99% on `app/`** (gate >90%); `uv run pytest tests/e2e/test_table_ux_polish.py -v --no-cov` (DB up) → **6/6 passed in isolation**; `uv run ruff check . && uv run pyright` → **clean** (0 errors) - **Completion criteria:** sticky header all 3 tables (exact-0 offset while `scrollTop > 0`) ✅ · horizontal scroll unchanged ✅ · ellipsis computed style + actual clip + title/aria reachable ✅ · mobile ≤640px contract ✅ · full suite/E2E/lint/coverage ✅ · atomic commit: **harness commits this phase after the pass** (per pipeline protocol — no git history commands run by me) - **Documentation:** AGENTS.md phase-156 wording (bounded panels, sticky headers, `.cell-clip` + aria-label idiom) matches the implementation exactly — verified consistent; README.md reviewed in full (pages/URLs vs registered routes, all `.env.example` vars documented, Quick Start, eval/llm scripts, compose prod profile, E2E examples) — verified consistent, no changes needed (README describes no table layout/scroll, so no phase-156 mention required) - **Deviations:** none — all work was already in the working tree; this pass re-verified every gate and the docs - **Next pending phase:** `157_chat_chip_cap`
This commit is contained in:
@@ -540,7 +540,15 @@ export async function mount(root) {
|
||||
"aria-label",
|
||||
`Index hidden folders for ${kindLabel} source: ${value}`,
|
||||
);
|
||||
hiddenLabel.append(hiddenBox, document.createTextNode("Hidden"));
|
||||
/* Phase 156 (task 02, A3): the visible "Hidden" text is the
|
||||
.cell-clip span — a squeezed cell shows "…" instead of a
|
||||
hard cut; the full text stays reachable (the checkbox's
|
||||
aria-label names the full source — the phase-105 idiom,
|
||||
the label's own title carries the explanation). */
|
||||
const hiddenClip = document.createElement("span");
|
||||
hiddenClip.className = "cell-clip";
|
||||
hiddenClip.textContent = "Hidden"; // never innerHTML (XSS contract)
|
||||
hiddenLabel.append(hiddenBox, hiddenClip);
|
||||
hiddenBox.addEventListener("change", () => toggleHidden(s, hiddenBox));
|
||||
actTd.appendChild(hiddenLabel);
|
||||
/* Phase 89: the per-row ignore-paths editor trigger (A3: stored
|
||||
@@ -556,14 +564,25 @@ export async function mount(root) {
|
||||
"aria-label",
|
||||
`Edit ignored paths for ${kindLabel} source: ${value}`,
|
||||
);
|
||||
ignoreBtn.textContent = "Ignore paths";
|
||||
/* Phase 156 (task 02, A3): the static text label is the
|
||||
.cell-clip span — ellipsizes in a squeezed cell (the
|
||||
aria-label above keeps the full context reachable). */
|
||||
const ignoreClip = document.createElement("span");
|
||||
ignoreClip.className = "cell-clip";
|
||||
ignoreClip.textContent = "Ignore paths"; // never innerHTML (XSS contract)
|
||||
ignoreBtn.append(ignoreClip);
|
||||
ignoreBtn.addEventListener("click", () => openIgnoreEditor(s, ignoreBtn));
|
||||
actTd.appendChild(ignoreBtn);
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "git-source-remove";
|
||||
btn.setAttribute("aria-label", `Remove ${kindLabel} source: ${value}`);
|
||||
btn.innerHTML = REMOVE_ICON + "<span>Remove</span>";
|
||||
/* Phase 156 (task 02, A3): the visible text is the .cell-clip
|
||||
span (a static template — the icon is static SVG, the
|
||||
established exception to the textContent house rule); a
|
||||
squeezed cell ellipsizes it (the aria-label above names
|
||||
the full source). */
|
||||
btn.innerHTML = REMOVE_ICON + '<span class="cell-clip">Remove</span>';
|
||||
// Phase 69: opens the confirmation modal (the native confirm()
|
||||
// retired) — the modal names the source and states the
|
||||
// full-removal policy; the per-row error span is retired (the
|
||||
|
||||
@@ -35,6 +35,11 @@
|
||||
* "Delete? [Yes] [No]" (focus moves to Yes, so the confirm is
|
||||
* keyboard-reachable), Yes fires the DELETE and removes the row,
|
||||
* No (or a failed request) keeps it.
|
||||
* Phase 156 (task 02, A3): the Create link / Copy / Unshare /
|
||||
* Delete visible texts are .cell-clip spans — a squeezed
|
||||
* Actions/Share cell shows "…" instead of a hard cut; every one
|
||||
* of these buttons' aria-label names the chat title (the
|
||||
* phase-66 idiom), so the full text stays reachable.
|
||||
*
|
||||
* The share copy has the owner-locked inline-link fallback: a
|
||||
* non-secure (http) homelab origin rejects navigator.clipboard, so the
|
||||
@@ -214,7 +219,14 @@ export async function mount(root) {
|
||||
del.type = "button";
|
||||
del.className = "history-delete";
|
||||
del.setAttribute("aria-label", `Delete saved chat: ${chat.title}`);
|
||||
del.textContent = "Delete";
|
||||
/* Phase 156 (task 02, A3): the visible text is the .cell-clip
|
||||
span — a squeezed Actions cell shows "…" instead of a hard
|
||||
cut; the aria-label above names the chat title (the
|
||||
phase-66 idiom), so the full text stays reachable. */
|
||||
const delClip = document.createElement("span");
|
||||
delClip.className = "cell-clip";
|
||||
delClip.textContent = "Delete"; // textContent only (XSS contract)
|
||||
del.append(delClip);
|
||||
|
||||
function restoreDelete() {
|
||||
cell.replaceChildren(del);
|
||||
@@ -326,7 +338,13 @@ export async function mount(root) {
|
||||
create.type = "button";
|
||||
create.className = "history-share-create";
|
||||
create.setAttribute("aria-label", `Create share link: ${chat.title}`);
|
||||
create.textContent = "Create link";
|
||||
/* Phase 156 (task 02, A3): the visible text is the .cell-clip
|
||||
span (the aria-label above names the chat title — the
|
||||
phase-66 idiom, the full text stays reachable). */
|
||||
const createClip = document.createElement("span");
|
||||
createClip.className = "cell-clip";
|
||||
createClip.textContent = "Create link"; // textContent only (XSS contract)
|
||||
create.append(createClip);
|
||||
create.addEventListener("click", () => void createShareLink(chat, cell, create));
|
||||
cell.replaceChildren(create);
|
||||
}
|
||||
@@ -340,13 +358,22 @@ export async function mount(root) {
|
||||
copy.type = "button";
|
||||
copy.className = "history-share-copy";
|
||||
copy.setAttribute("aria-label", `Copy share link: ${chat.title}`);
|
||||
copy.textContent = "Copy";
|
||||
/* Phase 156 (task 02, A3): the visible texts are .cell-clip
|
||||
spans (the aria-labels above name the chat title — the
|
||||
phase-66 idiom, the full text stays reachable). */
|
||||
const copyClip = document.createElement("span");
|
||||
copyClip.className = "cell-clip";
|
||||
copyClip.textContent = "Copy"; // textContent only (XSS contract)
|
||||
copy.append(copyClip);
|
||||
copy.addEventListener("click", () => void copyRowShareLink(chat, cell));
|
||||
const unshare = document.createElement("button");
|
||||
unshare.type = "button";
|
||||
unshare.className = "history-unshare";
|
||||
unshare.setAttribute("aria-label", `Unshare saved chat: ${chat.title}`);
|
||||
unshare.textContent = "Unshare";
|
||||
const unshareClip = document.createElement("span");
|
||||
unshareClip.className = "cell-clip";
|
||||
unshareClip.textContent = "Unshare"; // textContent only (XSS contract)
|
||||
unshare.append(unshareClip);
|
||||
function restoreShared() {
|
||||
cell.replaceChildren(copy, unshare);
|
||||
unshare.focus({ preventScroll: true }); // focus returns to the (restored) control
|
||||
|
||||
@@ -627,7 +627,11 @@ function wireDescriptionEdit({
|
||||
* 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). */
|
||||
* wrapper — the <td> always keeps it).
|
||||
* Phase 156 (task 02, A3): the Edit button's visible text is the
|
||||
* .cell-clip span — a squeezed cell shows "…" instead of a hard cut;
|
||||
* the aria-label names the row (the source / the source/folder path
|
||||
* — the phase-97 idiom), so the full text stays reachable. */
|
||||
function makeDescCell(node, source, folder, label) {
|
||||
const td = document.createElement("td");
|
||||
/* Phase 99 (task 01, D1): the ONE flex wrapper — the text flexes +
|
||||
@@ -651,7 +655,15 @@ function makeDescCell(node, source, folder, label) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "kb-summary-edit";
|
||||
btn.textContent = "Edit";
|
||||
/* Phase 156 (task 02, A3): the visible text is the .cell-clip
|
||||
span — a squeezed Description cell shows "…" instead of a hard
|
||||
cut; the aria-label below names the row (the source / the
|
||||
source/folder path — the phase-97 idiom), so the full text
|
||||
stays reachable. */
|
||||
const clip = document.createElement("span");
|
||||
clip.className = "cell-clip";
|
||||
clip.textContent = "Edit"; // never innerHTML (XSS contract)
|
||||
btn.append(clip);
|
||||
btn.setAttribute("aria-label", `Edit description: ${label}`);
|
||||
wrap.append(text, btn);
|
||||
td.append(wrap);
|
||||
|
||||
+103
-22
@@ -2524,14 +2524,74 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
Tokens view carried the identical defect). Zero-offset positioning
|
||||
changes no layout: the spans stay clipped by their own
|
||||
clip: rect(0 0 0 0) + 1px box, and cards without such spans (the
|
||||
RAG .docs-table, the git-sources table) render unchanged. */
|
||||
RAG .docs-table, the git-sources table) render unchanged.
|
||||
Phase 156 (task 01, owner-locked A1): the card is ALSO the
|
||||
vertical scroll region — max-height: min(70vh, 64rem) bounds it
|
||||
(a 15-row table scrolls, a 3-row one fits without a scrollbar)
|
||||
and overflow: auto (superseding the phase-86 overflow-x pin —
|
||||
the tables' min-width still triggers the in-card horizontal
|
||||
scroll at narrow widths) makes the card the sticky th's
|
||||
scrolling ancestor; an unbounded card never scrolls vertically,
|
||||
so a sticky header in it would scroll away with the page. */
|
||||
.table-wrap {
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow-x: auto;
|
||||
overflow: auto;
|
||||
max-height: min(70vh, 64rem);
|
||||
}
|
||||
/* Phase 156 (task 01): the ONE sticky-header rule for every
|
||||
catalogue table inside a .table-wrap card (the RAG .docs-table,
|
||||
the .git-sources-table, the .history-table, the .tokens-table —
|
||||
the per-table th rules keep the text styling only). top: 0 pins
|
||||
the header row to the card top while the card's own vertical
|
||||
scroll moves; z-index: 1 keeps the pinned th above the scrolled
|
||||
cells. OPAQUE background: --brand-soft is a solid hex (no alpha),
|
||||
so rows never show through the pinned header (B3: no new palette
|
||||
variable; a themed page's #bor-theme override of the token
|
||||
handles the color — same behavior, B4). */
|
||||
.table-wrap table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--brand-soft);
|
||||
}
|
||||
/* Phase 156 (task 02, owner-locked A3): the ellipsis hint on the
|
||||
catalogue tables' CLIPPED action labels. The class sits on the
|
||||
LABEL SPAN, never the cell (the docs-table cell padding is
|
||||
untouched). The five families: the Sources "Hidden" checkbox
|
||||
text + "Ignore paths" + "Remove" (git-sources.js makeRow), the
|
||||
RAG folder table's "Edit" (sources.js makeDescCell), the
|
||||
History "Copy" / "Unshare" / "Create link" / "Delete" (history.js).
|
||||
max-width: 12rem is GENEROUS — every one of the five labels
|
||||
("Edit" ~2.4rem … "Ignore paths" ~6.3rem at 0.78–0.82rem)
|
||||
fits it, so a cell that FITS shows its full text (the clamp
|
||||
engages only in the CLIPPED case — the audit's ~1,009px
|
||||
rightmost columns), where the label shows "…" instead of the
|
||||
card edge's hard cut. Mechanism: the block container's
|
||||
overflow hidden + text-overflow ellipsis + nowrap is the
|
||||
house .sync-label shrinkable-item pattern; the SPAN's
|
||||
min-width: 0 lets the flex item shrink below its text width —
|
||||
but a flex item shrinks only when its FLEX PARENT is
|
||||
constrained, so the hosting controls carry the supporting width
|
||||
rules (the max-width: 100% + min-width: 0 added to each block
|
||||
below): without them the inline-flex buttons keep their content
|
||||
size, overflow the squeezed cell, and the card edge hard-cuts
|
||||
the label again (the pre-phase defect). The FULL text stays
|
||||
reachable — every one of these controls' aria-label names the
|
||||
row's identity (the source's URL/path — the phase-89/105 idiom;
|
||||
the chat title — the phase-66 idiom; the source/folder name —
|
||||
the phase-97 idiom), so the clip is visual only. B5 idiom
|
||||
intact: the labels are text (text + color, never color alone). */
|
||||
.cell-clip {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
max-width: 12rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.docs-table {
|
||||
width: 100%;
|
||||
@@ -2546,13 +2606,10 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
white-space: nowrap;
|
||||
}
|
||||
.docs-table th {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand-ink);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
.docs-table td:nth-child(2) { font-family: var(--mono); font-size: 0.82rem; max-width: 30rem; overflow: hidden; text-overflow: ellipsis; }
|
||||
.docs-table tbody tr:hover { background: var(--bg); }
|
||||
@@ -2720,6 +2777,14 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
/* Phase 156 (task 02, A3): the width support for the label span's
|
||||
clamp — the flex item is clamped to the cell + shrinkable
|
||||
(min-width: 0), so a squeezed Description cell ellipsizes the
|
||||
"Edit" label instead of overflowing the card edge. The level
|
||||
block's use (the non-flex .kb-level-body) is unaffected: its
|
||||
natural width never reaches the clamp. */
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 24px;
|
||||
padding: 0.15rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
@@ -3384,16 +3449,15 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
.ignore-editor-save:hover:not(:disabled) { background: var(--brand-hover); }
|
||||
|
||||
/* The list: the Sources page's table pattern — full width in the
|
||||
72rem frame, surface card, horizontally scrollable wrapper (the
|
||||
URL column never wraps or ellipsizes: long URLs, credentials
|
||||
included, scroll the wrapper instead of truncating). */
|
||||
#git-sources-table-wrap {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow-x: auto;
|
||||
}
|
||||
72rem frame. The URL column never wraps or ellipsizes: long
|
||||
URLs, credentials included, scroll the wrapper instead of
|
||||
truncating (the .git-sources-table 560px min-width keeps the
|
||||
in-wrapper horizontal scroll). Phase 156 (task 01): the id
|
||||
card's duplicate background/border/radius/shadow/overflow
|
||||
declarations were REMOVED — the wrapper carries
|
||||
class="table-wrap", so it inherits the base card + the
|
||||
bounded-panel max-height + the sticky rule (the one-rule
|
||||
contract: no divergent unbounded override). */
|
||||
.git-sources-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -3407,13 +3471,10 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
white-space: nowrap;
|
||||
}
|
||||
.git-sources-table th {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand-ink);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
/* Location cell: mono at the Sources-table size; the <code> is plain
|
||||
(no chip background — the cell IS the mono readout). Phase 38:
|
||||
@@ -3448,6 +3509,10 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
/* Phase 156 (task 02, A3): the width support for the "Remove"
|
||||
label span's clamp (same rationale as .git-source-hidden
|
||||
above). */
|
||||
max-width: 100%;
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
flex: 0 0 auto;
|
||||
@@ -3476,6 +3541,10 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* Phase 156 (task 02, A3): the width support for the "Ignore
|
||||
paths" label span's clamp (same rationale as .git-source-hidden
|
||||
above). */
|
||||
max-width: 100%;
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
flex: 0 0 auto;
|
||||
@@ -3525,6 +3594,11 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
/* Phase 156 (task 02, A3): the width support for the "Hidden"
|
||||
label span's clamp (the label is an inline-flex child of the
|
||||
cell — clamped to the cell so the span can ellipsize in a
|
||||
squeezed column instead of overflowing the card edge). */
|
||||
max-width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
@@ -3678,7 +3752,6 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
vertical-align: middle;
|
||||
}
|
||||
.history-table th {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand-ink);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
@@ -3730,9 +3803,17 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
}
|
||||
/* Actions: the Delete ghost button (the tuning row-action language)
|
||||
+ the inline two-step confirm pair (phase 50 task 04). */
|
||||
.history-actions { display: inline-flex; align-items: center; gap: 0.4rem; }
|
||||
/* Phase 156 (task 02, A3): the width support for the action
|
||||
labels' clamp — both wrappers are clamped to the cell (max-width
|
||||
100% of the td's content box), and the flex-item buttons are
|
||||
shrinkable (min-width: 0), so a squeezed Actions/Share column
|
||||
ellipsizes the label instead of the card edge hard-cutting it.
|
||||
Mobile (≤640px) is unaffected in kind: the flex-wrap there wraps
|
||||
the buttons onto their own lines before the clamp engages. */
|
||||
.history-actions { display: inline-flex; align-items: center; gap: 0.4rem; max-width: 100%; }
|
||||
.history-delete {
|
||||
min-height: 44px;
|
||||
min-width: 0;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -3785,12 +3866,13 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
row-action language of .history-delete: --line border, transparent
|
||||
fill, ink-soft (>=5.1:1), ≥44px comfortable target, focus-visible
|
||||
via the global 3px rule; hover takes the brand pair (6.9:1). */
|
||||
.history-share { display: inline-flex; align-items: center; gap: 0.4rem; flex-wrap: nowrap; }
|
||||
.history-share { display: inline-flex; align-items: center; gap: 0.4rem; flex-wrap: nowrap; max-width: 100%; }
|
||||
.history-share-cell { white-space: nowrap; }
|
||||
.history-share-create,
|
||||
.history-share-copy,
|
||||
.history-unshare {
|
||||
min-height: 44px;
|
||||
min-width: 0; /* phase 156 (task 02, A3) — the shrinkable flex item (the clamp support above) */
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -3984,7 +4066,6 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
vertical-align: middle;
|
||||
}
|
||||
.tokens-table th {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand-ink);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
|
||||
Reference in New Issue
Block a user