phase: 191_source_name_labels_tags
All gates re-verified against a stable tree. Report: **Verified (all 6 tasks in `complete/`; this was the final pass)** - `SourceLabel`+alembic `0030` (dev DB `alembic current` = `0030 (head)`, table present, 0 rows); admin-only `GET/PUT/DELETE /api/source-labels` (`require_admin` router-wide, `resolve_source_name`, partial-update caps/404s); Sources-page "Names, labels and tags" card (read-only derived-name `<code>`, per-row `saveInFlight`, 404 note that survives the settle, chips reuse `.git-source-hidden-count`); `question_name_hits` D2 rule; ONE `all_labels` read per turn in `chat.py` feeding both the `source_name_hit` flag and the prompt surfaces; `source_name_hit_bonus=0.005` in `_selection_order`; ` name=`/` label=` in `build_high_prompt` + `build_overview_prompt(names=…)`. - **Completion criteria:** E2E `tests/e2e/test_source_name_labels.py` ✅ 7 passed (arms a–e: editor all kinds, control/named question, rename-never-breaks-identity, 404 note, reset) · full suite ✅ · coverage ✅ · README/AGENTS ✅ · phase dir in `complete/` ✅ · commit left to the harness. - **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 7160 stmts / 16 missed = 99%** (>90%) · `uv run pytest tests/e2e/test_source_name_labels.py -v --no-cov` → **7 passed, exit 0** · regression E2E isolated, exit 0: `test_document_viewer`, `test_admin_auth`, `test_api_tokens`, `test_git_sources_admin`, `test_kb_tree`, `test_local_directory_sources` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors. - **Battery (task 04 criterion):** `uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt` at default vs `BOR_SOURCE_NAME_HIT_BONUS=0` → **`diff` empty (line-for-line identical)**, matching the recorded arms (dev KB is empty, so every question deflects; the observable boost is unit/E2E-pinned). - **Documentation:** README — fixed the `/git-sources.html` rows (nav label is **Sources**) and cross-linked the phase-191 card from that section; full audit otherwise clean: all 86 `BOR_*` settings documented with defaults matching `app/config.py`, 0 broken file/dir references and 0 broken anchors in README+AGENTS, AGENTS rule 17 re-checked line-by-line against the code. - **Notable / deviations:** (1) a **concurrent pipeline process** (`phased-execution/progress.mjs` for `191_…_00_phase`) was mutating this same working tree mid-pass — it rewrote `tests/e2e/test_source_name_labels.py` (11→7 tests), `frontend/assets/git-sources.js`, `tests/unit/test_source_labels_editor_ui.py` and `AGENTS.md` at 05:27; every gate above was (re-)run after those edits with the tree no source file newer than the green full run. (2) The task-06 report/phase text claims regression suites `test_chat_token_gate.py`, `test_git_sources_page.py`, `test_rag_browser.py` are green — **those files have never existed in git history** (those runs exit 4); I ran the real equivalents listed above instead (phase files are not mine to edit). (3) **No live changes**: the VM DB already has `source_labels` (0 rows) but the deployed app predates this uncommitted code, so deploying `/api/source-labels` to the live service is the owner's post-commit step, not part of this phase's criteria. - **Next pending phase:** none — `191_source_name_labels_tags` is the only entry in `.agents/phases/todo/`.
This commit is contained in:
@@ -228,6 +228,36 @@
|
||||
* token-free with no per-site change; the UI must never re-embed a
|
||||
* credential.
|
||||
*
|
||||
* Phase 191 (task 03) — the per-source name / label / tags editor (the
|
||||
* owner-visible half of "Sources need a name, a label, tag(s) …"): a
|
||||
* SECOND list on the page, its own card below the sources table, whose
|
||||
* ROW LIST is the phase-191 catalogue GET /api/source-labels (task 02) —
|
||||
* the union of every registry row's DERIVED source name and every indexed
|
||||
* document source — so every kind (git, local, upload) gets an editor
|
||||
* without a second API (loadLabels → renderLabelRows → makeLabelRow, the
|
||||
* page's ONE <template id="source-label-row-template"> cloned per row).
|
||||
* The mono identity cell is the derived name, textContent-only and never
|
||||
* an input: a label row is an ALIAS (D1/A1) — renaming here touches no
|
||||
* document, citation, KB-tree node, saved chat or source/path locator.
|
||||
* Each row expands (aria-expanded + aria-controls) into the three fields
|
||||
* (Name ≤120 / Label ≤200 / the Tags token input — Enter or comma adds a
|
||||
* chip, Backspace drops the last, ≤10 chips of ≤32 chars, the caps of the
|
||||
* API's normalize_tags mirrored so an over-long submission never
|
||||
* round-trips as a 422) + Save / Reset + the row's own §7.4 save-state
|
||||
* line (role=status): dirty → "unsaved changes" + the .is-dirty row mark
|
||||
* → "Saving…"/"Resetting…" → "saved" or the ONE-LINE error note, whose
|
||||
* 404 arm names itself ("That source no longer exists") because a retry
|
||||
* cannot help — the source left the catalogue. ONE in-flight request per
|
||||
* ROW (the phase-168 saveInFlight pattern scoped per row — a second click
|
||||
* while a request is out is ignored); the PUT / DELETE response is the
|
||||
* new truth (no second GET), Reset clears the fields and the stored label,
|
||||
* and a collapse discards the draft instead of leaving the row claiming
|
||||
* something the server never got. The fetch sits inside the existing
|
||||
* admin-gated content reveal (the page's gate is this list's gate) and
|
||||
* every source-list change (add / remove / a finished upload) refreshes
|
||||
* it, so a new source is nameable without a reload. The existing sync /
|
||||
* upload / git-row flows are untouched — the editor is purely additive.
|
||||
*
|
||||
* Phase 135 (task 04) — the branch pin (the field, the tri-state
|
||||
* asymmetry, the tag rule): the add form gains the optional
|
||||
* `#git-source-branch` (maxlength 200, autocomplete=off, the visible
|
||||
@@ -354,6 +384,15 @@ export async function mount(root) {
|
||||
(the checkbox is a table-cell control — no dialog of its own —
|
||||
so its failure announces here, next to the table). */
|
||||
const hiddenErrorEl = root.querySelector("#git-sources-hidden-error");
|
||||
/* Phase 191 (task 03): the name/label/tags catalogue — this page's
|
||||
SECOND list (its own endpoint, its own union of every source kind),
|
||||
rendered into its own bounded table card. The template is the ONE
|
||||
row skeleton cloned per source (makeLabelRow). */
|
||||
const labelsErrorEl = root.querySelector("#source-labels-error");
|
||||
const labelsWrap = root.querySelector("#source-labels-wrap");
|
||||
const labelsTbody = root.querySelector("#source-labels-tbody");
|
||||
const labelsEmptyEl = root.querySelector("#source-labels-empty");
|
||||
const labelRowTemplate = root.querySelector("#source-label-row-template");
|
||||
|
||||
/* Polite live region: the screen-reader confirmation for loads, adds,
|
||||
and removals (the phase-15 announcer pattern). */
|
||||
@@ -724,6 +763,7 @@ export async function mount(root) {
|
||||
// app-managed files).
|
||||
closeRemoveConfirm(); // focus returns to the row's Remove button
|
||||
await loadSources(); // the row leaves the table
|
||||
await loadLabels(); // phase 191: and its label row leaves with it
|
||||
// The removal confirmation is the LAST announcement: the
|
||||
// reload's "N sources listed." must not overwrite it (the
|
||||
// announcer is the screen-reader confirmation for the
|
||||
@@ -1013,6 +1053,497 @@ export async function mount(root) {
|
||||
if (ignoreBackdrop) ignoreBackdrop.addEventListener("click", cancelIgnoreEditor);
|
||||
if (ignoreSaveBtn) ignoreSaveBtn.addEventListener("click", saveIgnorePaths);
|
||||
|
||||
/* ---------- source names, labels and tags (/api/source-labels) — phase 191 task 03 ----------
|
||||
* TODO.md L4: "Sources need a name, a label, tag(s) so you can focus the
|
||||
* model when prompting if you have a project name or a standard name you
|
||||
* reference things by." Tasks 01/02 stored and exposed that; this is the
|
||||
* surface the owner types into.
|
||||
*
|
||||
* WHY A SECOND LIST (and not a column of #git-sources-table): the row list
|
||||
* comes from GET /api/source-labels — the union of (a) every registry row's
|
||||
* DERIVED source name and (b) every indexed document source — so a source
|
||||
* is nameable even when it has no git_sources row of its own, and the page
|
||||
* never has to guess the server's naming (the importer's derivation is the
|
||||
* ONE owner, app/rag/source_removal.resolve_source_name). One extra fetch,
|
||||
* inside the admin branch only (the anonymous path returns at the gate and
|
||||
* this file never asks).
|
||||
*
|
||||
* WHY THE IDENTITY CELL IS READ-ONLY (D1/A1, owner-confirmed): the derived
|
||||
* name is the wire identity of every document, citation, KB-tree node,
|
||||
* saved chat, shared page and source/path locator. The name typed here is
|
||||
* an ALIAS the prompt layers read (tasks 04/05) — never a rename — so
|
||||
* nothing in this block writes, resolves or joins on it: it is looked up
|
||||
* (encodeURIComponent in the path) exactly as the API's PUT/DELETE take it.
|
||||
*
|
||||
* THE SAVE CONTRACT (§7.4 never-stale, the phase-168 shape scoped PER ROW):
|
||||
* an edit marks the row (.is-dirty) + "unsaved changes" + enables that row's
|
||||
* Save; Save/Reset set the row's own `saveInFlight` (a second click while a
|
||||
* request is out is IGNORED, and the in-flight label owns the status line),
|
||||
* the row settles on "saved" or the one-line error note — never a stuck
|
||||
* "Saving…" on ANY outcome (the finally always drops the flag). The PUT /
|
||||
* DELETE RESPONSE is the new truth (no second GET — task 03): the stored
|
||||
* label, the fields, the state tag and the summary all re-paint from the
|
||||
* body the server just wrote, so the row can never show something the
|
||||
* server does not have. Collapsing the editor DISCARDS the draft (no
|
||||
* request) and refills from the stored label for the same reason. */
|
||||
|
||||
/* The caps of app/rag/source_labels (D1/D5), mirrored client-side so a
|
||||
long tag never costs a round trip: the input's maxlength enforces the
|
||||
32/120/200 char caps in the markup, this pair owns the chip count. */
|
||||
const LABELS_ENDPOINT = "/api/source-labels";
|
||||
const LABEL_TAG_MAX_CHARS = 32;
|
||||
const LABEL_TAGS_MAX_COUNT = 10;
|
||||
|
||||
/* The save-state line's copy (role=status, one line per row). */
|
||||
const LABEL_STATUS_SAVED = "saved";
|
||||
const LABEL_STATUS_DIRTY = "unsaved changes";
|
||||
const LABEL_STATUS_SAVING = "Saving…";
|
||||
const LABEL_STATUS_RESETTING = "Resetting…";
|
||||
/* The 404 arm NAMED (task 03): the source is gone from the catalogue
|
||||
(removed elsewhere, or its last document pruned), so a retry cannot
|
||||
help — the owner needs a reload, not a retype. */
|
||||
const LABEL_NOTE_GONE = "That source no longer exists — reload the page.";
|
||||
const LABEL_FAIL_SAVE = "Could not save the name — try again.";
|
||||
const LABEL_FAIL_RESET = "Could not reset the name — try again.";
|
||||
|
||||
/* Per-clone id sequence: the template ships ONE id per field, and a
|
||||
literal clone N times over would duplicate it (WCAG 1.3.1 — every
|
||||
<label for> would aim at the first match). makeLabelRow rewrites the
|
||||
input ids, the matching <label for>s and the editor id (the toggle's
|
||||
aria-controls target) with this counter. */
|
||||
let labelRowSeq = 0;
|
||||
|
||||
function storedFromEntry(entry) {
|
||||
/* One label row in its SERVER shape → the row's stored state. Nulls /
|
||||
missing keys fold to "" / [] (the API's "no row" shape is identical
|
||||
to a cleared label — D1), so a row with nothing stored renders the
|
||||
empty form and no Reset. */
|
||||
const e = entry || {};
|
||||
return {
|
||||
display_name: typeof e.display_name === "string" ? e.display_name : "",
|
||||
label: typeof e.label === "string" ? e.label : "",
|
||||
tags: Array.isArray(e.tags) ? e.tags.map((tag) => String(tag)) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function hasStoredLabel(stored) {
|
||||
return Boolean(stored.display_name || stored.label || stored.tags.length);
|
||||
}
|
||||
|
||||
/* The collapsed row's read-out of the stored label — name · label · the
|
||||
tag list. "" renders nothing (the row shows the toggle instead). */
|
||||
function labelSummaryText(stored) {
|
||||
const parts = [];
|
||||
if (stored.display_name) parts.push(stored.display_name);
|
||||
if (stored.label) parts.push(stored.label);
|
||||
if (stored.tags.length) parts.push(stored.tags.join(", "));
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/* The token input's chip list, re-painted from the row's draft array.
|
||||
The chips are the phase-89 tag idiom (.source-label-tag joins the
|
||||
.git-source-ignore-count / -hidden-count / -branch-count rule — TEXT +
|
||||
background, never color alone), so NO new tag-input CSS is needed
|
||||
beyond the list's own layout. At the cap the input disables (the tenth
|
||||
chip is the last one the API accepts). */
|
||||
function paintLabelTags(view) {
|
||||
view.tagsList.replaceChildren(
|
||||
...view.tags.map((tag) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "source-label-tag";
|
||||
li.textContent = tag; // textContent only (the XSS contract)
|
||||
return li;
|
||||
}),
|
||||
);
|
||||
const full = view.tags.length >= LABEL_TAGS_MAX_COUNT;
|
||||
view.tagsInput.disabled = full;
|
||||
view.tagsInput.placeholder = full
|
||||
? `${LABEL_TAGS_MAX_COUNT} tags at most`
|
||||
: "gitea";
|
||||
}
|
||||
|
||||
/* Type + Enter (or a comma) adds a chip. The rules are the API's
|
||||
normalize_tags, applied client-side: trim, drop the empties, keep the
|
||||
FIRST case of a duplicate, cap the count — a duplicate chip would
|
||||
otherwise show the owner two chips the server de-duplicates to one. */
|
||||
function addLabelTags(view, raw) {
|
||||
for (const piece of String(raw).split(",")) {
|
||||
const tag = piece.trim().slice(0, LABEL_TAG_MAX_CHARS);
|
||||
if (!tag) continue;
|
||||
if (view.tags.some((t) => t.toLowerCase() === tag.toLowerCase())) continue;
|
||||
if (view.tags.length >= LABEL_TAGS_MAX_COUNT) break;
|
||||
view.tags.push(tag);
|
||||
}
|
||||
view.tagsInput.value = "";
|
||||
paintLabelTags(view);
|
||||
updateLabelRowUI(view);
|
||||
}
|
||||
|
||||
function readLabelForm(view) {
|
||||
return {
|
||||
display_name: view.nameInput.value.trim(),
|
||||
label: view.labelInput.value.trim(),
|
||||
tags: view.tags.slice(),
|
||||
};
|
||||
}
|
||||
|
||||
function fillLabelForm(view) {
|
||||
view.nameInput.value = view.stored.display_name;
|
||||
view.labelInput.value = view.stored.label;
|
||||
view.tagsInput.value = "";
|
||||
view.tags = view.stored.tags.slice();
|
||||
paintLabelTags(view);
|
||||
}
|
||||
|
||||
function labelFormDirty(view) {
|
||||
const form = readLabelForm(view);
|
||||
return (
|
||||
form.display_name !== view.stored.display_name ||
|
||||
form.label !== view.stored.label ||
|
||||
form.tags.join("\n") !== view.stored.tags.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
function setLabelStatus(view, text, kind) {
|
||||
view.statusEl.textContent = text;
|
||||
view.statusEl.classList.toggle("is-error", kind === "error");
|
||||
view.statusEl.classList.toggle("is-hint", kind === "hint");
|
||||
}
|
||||
|
||||
/* The row's stored-state paint: the "named" tag, the summary line, the
|
||||
Reset button (a row with nothing stored has nothing to reset — the
|
||||
phase-168 per-row reset shape) and the toggle's label + aria-label
|
||||
(the ONLY place the source name rides on the controls — setAttribute,
|
||||
never innerHTML, the phase-89/105 idiom). */
|
||||
function paintLabelRowState(view) {
|
||||
const labelled = hasStoredLabel(view.stored);
|
||||
if (view.stateTag) view.stateTag.hidden = !labelled;
|
||||
if (view.summaryEl) {
|
||||
const text = labelSummaryText(view.stored);
|
||||
view.summaryEl.textContent = text;
|
||||
view.summaryEl.hidden = text === "";
|
||||
}
|
||||
view.resetBtn.hidden = !labelled;
|
||||
view.toggleBtn.textContent = labelled ? "Edit name" : "Name this source";
|
||||
view.toggleBtn.setAttribute(
|
||||
"aria-label",
|
||||
`${labelled ? "Edit" : "Set"} the name, label and tags for source: ${view.source}`,
|
||||
);
|
||||
view.saveBtn.setAttribute(
|
||||
"aria-label",
|
||||
`Save the name, label and tags for source: ${view.source}`,
|
||||
);
|
||||
view.resetBtn.setAttribute(
|
||||
"aria-label",
|
||||
`Reset the name, label and tags of source: ${view.source}`,
|
||||
);
|
||||
}
|
||||
|
||||
/* Drop a failure note the moment the draft moves again: the note
|
||||
explains the submission the server refused, and that submission is
|
||||
gone as soon as the owner types. Called by the edit handlers ONLY —
|
||||
never by updateLabelRowUI, which is exactly what a finishing request
|
||||
calls (and would erase the note it just wrote). */
|
||||
function clearLabelError(view) {
|
||||
if (view.statusEl.classList.contains("is-error")) setLabelStatus(view, "", null);
|
||||
}
|
||||
|
||||
/* The per-row dirty paint (the phase-168 .is-dirty shape, scoped to ONE
|
||||
row instead of the panel). While a request is out the in-flight label
|
||||
owns the status line — a keystroke mid-save must not overwrite it. */
|
||||
function updateLabelRowUI(view) {
|
||||
const dirty = labelFormDirty(view);
|
||||
view.tr.classList.toggle("is-dirty", dirty);
|
||||
if (view.saveInFlight) return;
|
||||
view.saveBtn.disabled = !dirty;
|
||||
/* A FAILED save's note survives this paint (§7.4): the dirty hint and
|
||||
the note are the same one line, and the hint is the weaker claim —
|
||||
silently replacing "That source no longer exists — reload the
|
||||
page." with "unsaved changes" hides the one error the owner cannot
|
||||
fix by retrying. The note clears on the next edit (clearLabelError)
|
||||
or on a collapse, never by re-deriving the dirty state. */
|
||||
if (view.statusEl.classList.contains("is-error")) return;
|
||||
if (dirty) setLabelStatus(view, LABEL_STATUS_DIRTY, "hint");
|
||||
else if (view.statusEl.classList.contains("is-hint")) {
|
||||
setLabelStatus(view, "", null);
|
||||
}
|
||||
}
|
||||
|
||||
/* Expand / collapse. A collapse discards the draft (no request) and
|
||||
refills from the stored label, so a closed row never claims an edit the
|
||||
server never got; a save in flight freezes the row. */
|
||||
function setLabelEditorOpen(view, open) {
|
||||
if (view.saveInFlight) return;
|
||||
view.editor.hidden = !open;
|
||||
view.toggleBtn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
if (!open) {
|
||||
fillLabelForm(view);
|
||||
setLabelStatus(view, "", null);
|
||||
}
|
||||
updateLabelRowUI(view);
|
||||
}
|
||||
|
||||
/* The one-line error note. The 404 arm names itself (a retry cannot fix a
|
||||
source that left the catalogue); every other non-2xx carries the server
|
||||
detail (apiDetail, 422 shape-aware — the cap that was crossed names
|
||||
itself there). */
|
||||
async function labelFailureNote(r, fallback) {
|
||||
if (r.status === 404) return LABEL_NOTE_GONE;
|
||||
return await apiDetail(r, fallback);
|
||||
}
|
||||
|
||||
/* PUT the whole form (the three keys always ride — an empty Name is the
|
||||
deliberate CLEAR leg, the API folds "" to NULL; the tags array is the
|
||||
chip list). 200 → the response body IS the row's new stored label. */
|
||||
async function saveLabelRow(view) {
|
||||
if (view.saveInFlight) return; // §7.4 — one request per row at a time
|
||||
const form = readLabelForm(view);
|
||||
view.saveInFlight = true;
|
||||
view.saveBtn.disabled = true;
|
||||
view.resetBtn.disabled = true;
|
||||
setLabelStatus(view, LABEL_STATUS_SAVING, null);
|
||||
try {
|
||||
const r = await fetch(`${LABELS_ENDPOINT}/${encodeURIComponent(view.source)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
if (r.ok) {
|
||||
/* The response is the new truth — NO second GET (task 03). A body
|
||||
that cannot be parsed degrades to what was just sent (never to
|
||||
"unknown": the write succeeded either way). */
|
||||
let saved = null;
|
||||
try {
|
||||
saved = await r.json();
|
||||
} catch {
|
||||
saved = null;
|
||||
}
|
||||
view.stored = storedFromEntry(
|
||||
saved || {
|
||||
display_name: form.display_name || null,
|
||||
label: form.label || null,
|
||||
tags: form.tags,
|
||||
},
|
||||
);
|
||||
fillLabelForm(view);
|
||||
paintLabelRowState(view);
|
||||
hideLabelsError(); // a healed save heals the catalogue line too
|
||||
setLabelStatus(view, LABEL_STATUS_SAVED, null);
|
||||
return;
|
||||
}
|
||||
setLabelStatus(view, await labelFailureNote(r, LABEL_FAIL_SAVE), "error");
|
||||
} catch {
|
||||
setLabelStatus(view, `${LABEL_FAIL_SAVE} Is the app reachable?`, "error");
|
||||
} finally {
|
||||
// Never stale on ANY outcome (§7.4): the flag drops, the buttons
|
||||
// come back, and the dirty paint re-decides Save.
|
||||
view.saveInFlight = false;
|
||||
view.resetBtn.disabled = false;
|
||||
updateLabelRowUI(view);
|
||||
}
|
||||
}
|
||||
|
||||
/* DELETE the row — the fields clear, the stored label nulls, and the
|
||||
row settles on the same "saved" line (the row now matches the server,
|
||||
which is all the line has ever claimed). */
|
||||
async function resetLabelRow(view) {
|
||||
if (view.saveInFlight) return; // the SAME per-row guard covers Reset
|
||||
view.saveInFlight = true;
|
||||
view.saveBtn.disabled = true;
|
||||
view.resetBtn.disabled = true;
|
||||
setLabelStatus(view, LABEL_STATUS_RESETTING, null);
|
||||
try {
|
||||
const r = await fetch(`${LABELS_ENDPOINT}/${encodeURIComponent(view.source)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (r.ok) {
|
||||
// The 200 body is the entry's cleared state (task 02) — the row
|
||||
// re-reads it instead of assuming, and the fields clear with it.
|
||||
let cleared = null;
|
||||
try {
|
||||
cleared = await r.json();
|
||||
} catch {
|
||||
cleared = null;
|
||||
}
|
||||
view.stored = storedFromEntry(cleared || {});
|
||||
fillLabelForm(view);
|
||||
paintLabelRowState(view);
|
||||
hideLabelsError();
|
||||
setLabelStatus(view, LABEL_STATUS_SAVED, null);
|
||||
return;
|
||||
}
|
||||
setLabelStatus(view, await labelFailureNote(r, LABEL_FAIL_RESET), "error");
|
||||
} catch {
|
||||
setLabelStatus(view, `${LABEL_FAIL_RESET} Is the app reachable?`, "error");
|
||||
} finally {
|
||||
view.saveInFlight = false;
|
||||
view.resetBtn.disabled = false;
|
||||
updateLabelRowUI(view);
|
||||
}
|
||||
}
|
||||
|
||||
/* Clone the page's ONE row template for one catalogue entry. The
|
||||
template is static markup (no data): every value lands through
|
||||
textContent / .value, and the row's own object (`view`) carries its
|
||||
state — stored label, chip draft, saveInFlight — closed over by its
|
||||
listeners. */
|
||||
function makeLabelRow(entry) {
|
||||
const proto =
|
||||
labelRowTemplate && labelRowTemplate.content
|
||||
? labelRowTemplate.content.querySelector("tr")
|
||||
: null;
|
||||
if (!proto) return null; // defensive — the template ships with the page
|
||||
const tr = proto.cloneNode(true);
|
||||
labelRowSeq += 1;
|
||||
const seq = labelRowSeq;
|
||||
const pick = (sel) => tr.querySelector(sel);
|
||||
const nameInput = pick(".source-label-name-input");
|
||||
const labelInput = pick(".source-label-label-input");
|
||||
const tagsInput = pick(".source-label-tags-input");
|
||||
const tagsList = pick(".source-label-tags");
|
||||
const editor = pick(".source-label-editor");
|
||||
const toggleBtn = pick(".source-label-toggle");
|
||||
const saveBtn = pick(".source-label-save");
|
||||
const resetBtn = pick(".source-label-reset");
|
||||
const statusEl = pick(".source-label-status");
|
||||
if (!nameInput || !labelInput || !tagsInput || !tagsList || !editor ||
|
||||
!toggleBtn || !saveBtn || !resetBtn || !statusEl) {
|
||||
return null; // a template without the fields is a build error, not a row
|
||||
}
|
||||
/* Rewrite the template's static ids (and the <label for>s that point at
|
||||
them) so every row keeps a unique id and a real label pair; the
|
||||
editor id is the toggle's aria-controls target. */
|
||||
for (const [input, base] of [
|
||||
[nameInput, "source-label-name"],
|
||||
[labelInput, "source-label-label"],
|
||||
[tagsInput, "source-label-tags"],
|
||||
]) {
|
||||
input.id = `${base}-${seq}`;
|
||||
const lbl = tr.querySelector(`label[for="${base}"]`);
|
||||
if (lbl) lbl.setAttribute("for", input.id);
|
||||
}
|
||||
editor.id = `source-label-editor-${seq}`;
|
||||
toggleBtn.setAttribute("aria-controls", editor.id);
|
||||
|
||||
const codeEl = pick(".source-label-source");
|
||||
const source = String((entry && entry.source) || "");
|
||||
if (codeEl) codeEl.textContent = source; // the identity: read-only text
|
||||
const cell = pick(".source-label-id-cell");
|
||||
if (cell) cell.title = source; // long names hover in full (house idiom)
|
||||
|
||||
const view = {
|
||||
source,
|
||||
tr,
|
||||
stored: storedFromEntry(entry),
|
||||
tags: [],
|
||||
saveInFlight: false,
|
||||
nameInput,
|
||||
labelInput,
|
||||
tagsInput,
|
||||
tagsList,
|
||||
editor,
|
||||
toggleBtn,
|
||||
saveBtn,
|
||||
resetBtn,
|
||||
statusEl,
|
||||
stateTag: pick(".source-label-state"),
|
||||
summaryEl: pick(".source-label-summary"),
|
||||
};
|
||||
fillLabelForm(view);
|
||||
paintLabelRowState(view);
|
||||
updateLabelRowUI(view);
|
||||
|
||||
for (const input of [nameInput, labelInput]) {
|
||||
input.addEventListener("input", () => {
|
||||
clearLabelError(view);
|
||||
updateLabelRowUI(view);
|
||||
});
|
||||
}
|
||||
tagsInput.addEventListener("keydown", (e) => {
|
||||
clearLabelError(view);
|
||||
// Enter or a comma commits the token; the comma never "types".
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
addLabelTags(view, tagsInput.value);
|
||||
return;
|
||||
}
|
||||
// Backspace on an empty input drops the LAST chip (the token rule).
|
||||
if (e.key === "Backspace" && tagsInput.value === "" && view.tags.length) {
|
||||
e.preventDefault();
|
||||
view.tags.pop();
|
||||
paintLabelTags(view);
|
||||
updateLabelRowUI(view);
|
||||
}
|
||||
});
|
||||
// Leaving the field commits a half-typed token (nothing is lost).
|
||||
tagsInput.addEventListener("blur", () => {
|
||||
if (tagsInput.value.trim()) addLabelTags(view, tagsInput.value);
|
||||
});
|
||||
toggleBtn.addEventListener("click", () => setLabelEditorOpen(view, editor.hidden));
|
||||
saveBtn.addEventListener("click", () => saveLabelRow(view));
|
||||
resetBtn.addEventListener("click", () => resetLabelRow(view));
|
||||
return tr;
|
||||
}
|
||||
|
||||
/* GET the label catalogue (ONE fetch — the union the server assembles).
|
||||
A failure is this list's own role=alert line: the sources table above
|
||||
keeps working (the two lists are independent), and the table + empty
|
||||
state hide while the error is up so the note is the only claim about
|
||||
the rows (the #git-sources-load-error discipline). */
|
||||
async function loadLabels() {
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(LABELS_ENDPOINT);
|
||||
} catch {
|
||||
showLabelsError("Could not reach the server — the source names are not shown.");
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
showLabelsError(
|
||||
await apiDetail(r, `The server could not list the source names (${r.status}).`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = await r.json();
|
||||
} catch {
|
||||
showLabelsError("The server sent an unreadable label list — try again.");
|
||||
return;
|
||||
}
|
||||
hideLabelsError();
|
||||
renderLabelRows(Array.isArray(data.labels) ? data.labels : []);
|
||||
}
|
||||
|
||||
function renderLabelRows(entries) {
|
||||
if (!labelsTbody) return;
|
||||
labelsTbody.replaceChildren(); // the list is REPLACED, never appended
|
||||
for (const entry of entries) {
|
||||
const tr = makeLabelRow(entry);
|
||||
if (tr) labelsTbody.appendChild(tr);
|
||||
}
|
||||
const hasRows = labelsTbody.children.length > 0;
|
||||
if (labelsWrap) labelsWrap.hidden = !hasRows;
|
||||
if (labelsEmptyEl) labelsEmptyEl.hidden = hasRows;
|
||||
}
|
||||
|
||||
function showLabelsError(message) {
|
||||
if (labelsErrorEl) {
|
||||
labelsErrorEl.textContent = message;
|
||||
labelsErrorEl.hidden = false;
|
||||
}
|
||||
// The list state is unknown — hide the table AND the empty state.
|
||||
if (labelsWrap) labelsWrap.hidden = true;
|
||||
if (labelsEmptyEl) labelsEmptyEl.hidden = true;
|
||||
}
|
||||
|
||||
function hideLabelsError() {
|
||||
if (labelsErrorEl) {
|
||||
labelsErrorEl.textContent = "";
|
||||
labelsErrorEl.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- add (POST /api/git-sources) — the git form ----------
|
||||
* wireAddForm gives the form the §7.4 never-stale lifecycle: while
|
||||
* the request is out the button disables + relabels "Adding…" and
|
||||
@@ -1065,6 +1596,7 @@ export async function mount(root) {
|
||||
if (branchInput) branchInput.value = ""; // the branch is stored (NULL when blank)
|
||||
announce(opts.addedMessage);
|
||||
await loadSources(); // the new row lands in the table
|
||||
await loadLabels(); // phase 191: and it is nameable straight away
|
||||
focusNewRow(createdId); // a11y: land the caret on the new row
|
||||
return;
|
||||
}
|
||||
@@ -1333,6 +1865,7 @@ export async function mount(root) {
|
||||
uploadFileInput.value = "";
|
||||
restoreUploadButton();
|
||||
loadSources(); // the row lands / refreshes
|
||||
loadLabels(); // phase 191: the uploaded source joins the name list
|
||||
return;
|
||||
}
|
||||
if (status.state === "failed") {
|
||||
@@ -1524,6 +2057,13 @@ export async function mount(root) {
|
||||
the whoami gate passed: anonymous shows the gate and must never
|
||||
fetch /api/git-sources (the Sources-page gate pattern). */
|
||||
root.addEventListener("bor:view-refresh", () => loadSources());
|
||||
/* Phase 191 (task 03): the label catalogue rides the same re-show
|
||||
refresh. A SECOND listener (rather than a call inside loadSources)
|
||||
keeps the phase-77 listener above verbatim AND keeps the two lists
|
||||
independent — the ignore-paths / hidden-folders saves reload the
|
||||
source table without ever wiping a half-typed name draft. */
|
||||
root.addEventListener("bor:view-refresh", () => loadLabels());
|
||||
await loadLabels(); // phase 191: the names list (admin branch only)
|
||||
await loadSources();
|
||||
// Phase 64 (task 05): re-attach a running run (a reload mid-run
|
||||
// resumes the bare Processing state) or re-render a terminal
|
||||
|
||||
+259
-4
@@ -4349,9 +4349,16 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
.git-source-ignore-count idiom (TEXT + background, never
|
||||
color alone — WCAG 1.4.1: --ink on --bg 16.7:1). Phase 135 (A6):
|
||||
the "branch: <name>" state tag joins the same rule — stored git
|
||||
rows with a branch only, the text IS the signal. */
|
||||
rows with a branch only, the text IS the signal.
|
||||
Phase 191 (task 03): the TWO new tags of the name/label/tags row join
|
||||
the SAME rule — .source-label-state ("named", beside the mono identity)
|
||||
and .source-label-tag (one chip of the Tags token input). That is the
|
||||
reuse the phase asks for: the chips are the house tag, not a new
|
||||
widget, so no tag-input styling is added anywhere below. */
|
||||
.git-source-hidden-count,
|
||||
.git-source-branch-count {
|
||||
.git-source-branch-count,
|
||||
.source-label-state,
|
||||
.source-label-tag {
|
||||
display: inline-block;
|
||||
margin-left: 0.55rem;
|
||||
padding: 0.08rem 0.5rem;
|
||||
@@ -4376,8 +4383,11 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
}
|
||||
|
||||
/* Empty state: the tuning page's quiet centered line at full table
|
||||
width — no stored rows AND no env fallback to show. */
|
||||
.git-sources-empty {
|
||||
width — no stored rows AND no env fallback to show. Phase 191 (task
|
||||
03): the name/label/tags list shares the line verbatim (its own
|
||||
no-sources case, the same quiet card). */
|
||||
.git-sources-empty,
|
||||
.source-labels-empty {
|
||||
margin: 0;
|
||||
padding: 1.4rem 1rem;
|
||||
text-align: center;
|
||||
@@ -4389,6 +4399,251 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* ---------- Source names, labels and tags (phase 191, task 03) ----------
|
||||
The Sources page's SECOND list: every source the knowledge base knows
|
||||
(git, local, upload — the union the phase-191 catalogue GET returns),
|
||||
each with the optional name / label / tags the model is told about
|
||||
(D1: an ALIAS — the mono identity cell is read-only text, nothing here
|
||||
renames a document, a citation or a locator).
|
||||
Nothing new is invented: the card is the phase-156 .table-wrap (bounded
|
||||
panel + sticky header, inherited by the class alone), the identity cell
|
||||
is the .git-source-url-cell mono, the state tag and the tag CHIPS are
|
||||
the phase-89 tag rule above, the field boxes are the .ignore-editor-
|
||||
token / #ignore-editor-branch language at the 44px floor, Save is the
|
||||
page's brand pill, Reset and the row's toggle are the neutral
|
||||
.git-source-ignore language, and the dirty mark is the phase-168
|
||||
the phase-168 dirty-row stripe. PALETTE VARIABLES ONLY — no new hue
|
||||
(every pair is a pair this page already measures: ink 15.3:1 and
|
||||
ink-soft 8.2:1 on --surface, brand-ink on brand-soft 12.4:1, the err
|
||||
pair 9.3:1, --bg ink on --brand 5.2:1); :focus-visible rides the global
|
||||
3px outline rule; no CDN, no blur. The editor expands UNDER its own row
|
||||
(one row open at a time keeps a 27-source list scannable) and its three
|
||||
fields stack in a single column below 60rem instead of pushing the card
|
||||
into a horizontal scroll at 375px. */
|
||||
.source-labels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.source-labels-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.3;
|
||||
color: var(--ink);
|
||||
}
|
||||
/* The alias note (the contract in the owner's words) — the page-sub
|
||||
language, ink-soft on the page canvas (8.2:1 on --surface). */
|
||||
.source-labels-sub {
|
||||
margin: 0;
|
||||
max-width: 60rem;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
/* The list's own load failure (role=alert) — the .git-source-error
|
||||
pair (err-ink on err-bg 9.3:1). */
|
||||
.source-labels-error {
|
||||
margin: 0;
|
||||
padding: 0.45rem 0.8rem;
|
||||
background: var(--err-bg);
|
||||
color: var(--err-ink);
|
||||
border: 1px solid var(--err-line);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* No min-width on purpose: the editor column has to SHRINK (the fields
|
||||
stack), so this table never scrolls the card sideways at 375px — the
|
||||
panel still bounds vertically (.table-wrap). */
|
||||
.source-labels-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.93rem;
|
||||
}
|
||||
.source-labels-table th, .source-labels-table td {
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
padding: 0.7rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.source-labels-table th {
|
||||
color: var(--brand-ink);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.source-labels-table tbody tr:last-child td { border-bottom: 0; }
|
||||
/* The identity cell: the derived name, mono like the sources table's
|
||||
location cell, wrapping (overflow-wrap) instead of widening the card —
|
||||
it is text, never an input (D1/A1). */
|
||||
.source-labels-table td.source-label-id-cell {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.82rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.source-labels-table td.source-label-id-cell code { font-family: inherit; }
|
||||
/* The dirty row mark — the phase-168 dirty-row stripe (the brand edge is
|
||||
beside the row's own "unsaved changes" TEXT, so the state is never color
|
||||
alone — WCAG 1.4.1). The literal is NOT written in this comment: the
|
||||
phase-168 pin (test_settings_frontend_view.py) regex-matches that
|
||||
selector to its own rule and a comment mention would steal the match. */
|
||||
.source-label-row.is-dirty > td:first-child { box-shadow: inset 3px 0 0 var(--brand); }
|
||||
/* The collapsed row's read-out of the stored label (name · label · tags).
|
||||
textContent-only (a label is owner text — the XSS contract). */
|
||||
.source-label-summary {
|
||||
margin: 0 0 0.45rem;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.85rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* The row's expand trigger — the .git-source-ignore language (neutral
|
||||
secondary fill, >=44px, hovers to the brand pair 12.4:1). */
|
||||
.source-label-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 100%;
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.source-label-toggle:hover:not(:disabled) {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand-ink);
|
||||
}
|
||||
/* The expanded editor panel — the row's own form card. The fields go in
|
||||
one column narrow, three across from 60rem (the house grid). */
|
||||
.source-label-editor {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.9rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
@media (min-width: 60rem) {
|
||||
.source-label-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
.source-label-fields {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.source-label-field {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
/* The field label — the .ignore-editor-label language (ink-soft 8.2:1 on
|
||||
the panel, 600 weight, the hint span the .field-hint secondary read). */
|
||||
.source-label-field-label {
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* The field box — the .ignore-editor-token / #ignore-editor-branch box
|
||||
language VERBATIM (full width, --bg… panel fill, the line border, mono)
|
||||
at the 44px touch floor; the global [hidden] rule and the global
|
||||
:focus-visible outline cover the rest. */
|
||||
.source-label-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0.55rem 0.65rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
/* The Tags token input's chip list — the LAYOUT only (the chips
|
||||
themselves are the shared tag rule above, so the widget adds no tag
|
||||
styling). list-style is dropped, so role="list" stays in the markup
|
||||
(the VoiceOver list-semantics idiom the house uses). */
|
||||
.source-label-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin: 0 0 0.45rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.source-label-tags .source-label-tag { margin-left: 0; }
|
||||
.source-label-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
/* Save — the page's brand pill at the row scale (--bg ink on --brand,
|
||||
5.2:1); disabled until the row is dirty, and again while the PUT is
|
||||
out (§7.4 — the row's own in-flight state). */
|
||||
.source-label-save {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
padding: 0.4rem 1.1rem;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--brand);
|
||||
color: var(--bg);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.source-label-save:hover:not(:disabled) { background: var(--brand-hover); }
|
||||
.source-label-save:disabled { opacity: 0.6; cursor: default; }
|
||||
/* Reset — the neutral secondary button (the .git-source-ignore language):
|
||||
it clears a STORED label, it is not the destructive row Remove. */
|
||||
.source-label-reset {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
padding: 0.35rem 0.9rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.source-label-reset:hover:not(:disabled) {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand-ink);
|
||||
}
|
||||
.source-label-reset:disabled { opacity: 0.5; cursor: wait; }
|
||||
/* The §7.4 save-state line (role=status): "Saving…" → "saved" / the
|
||||
one-line error note. The hint (unsaved) is the muted italic; the error
|
||||
takes the err pair (9.3:1) so the note reads as a failure, not as a
|
||||
label — TEXT carries it either way, never color alone. */
|
||||
.source-label-status {
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.source-label-status.is-hint { font-style: italic; }
|
||||
.source-label-status.is-error {
|
||||
padding: 0.35rem 0.6rem;
|
||||
color: var(--err-ink);
|
||||
background: var(--err-bg);
|
||||
border: 1px solid var(--err-line);
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---------- History refresh button (phase 77 task 03, TODO.md L3) ----------
|
||||
The #view-history page-head is a flex row: the title block left, the
|
||||
actions slot right (the row wraps below 640px). Scoped to
|
||||
|
||||
@@ -840,6 +840,124 @@
|
||||
pruned on that run).
|
||||
</p>
|
||||
|
||||
<!-- Phase 191 (task 03): the per-source NAME / LABEL / TAGS
|
||||
editor — "Sources need a name, a label, tag(s) so you can
|
||||
focus the model when prompting if you have a project name
|
||||
or a standard name you reference things by."
|
||||
|
||||
The ROW LIST is the phase-191 catalogue
|
||||
GET /api/source-labels (task 02) — the union of every
|
||||
registry row's DERIVED source name and every indexed
|
||||
document source — which is why this is its own table card
|
||||
and not a column of #git-sources-table above: a source can
|
||||
carry documents with no git_sources row of its own, and the
|
||||
one GET is the single source of truth for the row list.
|
||||
|
||||
THE ALIAS CONTRACT (D1/A1, owner-confirmed): the mono cell
|
||||
is the row's IDENTITY — the derived name, rendered with
|
||||
textContent and never an input. Editing a name here
|
||||
renames NOTHING: documents, citations, the KB tree, saved
|
||||
chats, shared pages and every source/path locator keep the
|
||||
derived name. A name and its tags only change what the
|
||||
model is TOLD (tasks 04/05).
|
||||
|
||||
ONE shared <template> (source-label-row-template) is cloned
|
||||
per row by git-sources.js: the three fields (Name ≤120,
|
||||
Label ≤200, the Tags token input — Enter or comma adds a
|
||||
chip, Backspace drops the last, ≤10 chips of ≤32 chars: the
|
||||
caps of the API's normalize_tags, mirrored so a long
|
||||
submission never round-trips as a 422) + Save / Reset + the
|
||||
§7.4 never-stale save-state line (role=status: "Saving…" →
|
||||
"saved" / the one-line error note, scoped PER ROW). The
|
||||
section lives INSIDE the admin-gated #git-sources-content,
|
||||
so an anonymous visitor never fetches it — the page's own
|
||||
gate is this list's gate too. -->
|
||||
<section class="source-labels" id="source-labels-section" aria-labelledby="source-labels-title">
|
||||
<h2 class="source-labels-title" id="source-labels-title">Names, labels and tags</h2>
|
||||
<p class="source-labels-sub">
|
||||
A name is what <em>you</em> call a source (“the git server”)
|
||||
and a tag is a focus token — the model is told both, so a
|
||||
question that uses the name finds that source’s documents.
|
||||
Nothing here renames a source: the mono name below stays the
|
||||
identity of every document, citation and link.
|
||||
</p>
|
||||
<!-- The catalogue's own load failure (role=alert). The table and
|
||||
the empty state are hidden while it is up, so the error is
|
||||
the only claim about the list (the #git-sources-load-error
|
||||
discipline, applied to this page's second list). -->
|
||||
<p class="source-labels-error" id="source-labels-error" role="alert" hidden></p>
|
||||
<div class="table-wrap source-labels-wrap" id="source-labels-wrap" role="region"
|
||||
aria-label="Source names, labels and tags" tabindex="0">
|
||||
<table class="source-labels-table" id="source-labels-table">
|
||||
<caption class="visually-hidden">Every source the knowledge base knows — git repositories, local directories and uploaded archives — each with the optional name, label and tags the model reads. The mono name in the first column is the source’s identity and never changes.</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Source</th>
|
||||
<th scope="col">Name, label and tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="source-labels-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Empty state — the catalogue answered with nothing at all
|
||||
(no registry row and no indexed document). -->
|
||||
<p class="source-labels-empty" id="source-labels-empty" hidden>No sources to name yet.</p>
|
||||
|
||||
<!-- ONE shared template, cloned per row by makeLabelRow.
|
||||
The ids written here are the FIRST row’s: the clone
|
||||
loop rewrites each input’s id, its <label for> and the
|
||||
editor’s id (the toggle’s aria-controls target) —
|
||||
a literal clone N times over would duplicate ids and make
|
||||
every label ambiguous (WCAG 1.3.1; the fields keep real
|
||||
<label for> pairs, never aria-label-only). Static markup
|
||||
only: every value lands through textContent or .value in
|
||||
git-sources.js (the XSS contract), and the editor ships
|
||||
hidden — the row’s toggle reveals it. -->
|
||||
<template id="source-label-row-template">
|
||||
<tr class="source-label-row">
|
||||
<td class="source-label-id-cell">
|
||||
<code class="source-label-source"></code>
|
||||
<!-- The stored-label state tag: TEXT + a background, never
|
||||
color alone (WCAG 1.4.1 — the
|
||||
.git-source-ignore-count idiom, phase 89/105/135). -->
|
||||
<span class="source-label-state" hidden>named</span>
|
||||
</td>
|
||||
<td class="source-label-edit-cell">
|
||||
<p class="source-label-summary" hidden></p>
|
||||
<button type="button" class="source-label-toggle" aria-expanded="false">Name this source</button>
|
||||
<div class="source-label-editor" id="source-label-editor" hidden>
|
||||
<div class="source-label-fields">
|
||||
<div class="source-label-field">
|
||||
<label class="source-label-field-label" for="source-label-name">Name <span class="field-hint">optional — what you call it</span></label>
|
||||
<input class="source-label-input source-label-name-input" id="source-label-name"
|
||||
type="text" maxlength="120" autocomplete="off"
|
||||
placeholder="the git server">
|
||||
</div>
|
||||
<div class="source-label-field">
|
||||
<label class="source-label-field-label" for="source-label-label">Label <span class="field-hint">optional — one line about it</span></label>
|
||||
<input class="source-label-input source-label-label-input" id="source-label-label"
|
||||
type="text" maxlength="200" autocomplete="off"
|
||||
placeholder="self-hosted forge + Gitea">
|
||||
</div>
|
||||
<div class="source-label-field">
|
||||
<label class="source-label-field-label" for="source-label-tags">Tags <span class="field-hint">optional — Enter or comma adds one</span></label>
|
||||
<ul class="source-label-tags" role="list"></ul>
|
||||
<input class="source-label-input source-label-tags-input" id="source-label-tags"
|
||||
type="text" maxlength="32" autocomplete="off"
|
||||
placeholder="gitea">
|
||||
</div>
|
||||
</div>
|
||||
<div class="source-label-actions">
|
||||
<button type="button" class="source-label-save" disabled>Save</button>
|
||||
<button type="button" class="source-label-reset" hidden>Reset</button>
|
||||
<span class="source-label-status" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- Phase 69 (owner request 2026-09-02): the remove
|
||||
confirmation — a real in-app alertdialog (the native
|
||||
confirm() retired): a row's Remove button opens it
|
||||
|
||||
Reference in New Issue
Block a user