From f7c7f46baa4fd18dcaa77211d02cc5e823e9ec88 Mon Sep 17 00:00:00 2001 From: Malik Warren Date: Thu, 11 Jun 2026 16:54:11 -0400 Subject: [PATCH 1/2] 274_aggregated_gap_bar_ux Replace the per-gap callout spam (issue #269 UI) with the aggregated gap UX from issue #274: one live-count gap bar per section with next-gap cycling and an on-demand list, a GOV.UK-style summary at the review gate, and client-side recounting so the bar clears the moment a gap is filled. Co-Authored-By: Claude Fable 5 --- internal/dashboard/editor.go | 178 ++++++++++++++++++--- internal/dashboard/editor_internal_test.go | 57 +++++++ internal/dashboard/editor_test.go | 87 +++++++--- internal/dashboard/handler.go | 6 +- internal/dashboard/proposals.go | 9 +- internal/dashboard/proposals_templates.go | 47 +++--- internal/dashboard/tokens.go | 20 ++- 7 files changed, 334 insertions(+), 70 deletions(-) diff --git a/internal/dashboard/editor.go b/internal/dashboard/editor.go index 5f502b3..c1b785a 100644 --- a/internal/dashboard/editor.go +++ b/internal/dashboard/editor.go @@ -71,6 +71,62 @@ func highlightGaps(body string) template.HTML { return template.HTML(b.String()) //nolint:gosec // input is escaped above; only our own wrapper is added } +// gapSummaryData aggregates every unresolved Writer gap across a document's +// sections for the review gate's top-of-page summary (issue #274) — one +// GOV.UK-style block with anchor links instead of a banner per gap. +type gapSummaryData struct { + Total int // gaps across the whole document + Headline string // e.g. "3 unresolved gaps across 2 sections" + Sections []gapSummarySection // only sections that hold gaps, in order +} + +// gapSummarySection is one gap-holding section in the review-gate summary. +type gapSummarySection struct { + ID string + Heading string + Count int +} + +// summarizeGaps builds the review-gate gap summary from the live section +// bodies, so it agrees with the per-section gap bars by construction. +func summarizeGaps(sections []document.Section) gapSummaryData { + var d gapSummaryData + for _, s := range sections { + n := len(finalreview.GapTexts(s.Body)) + if n == 0 { + continue + } + d.Total += n + d.Sections = append(d.Sections, gapSummarySection{ID: s.ID, Heading: s.Heading, Count: n}) + } + d.Headline = fmt.Sprintf("%d unresolved %s across %d %s", + d.Total, pluralize(d.Total, "gap"), len(d.Sections), pluralize(len(d.Sections), "section")) + return d +} + +// pluralize returns word with an "s" appended unless n is exactly 1. +func pluralize(n int, word string) string { + if n == 1 { + return word + } + return word + "s" +} + +// openNonGapFlags returns the document flags the review gate should banner: +// unresolved flags that are NOT per-gap "Unresolved gap" flags. Gaps are +// surfaced by the aggregated summary and section bars instead (issue #274) — +// bannering them too would re-introduce the one-callout-per-gap spam. +func openNonGapFlags(flags []document.Flag) []document.Flag { + var open []document.Flag + for _, f := range flags { + if f.Resolved || f.Title == gapFlagTitle { + continue + } + open = append(open, f) + } + return open +} + // handleEditor renders the full-page draft editor for a selected proposal. func (h *Handler) handleEditor(w http.ResponseWriter, r *http.Request) { if h.proposals == nil { @@ -173,13 +229,17 @@ const editorPageTmpl = ` - {{range .Gaps}} -
+
` + iconWarn + ` -
Unresolved gap

{{.}}

- +
+ {{len .Gaps}} unresolved gap{{if ne (len .Gaps) 1}}s{{end}} + +
+ +
- {{end}} {{if .Flag}}
` + iconWarn + ` @@ -220,22 +280,104 @@ const editorPageTmpl = ` }, 900); }); }); - // Jump-to-gap: select the [GAP: ...] marker inside the section's textarea so - // the browser scrolls to it and the human sees exactly what is missing. - document.querySelectorAll(".gap-jump").forEach(function (b) { - b.addEventListener("click", function () { - var area = b.closest("section").querySelector("textarea"); +` + gapScriptJS + ` + + + +` + +// gapScriptJS is the client half of the aggregated gap UI (issue #274), +// shared by the full-page editor and the workspace review gate. It keeps each +// section's gap bar live while the human types — count, list, textarea tint, +// rail dot, and the gate summary all derive from the textarea value, so they +// clear the moment the last [GAP: ...] marker is filled (no reload) — and +// drives the "Next gap" cycling and the on-demand gap list. +const gapScriptJS = ` + // Mirrors finalreview.GapTexts: every "[GAP:" marker counts as one gap; its + // text runs to the closing "]" or, if the model left it unclosed, to the + // end of its line. + function gapTexts(text) { + var gaps = []; + text.split("\n").forEach(function (line) { + var from = 0; + for (;;) { + var s = line.indexOf("[GAP:", from); + if (s < 0) break; + var e = line.indexOf("]", s); + gaps.push((e < 0 ? line.slice(s + 5) : line.slice(s + 5, e)).trim()); + from = e < 0 ? line.length : e + 1; + } + }); + return gaps; + } + // Keep the review-gate summary (if this page has one) agreeing with the + // per-section bars: recount every section, update the per-section rows, and + // hide the whole block when the draft is clean. + function updateGapSummary() { + var sum = document.querySelector("[data-gapsummary]"); + if (!sum) return; + var total = 0, secs = 0; + document.querySelectorAll("[data-gapbar]").forEach(function (bar) { + var sec = bar.closest("section"); + var area = sec && sec.querySelector("textarea"); if (!area) return; - var idx = area.value.indexOf(b.getAttribute("data-gap")); - var start = idx < 0 ? area.value.indexOf("[GAP:") : area.value.lastIndexOf("[GAP:", idx); - if (start < 0) return; - var end = area.value.indexOf("]", start); + var n = gapTexts(area.value).length; + var li = sec.id ? sum.querySelector('li[data-sec="' + sec.id + '"]') : null; + if (li) { + li.hidden = n === 0; + var c = li.querySelector(".gs-n"); + if (c) c.textContent = n; + } + if (n > 0) { total += n; secs += 1; } + }); + sum.hidden = total === 0; + var h = sum.querySelector("[data-gapheadline]"); + if (h) h.textContent = total + " unresolved " + (total === 1 ? "gap" : "gaps") + + " across " + secs + " " + (secs === 1 ? "section" : "sections"); + } + document.querySelectorAll("[data-gapbar]").forEach(function (bar) { + var sec = bar.closest("section"); + var area = sec && sec.querySelector("textarea"); + if (!area) return; + var count = bar.querySelector("[data-gapcount]"); + var list = bar.querySelector("[data-gaplist]"); + var toggle = bar.querySelector("[data-gaptoggle]"); + var next = bar.querySelector("[data-gapnext]"); + var rail = sec.id ? document.querySelector('.ed-sec[href="#' + sec.id + '"]') : null; + // A non-gap review flag keeps the rail dot amber even once gaps are filled. + var hasOtherFlag = !!sec.querySelector(".ed-flag:not(.ed-gap)"); + area.addEventListener("input", function () { + var gaps = gapTexts(area.value); + bar.hidden = gaps.length === 0; + area.classList.toggle("gap-warn", gaps.length > 0); + if (rail) rail.classList.toggle("warn", gaps.length > 0 || hasOtherFlag); + if (count) count.textContent = gaps.length + " unresolved " + (gaps.length === 1 ? "gap" : "gaps"); + if (list) { + list.textContent = ""; + gaps.forEach(function (g) { + var li = document.createElement("li"); + li.textContent = g; + list.appendChild(li); + }); + } + updateGapSummary(); + }); + if (toggle && list) toggle.addEventListener("click", function () { + list.hidden = !list.hidden; + toggle.textContent = list.hidden ? "Show list" : "Hide list"; + }); + // Cycle the selection through the [GAP: ...] markers, wrapping after the + // last, so one button replaces a find-button per gap. + if (next) next.addEventListener("click", function () { + var v = area.value; + var s = v.indexOf("[GAP:", area.selectionEnd || 0); + if (s < 0) s = v.indexOf("[GAP:"); + if (s < 0) return; + var close = v.indexOf("]", s), nl = v.indexOf("\n", s); + var end = close >= 0 && (nl < 0 || close < nl) ? close + 1 : (nl < 0 ? v.length : nl); area.focus(); - area.setSelectionRange(start, end < 0 ? area.value.length : end + 1); + area.setSelectionRange(s, end); area.scrollIntoView({ behavior: "smooth", block: "center" }); }); }); - - - ` diff --git a/internal/dashboard/editor_internal_test.go b/internal/dashboard/editor_internal_test.go index 3b5ed84..7b6c1c7 100644 --- a/internal/dashboard/editor_internal_test.go +++ b/internal/dashboard/editor_internal_test.go @@ -3,6 +3,8 @@ package dashboard import ( "strings" "testing" + + "github.com/Mawar2/Kaimi/internal/document" ) // TestHighlightGaps verifies the read-only draft rendering: gap markers are @@ -35,3 +37,58 @@ func TestHighlightGaps_NoMarker_PlainEscape(t *testing.T) { t.Errorf("plain text must still be escaped: %q", got) } } + +// TestSummarizeGaps verifies the review-gate summary aggregation (issue +// #274): totals across sections, only gap-holding sections listed, and a +// correctly pluralized headline. +func TestSummarizeGaps(t *testing.T) { + sections := []document.Section{ + {ID: "exec", Heading: "Executive Summary", Body: "All grounded prose."}, + {ID: "tech", Heading: "Technical Approach", Body: "Staffed by [GAP: cleared staff count] holding [GAP: clearance level]."}, + {ID: "past", Heading: "Past Performance", Body: "Delivered before. [GAP: DoD contract number]"}, + } + got := summarizeGaps(sections) + if got.Total != 3 { + t.Errorf("Total = %d, want 3", got.Total) + } + if len(got.Sections) != 2 { + t.Fatalf("Sections = %d entries, want 2 (only gap-holding sections)", len(got.Sections)) + } + if got.Sections[0].ID != "tech" || got.Sections[0].Count != 2 { + t.Errorf("Sections[0] = %+v, want tech with 2 gaps", got.Sections[0]) + } + if got.Headline != "3 unresolved gaps across 2 sections" { + t.Errorf("Headline = %q", got.Headline) + } +} + +func TestSummarizeGaps_Singular(t *testing.T) { + got := summarizeGaps([]document.Section{ + {ID: "tech", Heading: "Technical Approach", Body: "[GAP: staffing count]"}, + }) + if got.Headline != "1 unresolved gap across 1 section" { + t.Errorf("Headline = %q", got.Headline) + } +} + +func TestSummarizeGaps_Clean(t *testing.T) { + got := summarizeGaps([]document.Section{{ID: "exec", Heading: "Exec", Body: "fine"}}) + if got.Total != 0 || len(got.Sections) != 0 { + t.Errorf("clean draft: got %+v, want zero summary", got) + } +} + +// TestOpenNonGapFlags: the gate's flag banners must exclude resolved flags and +// the per-gap "Unresolved gap" flags — gaps are surfaced by the aggregated +// summary instead (issue #274), so persisted gap flags would double-report. +func TestOpenNonGapFlags(t *testing.T) { + flags := []document.Flag{ + {Title: gapFlagTitle, Detail: "missing staffing count", SectionID: "tech"}, + {Title: "Tone concern", Detail: "too informal", SectionID: "exec"}, + {Title: "Stale citation", Detail: "old contract", SectionID: "past", Resolved: true}, + } + got := openNonGapFlags(flags) + if len(got) != 1 || got[0].Title != "Tone concern" { + t.Errorf("openNonGapFlags = %+v, want only the open non-gap flag", got) + } +} diff --git a/internal/dashboard/editor_test.go b/internal/dashboard/editor_test.go index d19710b..8b5dcfe 100644 --- a/internal/dashboard/editor_test.go +++ b/internal/dashboard/editor_test.go @@ -4,6 +4,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "testing" "github.com/Mawar2/Kaimi/internal/proposal" @@ -53,9 +54,10 @@ func TestEditorRequiresDocument(t *testing.T) { } } -// gapBody is a section body holding one unresolved Writer gap marker plus a -// script tag, so the same fixture proves both the callout and the escaping. -const gapBody = "Staffed by [GAP: number of cleared staff] engineers. " +// gapBody is a section body holding two unresolved Writer gap markers plus a +// script tag, so the same fixture proves the aggregated gap bar (one bar, not +// one callout per gap — issue #274) and the escaping. +const gapBody = "Staffed by [GAP: number of cleared staff] engineers holding [GAP: facility clearance level] clearances. " // seedGapSection selects zta-1 and writes gapBody into its first section. func seedGapSection(t *testing.T, h http.Handler, svc *proposal.Service) string { @@ -75,10 +77,11 @@ func seedGapSection(t *testing.T, h http.Handler, svc *proposal.Service) string return secID } -// TestEditorHighlightsUnresolvedGaps: a section holding a [GAP: ...] marker -// gets the amber textarea tint, a per-gap callout with a jump control, and a -// warn mark in the section rail — and the gap text is HTML-escaped. -func TestEditorHighlightsUnresolvedGaps(t *testing.T) { +// TestEditorAggregatesUnresolvedGaps: a section holding two [GAP: ...] +// markers gets the amber textarea tint, ONE aggregated gap bar (count + +// next-gap cycling + expandable list — issue #274, not one callout per gap), +// and a warn mark in the section rail — and the gap text is HTML-escaped. +func TestEditorAggregatesUnresolvedGaps(t *testing.T) { h, svc, _ := newProposalHandler(t) seedGapSection(t, h, svc) @@ -89,22 +92,33 @@ func TestEditorHighlightsUnresolvedGaps(t *testing.T) { } body := rr.Body.String() for _, want := range []string{ - `class="gap-warn"`, // amber textarea tint - "Unresolved gap", // callout title - "number of cleared staff", // the missing-fact text - `data-gap="number of cleared staff"`, // jump-to-gap hook - `class="ed-sec warn"`, // section rail warn mark + `class="gap-warn"`, // amber textarea tint + "2 unresolved gaps", // aggregated count, not per-gap callouts + "data-gapnext", // cycle-through-gaps control + "data-gaptoggle", // expandable gap list + "number of cleared staff", // first missing fact, in the list + "facility clearance level", // second missing fact, in the list + `class="ed-sec warn"`, // section rail warn mark + "function gapTexts", // client-side live recount script } { if !contains(body, want) { t.Errorf("/editor missing %q", want) } } + if got := strings.Count(body, "data-gapbar>"); got != 1 { + t.Errorf("a 2-gap section must render exactly 1 visible gap bar, got %d", got) + } + if contains(body, "Find in text") { + t.Errorf("per-gap 'Find in text' buttons must be replaced by the aggregated bar") + } if contains(body, "") { t.Errorf("section body with markup must be HTML-escaped") } } -// TestEditorNoGaps_NoWarnUI: a clean draft renders without any gap UI. +// TestEditorNoGaps_NoWarnUI: a clean draft renders without visible gap UI — +// the bar is present but hidden, so the live recount can reveal it if the +// human types a new [GAP: ...] marker. func TestEditorNoGaps_NoWarnUI(t *testing.T) { h, svc, _ := newProposalHandler(t) if rr := postForm(t, h, "/opportunity/zta-1/select", url.Values{}); rr.Code != http.StatusSeeOther { @@ -115,16 +129,20 @@ func TestEditorNoGaps_NoWarnUI(t *testing.T) { rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest("GET", "/editor/zta-1", http.NoBody)) body := rr.Body.String() - for _, reject := range []string{`class="gap-warn"`, "Unresolved gap"} { + for _, reject := range []string{`class="gap-warn"`, `class="ed-sec warn"`} { if contains(body, reject) { t.Errorf("clean draft must not render %q", reject) } } + if contains(body, "data-gapbar") && !contains(body, "data-gapbar hidden") { + t.Errorf("clean draft must render gap bars hidden") + } } -// TestWorkspaceGateHighlightsGaps: the review-gate section editors get the -// same gap treatment as the full editor. -func TestWorkspaceGateHighlightsGaps(t *testing.T) { +// TestWorkspaceGateAggregatesGaps: the review-gate section editors get the +// same aggregated gap bar as the full editor, plus a top-of-page summary +// ("N unresolved gaps across M sections") with anchor links to the sections. +func TestWorkspaceGateAggregatesGaps(t *testing.T) { h, svc, _ := newProposalHandler(t) seedGapSection(t, h, svc) @@ -136,12 +154,43 @@ func TestWorkspaceGateHighlightsGaps(t *testing.T) { body := rr.Body.String() for _, want := range []string{ `class="gap-warn"`, - "Unresolved gap", + "2 unresolved gaps", + "data-gapnext", "number of cleared staff", - `data-gap="number of cleared staff"`, + "facility clearance level", + "data-gapsummary", // top-of-page summary block + "2 unresolved gaps across 1 section", // summary headline + `href="#gsec-`, // summary anchor link to the section + "function gapTexts", // client-side live recount script } { if !contains(body, want) { t.Errorf("/workspace gate missing %q", want) } } + if got := strings.Count(body, "data-gapbar>"); got != 1 { + t.Errorf("a 2-gap section must render exactly 1 visible gap bar, got %d", got) + } + if contains(body, "Find in text") { + t.Errorf("per-gap 'Find in text' buttons must be replaced by the aggregated bar") + } +} + +// TestWorkspaceGateNoGaps_SummaryHidden: a clean draft at the gate renders the +// summary hidden so the live recount can reveal it if a gap is introduced. +func TestWorkspaceGateNoGaps_SummaryHidden(t *testing.T) { + h, svc, _ := newProposalHandler(t) + if rr := postForm(t, h, "/opportunity/zta-1/select", url.Values{}); rr.Code != http.StatusSeeOther { + t.Fatalf("select: status %d, want 303", rr.Code) + } + svc.Wait() + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest("GET", "/workspace/zta-1", http.NoBody)) + body := rr.Body.String() + if contains(body, "data-gapsummary") && !contains(body, "data-gapsummary hidden") { + t.Errorf("clean draft must render the gap summary hidden") + } + if contains(body, `class="gap-warn"`) { + t.Errorf("clean draft must not tint any textarea") + } } diff --git a/internal/dashboard/handler.go b/internal/dashboard/handler.go index af9a6d6..a531c59 100644 --- a/internal/dashboard/handler.go +++ b/internal/dashboard/handler.go @@ -392,10 +392,12 @@ func (h *Handler) setupTemplates() { "miniPipe": miniPipe, "wPipe": wPipe, "propChip": propChip, - // Unresolved Writer gaps (issue #269): per-body gap texts for the - // section editors, inline highlighting for read-only views. + // Unresolved Writer gaps (issues #269/#274): per-body gap texts for + // the section gap bars, inline highlighting for read-only + // views, and the aggregated review-gate summary. "gapTexts": finalreview.GapTexts, "highlightGaps": highlightGaps, + "gapSummary": summarizeGaps, "orDash": func(s string) string { if s == "" { return "—" diff --git a/internal/dashboard/proposals.go b/internal/dashboard/proposals.go index e14685a..cd011d3 100644 --- a/internal/dashboard/proposals.go +++ b/internal/dashboard/proposals.go @@ -276,11 +276,10 @@ func (h *Handler) handleWorkspace(w http.ResponseWriter, r *http.Request) { data.Doc = doc data.VersionLabel = versionLabel(doc) data.Criteria = deriveCriteria(opp, doc) - for _, f := range doc.Flags { - if !f.Resolved { - data.OpenFlags = append(data.OpenFlags, f) - } - } + // Per-gap "Unresolved gap" flags are excluded: the gate surfaces + // gaps through one aggregated summary + per-section bars instead + // of a banner per gap (issue #274). + data.OpenFlags = openNonGapFlags(doc.Flags) } } diff --git a/internal/dashboard/proposals_templates.go b/internal/dashboard/proposals_templates.go index 9465012..d45ff76 100644 --- a/internal/dashboard/proposals_templates.go +++ b/internal/dashboard/proposals_templates.go @@ -232,6 +232,18 @@ const workspaceContentTmpl = `{{define "content"}}
{{if .Doc}} + {{$gs := gapSummary .Doc.Sections}} +
+ ` + iconWarn + ` +
+ {{$gs.Headline}} +

Tomás flagged facts he could not ground — fill each [GAP] marker before approving. The counts update as you edit.

+
    + {{range $gs.Sections}}
  • {{.Heading}}{{.Count}}
  • + {{end}} +
+
+
What Tomás produced
Drafted {{len .Doc.Sections}} sections into the working draft — download the full Markdown or edit it inline below.
@@ -262,19 +274,24 @@ const workspaceContentTmpl = `{{define "content"}}
Working draft — edit sections directly Saved ` + iconDoc + `Open full editor
{{range .Doc.Sections}} -
+ {{$gaps := gapTexts .Body}} +

{{.Heading}}

{{.Status}}
- +
- {{range gapTexts .Body}} -
+
` + iconWarn + ` -
Unresolved gap

{{.}}

- +
+ {{len $gaps}} unresolved gap{{if ne (len $gaps) 1}}s{{end}} + +
+ +
- {{end}}
{{end}} {{else}} @@ -395,21 +412,7 @@ const workspaceContentTmpl = `{{define "content"}} }, 900); }); }); - // Jump-to-gap: select the [GAP: ...] marker inside the section's textarea so - // the browser scrolls to it and the human sees exactly what is missing. - document.querySelectorAll(".gap-jump").forEach(function (b) { - b.addEventListener("click", function () { - var area = b.closest("section").querySelector("textarea"); - if (!area) return; - var idx = area.value.indexOf(b.getAttribute("data-gap")); - var start = idx < 0 ? area.value.indexOf("[GAP:") : area.value.lastIndexOf("[GAP:", idx); - if (start < 0) return; - var end = area.value.indexOf("]", start); - area.focus(); - area.setSelectionRange(start, end < 0 ? area.value.length : end + 1); - area.scrollIntoView({ behavior: "smooth", block: "center" }); - }); - }); +` + gapScriptJS + ` {{end}} ` diff --git a/internal/dashboard/tokens.go b/internal/dashboard/tokens.go index 236af99..20218e8 100644 --- a/internal/dashboard/tokens.go +++ b/internal/dashboard/tokens.go @@ -808,14 +808,26 @@ const editorStylesCSS = ` .ed-flag b{ font:650 13px/1.35 var(--font-sans); color:color-mix(in oklab,var(--st-human) 75%,black); display:block; } .ed-flag p{ font:450 12.5px/1.5 var(--font-sans); color:var(--ink-soft); margin:3px 0 0; } -/* Unresolved Writer gaps (issue #269): amber-tint the section editor, give each - gap a callout with a jump control, and mark gaps inline in read-only views. */ +/* Unresolved Writer gaps (issues #269/#274): amber-tint the section editor, + aggregate the gaps into one count bar per section (next-gap cycling + an + on-demand list), one summary block at the review gate, and inline marks in + read-only views. */ .edsec textarea.gap-warn{ border-color:color-mix(in oklab,var(--st-human) 55%,transparent); background:color-mix(in oklab,var(--st-human-bg) 55%,var(--surface)); } .edsec textarea.gap-warn:focus{ border-color:var(--st-human); } .ed-gap{ align-items:center; } -.ed-gap > div{ flex:1; } -.ed-gap .gap-jump{ flex:none; color:color-mix(in oklab,var(--st-human) 75%,black); } +.ed-gap > div{ flex:1; min-width:0; } +.ed-gap .gap-next, .ed-gap .gap-toggle{ flex:none; color:color-mix(in oklab,var(--st-human) 75%,black); } +.ed-gap .gap-list{ list-style:none; margin:7px 0 0; padding:0; display:flex; flex-direction:column; gap:4px; } +.ed-gap .gap-list li{ font:450 12.5px/1.5 var(--font-sans); color:var(--ink-soft); position:relative; padding-left:14px; } +.ed-gap .gap-list li::before{ content:""; position:absolute; left:2px; top:7px; width:5px; height:5px; + border-radius:50%; background:var(--st-human); } +.gap-summary{ align-items:flex-start; } +.gap-summary .gs-list{ list-style:none; margin:9px 0 0; padding:0; display:flex; flex-direction:column; gap:5px; } +.gap-summary .gs-list li{ display:flex; align-items:center; gap:8px; font:500 12.5px/1.4 var(--font-sans); } +.gap-summary .gs-list a{ color:color-mix(in oklab,var(--st-human) 75%,black); font-weight:600; } +.gap-summary .gs-n{ font:600 11px/1 var(--font-mono); color:color-mix(in oklab,var(--st-human) 80%,black); + background:color-mix(in oklab,var(--st-human) 16%,transparent); border-radius:var(--r-pill); padding:3px 7px; } .gap-mark{ background:var(--st-human-bg); color:color-mix(in oklab,var(--st-human) 75%,black); border:1px solid color-mix(in oklab,var(--st-human) 35%,transparent); border-radius:4px; padding:0 4px; font-weight:600; } ` From 97c09281f81b41d74d31a09a2cb0a64adbe2e075 Mon Sep 17 00:00:00 2001 From: Malik Warren Date: Thu, 11 Jun 2026 17:26:25 -0400 Subject: [PATCH 2/2] 274_hide_zero_gap_bars The design system sets display:flex on .ed-flag, which beats the UA stylesheet default for the [hidden] attribute - so clean sections showed zero-count gap bars. Re-assert display:none for hidden gap bars, the gate summary, and the collapsed gap list. Co-Authored-By: Claude Fable 5 --- internal/dashboard/editor_test.go | 6 ++++++ internal/dashboard/tokens.go | 3 +++ 2 files changed, 9 insertions(+) diff --git a/internal/dashboard/editor_test.go b/internal/dashboard/editor_test.go index 8b5dcfe..32fe1e2 100644 --- a/internal/dashboard/editor_test.go +++ b/internal/dashboard/editor_test.go @@ -137,6 +137,12 @@ func TestEditorNoGaps_NoWarnUI(t *testing.T) { if contains(body, "data-gapbar") && !contains(body, "data-gapbar hidden") { t.Errorf("clean draft must render gap bars hidden") } + // The design system sets display:flex on .ed-flag, which beats the UA's + // [hidden] default — the stylesheet must re-assert it or hidden bars show + // as "0 unresolved gaps" callouts. + if !contains(body, ".ed-gap[hidden]") { + t.Errorf("stylesheet must keep [hidden] gap bars display:none") + } } // TestWorkspaceGateAggregatesGaps: the review-gate section editors get the diff --git a/internal/dashboard/tokens.go b/internal/dashboard/tokens.go index 20218e8..fa3083b 100644 --- a/internal/dashboard/tokens.go +++ b/internal/dashboard/tokens.go @@ -815,6 +815,9 @@ const editorStylesCSS = ` .edsec textarea.gap-warn{ border-color:color-mix(in oklab,var(--st-human) 55%,transparent); background:color-mix(in oklab,var(--st-human-bg) 55%,var(--surface)); } .edsec textarea.gap-warn:focus{ border-color:var(--st-human); } +/* The display rules above would otherwise beat the UA's [hidden] default, so + re-assert it: bars/summary/list only show while gaps exist. */ +.ed-gap[hidden], .gap-summary[hidden], .gap-list[hidden]{ display:none !important; } .ed-gap{ align-items:center; } .ed-gap > div{ flex:1; min-width:0; } .ed-gap .gap-next, .ed-gap .gap-toggle{ flex:none; color:color-mix(in oklab,var(--st-human) 75%,black); }