diff --git a/internal/dashboard/editor_test.go b/internal/dashboard/editor_test.go index 32fe1e2..0b3868b 100644 --- a/internal/dashboard/editor_test.go +++ b/internal/dashboard/editor_test.go @@ -158,26 +158,40 @@ func TestWorkspaceGateAggregatesGaps(t *testing.T) { t.Fatalf("GET /workspace/zta-1: status %d", rr.Code) } body := rr.Body.String() + // The gate keeps the aggregated summary + the entry point to the full editor, + // but the per-section gap bars and next-gap controls now live ONLY on the full + // editor (the gate stays a clean review surface). for _, want := range []string{ - `class="gap-warn"`, - "2 unresolved gaps", - "data-gapnext", - "number of cleared staff", - "facility clearance level", + `class="gap-warn"`, // a textarea with gaps is still highlighted "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 + "Open full editor mode", // entry point to gap resolution } { 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) + // Match the rendered elements (trailing ">"), not the shared JS selectors + // ("[data-gapbar]") that the live-recount script still references. + if got := strings.Count(body, "data-gapbar>"); got != 0 { + t.Errorf("the gate must not render per-section gap bars (they live on the full editor now), 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, "data-gapnext>") { + t.Errorf("the gate must not render the next-gap control") + } + + // The full editor still carries the per-section gap bar + next-gap control. + er := httptest.NewRecorder() + h.ServeHTTP(er, httptest.NewRequest("GET", "/editor/zta-1", http.NoBody)) + if er.Code != http.StatusOK { + t.Fatalf("GET /editor/zta-1: status %d", er.Code) + } + ebody := er.Body.String() + for _, want := range []string{"data-gapbar>", "data-gapnext>", "unresolved gap"} { + if !contains(ebody, want) { + t.Errorf("/editor missing %q (gap controls must live here)", want) + } } } diff --git a/internal/dashboard/proposals.go b/internal/dashboard/proposals.go index cd011d3..b95bb52 100644 --- a/internal/dashboard/proposals.go +++ b/internal/dashboard/proposals.go @@ -345,7 +345,14 @@ func (h *Handler) handleAction(action string) http.HandlerFunc { case "changes": err = h.proposals.RequestChanges(r.Context(), id, r.FormValue("note")) case "submit": - err = h.proposals.Submit(r.Context(), id) + // Demo mode (the "Save to Google Drive" toggle) saves the package from + // any state, skipping the ready-to-submit gate; production submit is + // unchanged. + if r.FormValue("demo") == "1" { + err = h.proposals.SubmitDemo(r.Context(), id) + } else { + err = h.proposals.Submit(r.Context(), id) + } } if err != nil { http.Error(w, err.Error(), http.StatusConflict) diff --git a/internal/dashboard/proposals_templates.go b/internal/dashboard/proposals_templates.go index d45ff76..bb64175 100644 --- a/internal/dashboard/proposals_templates.go +++ b/internal/dashboard/proposals_templates.go @@ -233,33 +233,25 @@ 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.

+

Tomás flagged facts he could not ground. Open the full editor to resolve each [GAP] marker — the counts update as you edit.

+ ` + iconDoc + `Open full editor mode
What Tomás produced
-
Drafted {{len .Doc.Sections}} sections into the working draft — download the full Markdown or edit it inline below.
+
Drafted {{len .Doc.Sections}} sections into the working draft — download the full Markdown, or open the full editor to review every section and resolve gaps.
- {{range .OpenFlags}} -
-
` + iconWarn + `
-
-
{{.Title}}
-
{{.Detail}}
-
-
- {{end}} - {{if .Criteria}}
Check against criteria
@@ -281,17 +273,6 @@ const workspaceContentTmpl = `{{define "content"}} -
- ` + iconWarn + ` -
- {{len $gaps}} unresolved gap{{if ne (len $gaps) 1}}s{{end}} - -
- - -
{{end}} {{else}} @@ -306,6 +287,13 @@ const workspaceContentTmpl = `{{define "content"}} + +
Approving runs Vera's final pass on your edited draft. Requesting changes sends it back to Tomás.
@@ -324,11 +312,11 @@ const workspaceContentTmpl = `{{define "content"}}
` + iconCheck + `
-

Package ready to submit

-
All stages complete — Vera's final pass validated the draft as you left it. Final human submission to SAM.gov.
+

Package ready to save

+
All stages complete — Vera's final pass validated the draft as you left it. Save the final package to your Google Drive.
- +
@@ -338,8 +326,8 @@ const workspaceContentTmpl = `{{define "content"}}
` + iconCheck + `
-

Submitted to SAM.gov

-
Confirmation logged · the agents stand down on this one.
+

Saved to Google Drive

+
The final package is in your Drive · the agents stand down on this one.
Kaimi will watch for amendments and Q&A updates on this solicitation and let you know if anything needs attention.
@@ -380,6 +368,8 @@ const workspaceContentTmpl = `{{define "content"}} .ed-save-chip { font: 600 11px/1 var(--font-sans); color: var(--st-done); margin-left: 8px; text-transform: none; letter-spacing: 0; } .ed-save-chip.saving { color: var(--ink-3); } .r-actions form { margin: 0; } + .demo-toggle { display: inline-flex; align-items: center; gap: 6px; font: var(--t-small); color: var(--ink-3); cursor: pointer; user-select: none; margin-left: auto; } + .demo-toggle input { accent-color: var(--primary, #2563EB); width: 15px; height: 15px; } .draft-body { white-space: pre-wrap; background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md); padding: 12px 14px; font: var(--t-body); color: var(--ink); } .draft-pending { border: 1px dashed var(--border); border-radius: var(--r-md); padding: 12px 14px; font: var(--t-small); color: var(--ink-3); font-style: italic; } @@ -412,6 +402,26 @@ const workspaceContentTmpl = `{{define "content"}} }, 900); }); }); + + // Demo mode: a persisted toggle that reveals the "Save to Google Drive" action + // at the gate, so a presenter can show the full lifecycle without waiting for + // Vera's final pass. Production submit (the ready state) is unaffected. + (function () { + var KEY = "kaimi_demo_mode"; + function apply(on) { + document.querySelectorAll("[data-demo-only]").forEach(function (el) { el.hidden = !on; }); + } + var toggle = document.querySelector("[data-demo-toggle]"); + var on = localStorage.getItem(KEY) === "1"; + if (toggle) toggle.checked = on; + apply(on); + if (toggle) { + toggle.addEventListener("change", function () { + localStorage.setItem(KEY, toggle.checked ? "1" : "0"); + apply(toggle.checked); + }); + } + })(); ` + gapScriptJS + ` {{end}} diff --git a/internal/dashboard/proposals_test.go b/internal/dashboard/proposals_test.go index 7bf4688..17b2bdb 100644 --- a/internal/dashboard/proposals_test.go +++ b/internal/dashboard/proposals_test.go @@ -146,7 +146,7 @@ func TestProposalFlowOverHTTP(t *testing.T) { svc.Wait() body = get(t, h, "/workspace/zta-1") - if !contains(body, "Package ready to submit") || !contains(body, "Submit to SAM.gov") { + if !contains(body, "Package ready to save") || !contains(body, "Save to Google Drive") { t.Fatalf("workspace should be at the ready state after a clean final review") } @@ -156,11 +156,34 @@ func TestProposalFlowOverHTTP(t *testing.T) { t.Fatalf("submit: status %d, want 303", rr.Code) } body = get(t, h, "/workspace/zta-1") - if !contains(body, "Submitted to SAM.gov") { + if !contains(body, "Saved to Google Drive") { t.Errorf("workspace should show the submitted state") } } +// TestDemoSubmitFromGate proves the demo-mode "Save to Google Drive" action +// saves the package from the gate (skipping the ready-to-submit gate), while a +// normal submit from the gate is still rejected. +func TestDemoSubmitFromGate(t *testing.T) { + h, svc, _ := newProposalHandler(t) + postForm(t, h, "/opportunity/zta-1/select", url.Values{}) + svc.Wait() // paused at the human gate — not ready to submit + + // A normal submit from the gate is rejected. + if rr := postForm(t, h, "/workspace/zta-1/submit", url.Values{}); rr.Code != http.StatusConflict { + t.Errorf("non-demo submit from the gate: status %d, want 409", rr.Code) + } + // The demo submit saves from the gate and reaches the saved state. + rr := postForm(t, h, "/workspace/zta-1/submit", url.Values{"demo": {"1"}}) + if rr.Code != http.StatusSeeOther { + t.Fatalf("demo submit: status %d, want 303", rr.Code) + } + body := get(t, h, "/workspace/zta-1") + if !contains(body, "Saved to Google Drive") { + t.Errorf("demo submit should reach the saved-to-drive state") + } +} + // TestRequestChangesOverHTTP exercises the gate's other decision. func TestRequestChangesOverHTTP(t *testing.T) { h, svc, opps := newProposalHandler(t) diff --git a/internal/proposal/proposal.go b/internal/proposal/proposal.go index 1cf12dd..e236387 100644 --- a/internal/proposal/proposal.go +++ b/internal/proposal/proposal.go @@ -230,6 +230,21 @@ func (s *Service) Submit(ctx context.Context, oppID string) error { return s.setStatus(ctx, oppID, StatusSubmitted) } +// SubmitDemo marks a selected proposal submitted from any in-progress state. It +// exists only for demos (the "Demo mode" toggle / "Save to Google Drive" action) +// so a presenter can show the full lifecycle without waiting for the Final +// Review. Production submit (Submit) still requires the ready-to-submit gate. +func (s *Service) SubmitDemo(ctx context.Context, oppID string) error { + opp, err := s.deps.Opportunities.Get(ctx, oppID) + if err != nil { + return err + } + if !opp.Selected { + return fmt.Errorf("opportunity %s is not in your proposals", oppID) + } + return s.setStatus(ctx, oppID, StatusSubmitted) +} + // atGate reports whether the proposal is paused for the human. func atGate(status string) bool { return status == StatusGate || status == StatusReviewNeedsHuman