From b13db6eb1804430b813a3efe248e9fbfe23b7ea4 Mon Sep 17 00:00:00 2001 From: Gergely Gazda Date: Thu, 8 Oct 2026 14:04:25 +0200 Subject: [PATCH 1/8] docs: plan and interactive mockup for the recommended-target accept button sc-281 adds Accept / Accept all / Accept all & re-run-selection buttons to the targets Check page, so an organiser can apply the algorithm's recommended min/max relaxations in one click instead of retyping them. The plan maps the change onto the existing feasibility and target-save services (reusing check_targets_detailed and update_target_value), and the standalone HTML mockup gives Hamish a clickable before/after prototype plus user-flow and data-flow diagrams for review. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01X12D2pPnMTbVGriEVMRcJL --- backend/docs/agent/sc-281-mockup.html | 720 ++++++++++++++++++ .../sc-281-recommended-target-button-plan.md | 135 ++++ 2 files changed, 855 insertions(+) create mode 100644 backend/docs/agent/sc-281-mockup.html create mode 100644 backend/docs/agent/sc-281-recommended-target-button-plan.md diff --git a/backend/docs/agent/sc-281-mockup.html b/backend/docs/agent/sc-281-mockup.html new file mode 100644 index 000000000..a02b53b5f --- /dev/null +++ b/backend/docs/agent/sc-281-mockup.html @@ -0,0 +1,720 @@ + + + + + + sc-281 · Implement recommended target changes + + + + +
+
+
OpenDLP · Selection
+ + +
+
+ +
+ +
+

sc-281 Stratified selection improvements · Epic 426

+

Implement recommended target changes with one click

+

When an initial selection can't meet its targets, the algorithm already tells the organiser exactly which minimum or maximum to relax. Today they have to copy those numbers in by hand. This change adds Accept buttons that apply a recommendation — and optionally re-run the selection — in a single action.

+
+ type Feature + priority High + pattern from sc-447 replacements + surface Targets “Check” page +
+
+ + +
+
01

The problem

+

On the targets page, hitting Check runs a feasibility check. When the quotas can't all be met, the page already shows structured suggestions — “Suggested minimum: 12 (currently 15)”. But they're read-only prose: to act on one, the organiser must open edit mode, find the value, and retype the number. The replacement flow (sc-447) solved the identical problem with one-click accept buttons; this brings that to the initial selection.

+
+
+

What exists today. check_targets_detailed() produces a TargetAnnotation(level="suggestion", suggested_value=N) for every min/max the solver would relax, keyed to its category & value. The template renders them — with no way to apply them.

+
+
+

What we add. An Accept button on each suggestion, plus page-level Accept all and Accept all & re-run selection. Each posts to a new route that applies the authoritative suggested value via the existing target-save service, then re-checks or runs.

+
+
+
+ + +
+
02

Interactive prototype

+

A working reproduction of the targets Check page with infeasible quotas. Toggle between today's read-only view and the proposed one, then click the buttons — values update, suggestions clear, and “Accept all & re-run” hands off to a selection run.

+ +
+
+ + +
+ 2 suggestions outstanding — click Accept to apply. + +
+ +
+
+ + /backoffice/assembly/ebcb87…/targets/check +
+
+
+
+

Demographic targets

+ Climate Assembly · 50 to select +
+ proposed inline actions +
+ +
+ ! + The targets cannot all be met from the pool at once. The algorithm suggests the changes below. +
+ + +
+ + +
+ proposed  2 recommendations across all targets + + +
+ +
+
+
+ + +
+
03

User flow

+

The happy path from an infeasible check to a feasible, running selection — all without leaving the targets page.

+
+
STEP 1

Check targets

Organiser runs the feasibility check; quotas are infeasible.

+
STEP 2

See suggestions

Each unmeetable min/max shows a suggested value, inline with its row.

+
STEP 3

Accept

One suggestion, or Accept all. Server applies & saves the real targets.

+
STEP 4a

Re-check

Page reloads with a fresh check — the accepted suggestion is gone.

+
STEP 4b

…& re-run

Or go straight to a live selection run from the same click.

+
+
+ + +
+
04

Data flow

+

Server-side and authoritative: the new route re-derives the suggestions itself rather than trusting posted numbers, applies each through the existing target-value service, then redirects to either a re-check or a selection run. No new domain logic, no client-side state.

+ +
+ + + + + + + + + + + Accept button (template) + category_block.html + POST scope·category·value·field·then + + + + + + + accept_suggestions() + targets.py · NEW route + /targets/accept-suggestions [POST] + @login_required · manage perms · CSRF + + + + + + + + check_targets_detailed() + target_checking.py · reused + re-derive authoritative suggestions + + + + + + + update_target_value() + target_service.py · reused + apply suggested min/max · 1 or all + + + + + + + + persisted targets + one unit of work + minmax_manual = true + + + + + + + redirect → check_targets + then = recheck (default) + fresh feasibility, suggestions cleared + + + + + + then = run → start_db_select_task + + +
+ request / apply + re-check branch + re-run branch + reused, unchanged +
+
+
+ + +
+
05

Scope & decisions

+
+
Surface

Targets Check page. It already holds the structured suggestions; a failed run only stores plain-text error, so this is the only place with the data.

+
Granularity

Per-suggestion Accept + Accept all, mirroring the replacement feature exactly.

+
Action

Accept applies + saves + re-checks. A separate Accept all & re-run applies, saves, and starts the selection.

+
+ +
+
+

In scope

+
    +
  • One-click accept of a recommended min or max, server-side.
  • +
  • Accept-all and accept-all-and-run page actions.
  • +
  • Re-check after accepting so feasibility is confirmed.
  • +
+
+
+

Out of scope

+
    +
  • Side-by-side “review in context” edit page.
  • +
  • Anything replacement-specific (original targets, saved-number handling).
  • +
  • Changing how suggestions are computed — reused as-is.
  • +
+
+
+
+ + +
+
06

Files touched

+

One new route and two template edits. The heavy lifting — feasibility and persistence — is entirely reused.

+
+
new
blueprints/targets.py → accept_suggestions()
POST route: re-derive suggestions, apply via service, redirect to re-check or run.
+
edit
templates/backoffice/targets/category_block.html
Accept button beside each value-level & category-level suggestion.
+
edit
templates/backoffice/assembly_targets.html
Page-level “Accept all” and “Accept all & re-run selection”.
+
reuse
target_service.update_target_value()
Applies a manual min/max, breaking the percentage link — existing validation.
+
reuse
target_checking.check_targets_detailed()
Authoritative suggestion source, re-run inside the new route.
+
reuse
db_selection_backoffice.start_db_select_task()
The existing Run path for the “& re-run selection” variant.
+
+
+ +
+

sc-281 · Add an implement-recommended-target-changes button (initial selection). Interactive mockup — buttons simulate behaviour for review; numbers and assembly are illustrative. Companion plan: sc-281-recommended-target-button-plan.md.

+
+ +
+ + + + diff --git a/backend/docs/agent/sc-281-recommended-target-button-plan.md b/backend/docs/agent/sc-281-recommended-target-button-plan.md new file mode 100644 index 000000000..68a0f6cfd --- /dev/null +++ b/backend/docs/agent/sc-281-recommended-target-button-plan.md @@ -0,0 +1,135 @@ +# sc-281 — Add an "implement recommended target changes" button (initial selection) + +## Context + +When an **initial** stratified selection's targets are infeasible, the targets "Check" +page already shows the algorithm's recommended min/max changes (e.g. *"Suggested minimum: +12 (currently 15)"*) — but only as read-only text. To actually apply a recommendation the +organiser must manually enter edit mode and retype the number for each value. The +replacement feature (sc-447, now Done) solved the equivalent problem with one-click +**Accept** / **Accept all** buttons. sc-281 brings that same one-click experience to the +initial selection. + +Branch: `281-recommended-target-button`. + +See the companion interactive mockup: `sc-281-mockup.html` (open in a browser). + +## Where the recommendations already live + +- `check_targets_detailed()` (`backend/src/opendlp/service_layer/target_checking.py:282`) + runs the feasibility check and, on `InfeasibleQuotasError`, produces structured + `TargetAnnotation(level="suggestion", field="min"|"max", suggested_value=N)` entries + keyed by category/value (`_annotations_from_infeasible_quotas`, line 189). +- The `check_targets` route (`backend/src/opendlp/entrypoints/blueprints/targets.py:366`) + renders these via `assembly_targets.html` → `category_block.html`, where suggestions + appear read-only (value-level at line 182, category-level at line 89). +- A **failed selection run** only stores a plain-text `error_message` — no structured + suggestions — so the targets Check page is the only surface with the data, and is + already "the box with targets + immediate feasibility check" the ticket's *To consider* + section asks us to mirror from replacements. + +## Decisions (confirmed with product) + +- **Surface:** targets Check page — add buttons to the existing suggestion annotations. +- **Granularity:** per-suggestion **Accept** + an **Accept all** (mirrors sc-447). +- **Action:** Accept / Accept all → apply + save + **re-check feasibility** (stay on the + targets page). Plus a separate **Accept all & re-run selection** button → apply + save + + start the real selection run. + +## Approach + +Server-side POST (no client-side form-fill): the Check page is read-only, so unlike the +replacement modal — which fills live inputs in an open form — each button submits to a new +endpoint that applies the change authoritatively and re-renders. + +### 1. Backend — new endpoint in `targets.py` + +Add `POST /assembly//targets/accept-suggestions` to +`backend/src/opendlp/entrypoints/blueprints/targets.py` (beside `check_targets`, ~line 366). + +Behaviour: +1. Re-run `check_targets_detailed(uow, user_id, assembly_id)` to get the **authoritative** + current suggestions (don't trust client-sent numbers). +2. Read form: + - `scope` = `"one"` or `"all"`. + - for `scope="one"`: `category_id`, `value_id`, `field` ("min"/"max") identify which + suggestion to apply. + - `then` = `"recheck"` (default) or `"run"` (the "& re-run selection" variant). +3. For each targeted suggestion, apply it by calling the existing + **`update_target_value()`** (`backend/src/opendlp/service_layer/target_service.py:530`) + with the value's current `value` name, current min/max, and the one suggested field + overridden (min→`suggested_value` keeping current max, or max→`suggested_value` keeping + current min). This reuses the manual-override path (`set_manual_min_max`, + `domain/targets.py:167`) and its validation; no new domain logic. + - Batch all applies for `scope="all"` inside one `with uow:` block. +4. Redirect: + - `then="recheck"` → `url_for("targets.check_targets", ...)` (re-renders with fresh + feasibility — identical to what `save_all` already does at `targets.py:508`). + - `then="run"` → start the selection the same way the Run button does + (`start_db_selection` / `start_db_select_task`). Simplest is to apply+save here, flash + success, then hand off to the selection run; confirm during impl whether to call + `start_db_select_task` directly in this handler or redirect into the existing POST. + +Guard with the same decorators/patterns as neighbouring routes (`@login_required`, +`get_assembly_with_permissions`, CSRF via the project's form pattern). Match the existing +`try/except NotFoundError/InsufficientPermissions/Exception` + `flash` structure in +`check_targets`. + +### 2. Templates + +In `backend/templates/backoffice/targets/category_block.html`: +- Value-level suggestion (line ~182): after `{{ ann.message }}`, render an **Accept** + button inside a small `
` posting `scope=one`, `category_id` + (`category.id`), `value_id` (`val.value_id`), `field` (`ann.field`), `then=recheck`, and + the CSRF token. Use the design-system `button` macro (`variant="tertiary"`), matching the + replacement modal's Accept styling (`db_replacement_modal.html:97`). +- Category-level suggestions (line ~89): same treatment where a `suggested_value` exists. + +In `backend/templates/backoffice/assembly_targets.html` (near where `check_result` is +consumed): when `check_result` has any suggestion annotations, render **Accept all +suggestions** (`scope=all, then=recheck`) and **Accept all & re-run selection** +(`scope=all, then=run`) as two POST forms. Only show them when at least one suggestion +exists. + +No new JS needed (server-side POST). This is the deliberate divergence from +`replacement-suggestions.js`, which only works because the replacement modal has live +inputs in an open form. + +### 3. i18n + +New strings (`_("Accept")`, `_("Accept all suggestions")`, +`_("Accept all & re-run selection")`) go through the existing `gettext`/`_()` path. Run the +project's message-extraction step so `messages.po` picks them up (follow the repo's +existing translation workflow; do not hand-edit catalogues). + +## Files to modify + +- `backend/src/opendlp/entrypoints/blueprints/targets.py` — new `accept_suggestions` route. +- `backend/templates/backoffice/targets/category_block.html` — per-suggestion Accept forms. +- `backend/templates/backoffice/assembly_targets.html` — page-level Accept-all / re-run forms. +- (Reused, not modified) `target_service.update_target_value`, + `target_checking.check_targets_detailed`, `db_selection_backoffice.start_db_selection`. + +## Tests + +- **Component** (`backend/tests/component/test_targets_pages.py`): posting to + `accept-suggestions` with `scope=one` applies the suggested min/max to the right value and + redirects to the re-check; `scope=all` applies every suggestion; `then=run` starts a + selection. Assert the value's stored min/max and `minmax_manual=True` afterwards. +- **Unit** (`backend/tests/unit/test_target_checking.py` or `test_target_service.py`): + confirm the apply-one-suggestion helper overrides only the targeted field and leaves the + other bound. Mirror how `test_replacement_targets.py` covers accept logic. +- Render check: after accepting, `check_targets` shows no remaining suggestion for the + accepted value (feasibility improved) — assert via the component test's response HTML. + +## Verification (end-to-end) + +1. In the worktree: `npm ci` + generated_version.txt if fresh, then `just run`. +2. Open an assembly whose targets are infeasible, go to the targets page, hit **Check** so + suggestions appear. +3. Click a single **Accept** → value's min/max updates, page re-checks, that suggestion is + gone. +4. Re-introduce infeasibility, click **Accept all suggestions** → all applied, re-checked. +5. Click **Accept all & re-run selection** → targets saved and a selection run starts + (progress modal opens on the selection page). +6. Run `just test` (or the targeted pytest files above); BDD auth failures are pre-existing. From 4e51a8b7a303f8eb79538847fc2692973f157510 Mon Sep 17 00:00:00 2001 From: Gergely Gazda Date: Mon, 12 Oct 2026 14:49:10 +0200 Subject: [PATCH 2/8] docs: reframe sc-281 around the selection-failure entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the plan and mockup after Hamish's review. The journey now starts where the problem actually surfaces — a failed initial selection — with an "Adjust targets" link into the targets check rather than assuming the organiser is already on the targets page. Adds the audit comment recorded on accept (the mockup demonstrates both candidate behaviours, auto-written and review-before-save, via a toggle), and gates the "Accept all & re-run selection" button on having arrived from a selection run. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01X12D2pPnMTbVGriEVMRcJL --- backend/docs/agent/sc-281-mockup.html | 878 ++++++++++-------- .../sc-281-recommended-target-button-plan.md | 256 ++--- 2 files changed, 631 insertions(+), 503 deletions(-) diff --git a/backend/docs/agent/sc-281-mockup.html b/backend/docs/agent/sc-281-mockup.html index a02b53b5f..fb1aef79b 100644 --- a/backend/docs/agent/sc-281-mockup.html +++ b/backend/docs/agent/sc-281-mockup.html @@ -54,153 +54,154 @@ * { box-sizing: border-box; } html { scroll-behavior: smooth; } @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } } - body { - margin: 0; background: var(--bg); color: var(--ink); - font-family: var(--sans); line-height: 1.55; - -webkit-font-smoothing: antialiased; font-size: 16px; - } + body { margin: 0; background: var(--bg); color: var(--ink); font-family: var(--sans); line-height: 1.55; -webkit-font-smoothing: antialiased; font-size: 16px; } .wrap { max-width: 1120px; margin: 0 auto; padding: 0 24px; } - /* ---- Top bar ---- */ - .topbar { - position: sticky; top: 0; z-index: 40; - background: color-mix(in srgb, var(--bg) 86%, transparent); - backdrop-filter: blur(10px); - border-bottom: 1px solid var(--line); - } + .topbar { position: sticky; top: 0; z-index: 40; background: color-mix(in srgb, var(--bg) 86%, transparent); backdrop-filter: blur(10px); border-bottom: 1px solid var(--line); } .topbar .wrap { display: flex; align-items: center; gap: 18px; height: 56px; } .brand { font-weight: 680; letter-spacing: -.01em; font-size: 15px; display: flex; align-items: center; gap: 9px; } .brand .dot { width: 10px; height: 10px; border-radius: 3px; background: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); } .navlinks { display: flex; gap: 4px; margin-left: auto; flex-wrap: wrap; } - .navlinks a { - color: var(--ink-soft); text-decoration: none; font-size: 13px; font-weight: 560; - padding: 6px 10px; border-radius: 8px; - } + .navlinks a { color: var(--ink-soft); text-decoration: none; font-size: 13px; font-weight: 560; padding: 6px 10px; border-radius: 8px; } .navlinks a:hover { background: var(--surface-2); color: var(--ink); } - .themebtn { - border: 1px solid var(--line); background: var(--surface); color: var(--ink-soft); - width: 34px; height: 34px; border-radius: 9px; cursor: pointer; font-size: 15px; - display: grid; place-items: center; - } + .themebtn { border: 1px solid var(--line); background: var(--surface); color: var(--ink-soft); width: 34px; height: 34px; border-radius: 9px; cursor: pointer; font-size: 15px; display: grid; place-items: center; } .themebtn:hover { color: var(--ink); border-color: var(--accent); } - /* ---- Hero ---- */ - header.hero { padding: 56px 0 30px; } - .eyebrow { - font-family: var(--mono); font-size: 12px; letter-spacing: .08em; text-transform: uppercase; - color: var(--accent-ink); font-weight: 600; margin: 0 0 14px; - display: inline-flex; gap: 10px; align-items: center; - } - .eyebrow .pill { - background: var(--accent-soft); color: var(--accent-ink); - padding: 3px 9px; border-radius: 999px; letter-spacing: .04em; - } + header.hero { padding: 54px 0 28px; } + .eyebrow { font-family: var(--mono); font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--accent-ink); font-weight: 600; margin: 0 0 14px; display: inline-flex; gap: 10px; align-items: center; } + .eyebrow .pill { background: var(--accent-soft); color: var(--accent-ink); padding: 3px 9px; border-radius: 999px; letter-spacing: .04em; } h1 { font-size: clamp(30px, 5vw, 46px); line-height: 1.05; letter-spacing: -.025em; margin: 0 0 18px; text-wrap: balance; font-weight: 720; } - .lede { font-size: clamp(17px, 2.3vw, 20px); color: var(--ink-soft); max-width: 65ch; margin: 0; } - .meta { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 26px; } - .chip { - display: inline-flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 560; - background: var(--surface); border: 1px solid var(--line); color: var(--ink-soft); - padding: 7px 12px; border-radius: 999px; - } + .lede { font-size: clamp(16px, 2.2vw, 19px); color: var(--ink-soft); max-width: 68ch; margin: 0; } + .lede + .lede { margin-top: 14px; } + .meta { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 24px; } + .chip { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 560; background: var(--surface); border: 1px solid var(--line); color: var(--ink-soft); padding: 7px 12px; border-radius: 999px; } .chip b { color: var(--ink); font-weight: 640; } .chip .k { color: var(--muted); font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; } - /* ---- Sections ---- */ section { padding: 30px 0; } - .sec-head { display: flex; align-items: baseline; gap: 14px; margin-bottom: 18px; } + .sec-head { display: flex; align-items: baseline; gap: 14px; margin-bottom: 16px; } .sec-num { font-family: var(--mono); font-size: 13px; color: var(--accent); font-weight: 600; } h2 { font-size: clamp(22px, 3vw, 28px); letter-spacing: -.02em; margin: 0; font-weight: 680; text-wrap: balance; } - .sec-sub { color: var(--ink-soft); max-width: 68ch; margin: 10px 0 0; } + .sec-sub { color: var(--ink-soft); max-width: 70ch; margin: 10px 0 0; } - .card { - background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); - box-shadow: var(--shadow); - } + .card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); } .card.pad { padding: 22px 24px; } - .cols { display: grid; gap: 16px; } @media (min-width: 760px) { .cols.two { grid-template-columns: 1fr 1fr; } } - .callout { border-left: 3px solid var(--accent); padding: 4px 0 4px 16px; color: var(--ink-soft); } .callout b { color: var(--ink); } + p code, li code { background: var(--surface-2); border: 1px solid var(--line); padding: 1px 6px; border-radius: 6px; color: var(--accent-ink); font-family: var(--mono); font-size: .86em; } - code, .mono { font-family: var(--mono); font-size: .88em; } - p code, li code { background: var(--surface-2); border: 1px solid var(--line); padding: 1px 6px; border-radius: 6px; color: var(--accent-ink); } - - /* ---- Prototype shell ---- */ - .proto-controls { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 14px; } + /* ---- prototype controls ---- */ + .panel { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 16px; box-shadow: var(--shadow); margin-bottom: 14px; } + .ctl-row { display: flex; flex-wrap: wrap; gap: 14px 20px; align-items: center; } + .ctl { display: flex; flex-direction: column; gap: 6px; } + .ctl .lab { font-family: var(--mono); font-size: 10.5px; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); } .seg { display: inline-flex; background: var(--surface-2); border: 1px solid var(--line); border-radius: 10px; padding: 3px; } - .seg button { - border: 0; background: transparent; color: var(--ink-soft); font: inherit; font-size: 13px; font-weight: 600; - padding: 7px 14px; border-radius: 7px; cursor: pointer; - } + .seg button { border: 0; background: transparent; color: var(--ink-soft); font: inherit; font-size: 13px; font-weight: 600; padding: 7px 13px; border-radius: 7px; cursor: pointer; } .seg button[aria-pressed="true"] { background: var(--surface); color: var(--ink); box-shadow: var(--shadow); } - .hint { font-size: 13px; color: var(--muted); } - .hint b { color: var(--ink-soft); } - .linkbtn { margin-left: auto; border: 1px solid var(--line); background: var(--surface); color: var(--ink-soft); - font: inherit; font-size: 13px; font-weight: 600; padding: 7px 13px; border-radius: 9px; cursor: pointer; } + .seg.amber button[aria-pressed="true"] { color: var(--sug); } + .linkbtn { margin-left: auto; align-self: flex-end; border: 1px solid var(--line); background: var(--surface); color: var(--ink-soft); font: inherit; font-size: 13px; font-weight: 600; padding: 7px 13px; border-radius: 9px; cursor: pointer; } .linkbtn:hover { border-color: var(--accent); color: var(--ink); } - - /* app frame mimicking the backoffice targets page */ - .app { - background: var(--surface-2); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; - } + .mode-note { margin: 12px 0 0; font-size: 12.5px; color: var(--ink-soft); background: var(--sug-bg); border: 1px solid var(--sug-line); border-radius: 9px; padding: 9px 12px; } + .mode-note b { color: var(--sug); } + + /* steps */ + .steps { display: inline-flex; gap: 6px; align-items: center; } + .steps button { border: 1px solid var(--line); background: var(--surface-2); color: var(--ink-soft); font: inherit; font-size: 12.5px; font-weight: 600; padding: 6px 12px; border-radius: 999px; cursor: pointer; display: inline-flex; gap: 7px; align-items: center; } + .steps button .idx { font-family: var(--mono); font-size: 11px; width: 17px; height: 17px; border-radius: 50%; display: grid; place-items: center; background: var(--line); color: var(--ink); } + .steps button[aria-current="true"] { border-color: var(--accent); color: var(--ink); background: var(--surface); } + .steps button[aria-current="true"] .idx { background: var(--accent); color: #fff; } + .steps .arr { color: var(--muted); } + + /* app frame */ + .app { background: var(--surface-2); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; } .app-bar { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--line); background: var(--surface); } .app-bar .tl { display: flex; gap: 6px; } .app-bar .tl i { width: 11px; height: 11px; border-radius: 50%; background: var(--line); display: inline-block; } - .app-bar .url { font-family: var(--mono); font-size: 12px; color: var(--muted); background: var(--surface-2); border: 1px solid var(--line); - padding: 4px 10px; border-radius: 7px; margin-left: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .app-bar .url { font-family: var(--mono); font-size: 12px; color: var(--muted); background: var(--surface-2); border: 1px solid var(--line); padding: 4px 10px; border-radius: 7px; margin-left: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .app-body { padding: 20px; } + /* ---- selection failure modal ---- */ + .modalcard { max-width: 560px; margin: 8px auto; background: var(--surface); border: 1px solid var(--line); border-radius: 13px; box-shadow: var(--shadow); overflow: hidden; } + .modalcard .mhead { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--line); } + .modalcard .mhead h3 { margin: 0; font-size: 16px; } + .badge-status { margin-left: auto; font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; padding: 4px 9px; border-radius: 999px; background: var(--err-bg); color: var(--err); border: 1px solid var(--err-line); } + .modalcard .mbody { padding: 16px 18px; } + .kv { font-size: 13.5px; color: var(--ink-soft); margin: 0 0 12px; } + .kv b { color: var(--ink); } + .errbox { background: var(--err-bg); border: 1px solid var(--err-line); color: var(--err); border-radius: 10px; padding: 12px 14px; font-size: 13.5px; } + .errbox .et { color: var(--ink-soft); margin: 0 0 8px; } + .errbox ul { margin: 0; padding-left: 20px; } + .errbox li { margin: 3px 0; color: var(--ink); } + .errbox li b { color: var(--err); font-variant-numeric: tabular-nums; } + .modalcard .mfoot { display: flex; gap: 10px; align-items: center; padding: 14px 18px; border-top: 1px solid var(--line); background: var(--surface-2); flex-wrap: wrap; } + .foot-note { font-size: 12px; color: var(--muted); flex-basis: 100%; } + .foot-note.dead { color: var(--err); } + + /* ---- targets page ---- */ + .provbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; font-size: 13px; } + .backlink { border: 1px solid var(--line); background: var(--surface); color: var(--ink-soft); font: inherit; font-size: 12.5px; font-weight: 600; padding: 6px 11px; border-radius: 8px; cursor: pointer; } + .backlink:hover { border-color: var(--accent); color: var(--ink); } + .prov { display: inline-flex; align-items: center; gap: 7px; padding: 5px 11px; border-radius: 999px; font-size: 12.5px; font-weight: 560; } + .prov.sel { background: var(--info-bg); border: 1px solid var(--info-line); color: var(--info); } + .prov.dir { background: var(--surface-2); border: 1px solid var(--line); color: var(--muted); } + .prov .q { font-family: var(--mono); font-size: 11px; opacity: .85; } + .page-title { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 14px; flex-wrap: wrap; } .page-title h3 { margin: 0; font-size: 19px; letter-spacing: -.01em; } .page-title .sub { color: var(--muted); font-size: 13px; } - /* flash banners */ .flash { display: flex; gap: 10px; align-items: flex-start; padding: 11px 14px; border-radius: 10px; font-size: 14px; margin-bottom: 14px; border: 1px solid; } .flash.err { background: var(--err-bg); border-color: var(--err-line); color: var(--err); } .flash.ok { background: var(--ok-bg); border-color: var(--ok-line); color: var(--ok); } + .flash.todo { background: var(--surface-2); border-color: var(--line); color: var(--ink-soft); } .flash .ic { font-weight: 700; } .flash[hidden] { display: none; } - /* category card */ .cat { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; margin-bottom: 16px; } .cat-head { padding: 13px 16px; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 10px; } .cat-head h4 { margin: 0; font-size: 16px; } .cat-head .count { margin-left: auto; font-size: 12px; font-family: var(--mono); color: var(--muted); } - table.tg { width: 100%; border-collapse: collapse; font-size: 14px; } + .tblwrap { overflow-x: auto; } + table.tg { width: 100%; border-collapse: collapse; font-size: 14px; min-width: 520px; } table.tg th { text-align: left; font-size: 11px; letter-spacing: .05em; text-transform: uppercase; color: var(--muted); font-weight: 650; padding: 10px 14px; border-bottom: 2px solid var(--line); } table.tg th.n, table.tg td.n { text-align: right; font-variant-numeric: tabular-nums; } - table.tg td { padding: 11px 14px; border-bottom: 1px solid var(--line); color: var(--ink); } + table.tg td { padding: 11px 14px; border-bottom: 1px solid var(--line); color: var(--ink); vertical-align: top; } table.tg tr:last-child td { border-bottom: 0; } - .cell-mm { display: inline-flex; align-items: center; gap: 7px; justify-content: flex-end; min-width: 58px; padding: 3px 10px; border-radius: 7px; font-variant-numeric: tabular-nums; } + .cell-mm { display: inline-flex; align-items: center; gap: 7px; justify-content: flex-end; min-width: 60px; padding: 3px 10px; border-radius: 7px; font-variant-numeric: tabular-nums; } .cell-mm.calc { background: var(--info-bg); } .cell-mm.manual { background: var(--sug-bg); color: var(--sug); } + .cell-mm.pending { background: transparent; border: 1px dashed var(--accent); color: var(--accent-ink); } .cell-mm .tag { font-size: 10px; font-family: var(--mono); opacity: .8; } .cell-mm.changed { animation: popv .5s ease; } - @keyframes popv { 0%{ transform: scale(.8); } 55%{ transform: scale(1.12);} 100%{ transform: scale(1);} } + @keyframes popv { 0%{ transform: scale(.8);} 55%{ transform: scale(1.12);} 100%{ transform: scale(1);} } @media (prefers-reduced-motion: reduce){ .cell-mm.changed{ animation: none; } } + .cmt { font-size: 12.5px; color: var(--ink-soft); } + .cmt.auto { color: var(--sug); } + .cmt .mk { font-family: var(--mono); font-size: 10px; text-transform: uppercase; letter-spacing: .05em; background: var(--sug-bg); color: var(--sug); border: 1px solid var(--sug-line); padding: 1px 6px; border-radius: 5px; margin-right: 6px; } - /* suggestion row under a value */ - tr.sug-row td { padding-top: 0; border-bottom: 1px solid var(--line); } - .sug { - display: flex; align-items: center; gap: 10px; flex-wrap: wrap; - background: var(--sug-bg); border: 1px solid var(--sug-line); border-radius: 9px; - padding: 8px 10px 8px 12px; color: var(--sug); font-size: 13.5px; - } + tr.sug-row td { padding-top: 0; } + .sug { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; background: var(--sug-bg); border: 1px solid var(--sug-line); border-radius: 9px; padding: 8px 10px 8px 12px; color: var(--sug); font-size: 13.5px; } .sug .badge { background: var(--sug-badge); color: #fff; font-size: 10px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; padding: 3px 7px; border-radius: 6px; } .sug .txt { color: var(--ink-soft); } .sug .txt b { color: var(--ink); font-variant-numeric: tabular-nums; } - .sug form { margin: 0 0 0 auto; } - tr.sug-row.removing td .sug { animation: fade .34s ease forwards; } + .sug .act { margin-left: auto; } + tr.sug-row.removing td .sug { animation: fade .3s ease forwards; } @keyframes fade { to { opacity: 0; transform: translateX(8px); } } - @media (prefers-reduced-motion: reduce){ tr.sug-row.removing td .sug{ animation: none; opacity:0; } } + @media (prefers-reduced-motion: reduce){ tr.sug-row.removing td .sug{ animation: none; opacity: 0; } } + + /* review-before-save edit panel */ + .edit { background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); border-radius: 9px; padding: 10px 12px; display: grid; gap: 8px; } + .edit .er { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; color: var(--accent-ink); } + .edit .er .badge { background: var(--accent); color: #fff; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; padding: 3px 7px; border-radius: 6px; } + .edit label { font-size: 11px; font-family: var(--mono); letter-spacing: .05em; text-transform: uppercase; color: var(--ink-soft); } + .edit textarea { width: 100%; resize: vertical; min-height: 44px; font: inherit; font-size: 13px; color: var(--ink); background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; } + .edit textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } + .edit .ea { display: flex; gap: 8px; } - /* proposed-only tag */ .newtag { font-size: 10px; font-family: var(--mono); color: var(--accent-ink); background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); padding: 1px 6px; border-radius: 5px; text-transform: uppercase; letter-spacing: .05em; } - /* buttons (design-system-ish) */ .btn { font: inherit; font-size: 13px; font-weight: 620; border-radius: 8px; padding: 7px 13px; cursor: pointer; border: 1px solid transparent; white-space: nowrap; } .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .btn.tertiary { background: transparent; border-color: var(--sug-line); color: var(--sug); } @@ -209,58 +210,56 @@ .btn.primary:hover { background: var(--accent-ink); } .btn.secondary { background: var(--surface); border-color: var(--line); color: var(--ink); } .btn.secondary:hover { border-color: var(--accent); } - .btn[disabled] { opacity: .5; cursor: not-allowed; } + .btn.ghost { background: transparent; border-color: var(--line); color: var(--ink-soft); } + .btn.ghost:hover { color: var(--ink); border-color: var(--accent); } .pagebar { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 4px; padding: 14px; background: var(--surface); border: 1px dashed var(--accent-soft); border-radius: 11px; align-items: center; } .pagebar .label { font-size: 13px; color: var(--ink-soft); margin-right: auto; } .pagebar .label b { color: var(--ink); } .pagebar[hidden] { display: none; } - /* run simulation */ - .runsim { display: flex; align-items: center; gap: 12px; padding: 13px 15px; border-radius: 10px; background: var(--info-bg); border: 1px solid var(--info-line); color: var(--info); font-size: 14px; margin-top: 4px; } + .runsim { display: flex; align-items: center; gap: 12px; padding: 13px 15px; border-radius: 10px; background: var(--info-bg); border: 1px solid var(--info-line); color: var(--info); font-size: 14px; margin-top: 12px; } .runsim[hidden] { display: none; } .spinner { width: 16px; height: 16px; border-radius: 50%; border: 2px solid color-mix(in srgb, var(--info) 35%, transparent); border-top-color: var(--info); animation: spin .8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce){ .spinner{ animation: none; } } - /* ---- flow stepper ---- */ + /* flow */ .flow { display: grid; gap: 12px; } - @media (min-width: 820px){ .flow { grid-template-columns: repeat(5, 1fr); align-items: stretch; } } + @media (min-width: 880px){ .flow { grid-template-columns: repeat(3, 1fr); } } .step { position: relative; background: var(--surface); border: 1px solid var(--line); border-radius: 11px; padding: 14px 15px; box-shadow: var(--shadow); } .step .s-n { font-family: var(--mono); font-size: 11px; color: var(--accent); font-weight: 700; } .step h4 { margin: 6px 0 5px; font-size: 14.5px; } .step p { margin: 0; font-size: 12.5px; color: var(--ink-soft); } - @media (min-width: 820px){ - .step:not(:last-child)::after { - content: "→"; position: absolute; right: -11px; top: 50%; transform: translateY(-50%); - color: var(--muted); font-size: 16px; z-index: 2; - } - } - .step.branch { border-color: var(--accent-soft); background: linear-gradient(0deg, var(--accent-soft), transparent 60%), var(--surface); } + .step.branch { border-color: var(--accent-soft); background: linear-gradient(0deg, var(--accent-soft), transparent 55%), var(--surface); } - /* ---- data flow svg ---- */ .dfwrap { overflow-x: auto; } - svg.df { width: 100%; min-width: 720px; height: auto; display: block; } + svg.df { width: 100%; min-width: 760px; height: auto; display: block; } svg.df .node { fill: var(--surface); stroke: var(--line); } - svg.df .node.accent { stroke: var(--accent); } + svg.df .node.accent { stroke: var(--accent); stroke-width: 1.5; } svg.df .node.reuse { fill: var(--surface-2); stroke-dasharray: 4 3; } svg.df text { fill: var(--ink); font-family: var(--sans); } svg.df text.lbl { font-family: var(--mono); fill: var(--muted); } svg.df text.sub { fill: var(--ink-soft); } - svg.df .edge { stroke: var(--muted); fill: none; marker-end: url(#arrow); } + svg.df .edge { stroke: var(--muted); fill: none; marker-end: url(#arrow); stroke-width: 1.4; } svg.df .edge.ok { stroke: var(--ok); } svg.df .edge.run { stroke: var(--info); } - .legend { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 14px; font-size: 12.5px; color: var(--ink-soft); } .legend span { display: inline-flex; align-items: center; gap: 7px; } .legend .sw { width: 22px; height: 0; border-top: 2px solid; } - /* ---- lists / files ---- */ ul.clean { list-style: none; padding: 0; margin: 0; display: grid; gap: 9px; } ul.clean li { display: flex; gap: 11px; align-items: flex-start; font-size: 14.5px; color: var(--ink-soft); } ul.clean li::before { content: ""; width: 7px; height: 7px; border-radius: 2px; background: var(--accent); margin-top: 8px; flex: none; } ul.clean li b { color: var(--ink); } + .decisions { display: grid; gap: 12px; } + @media (min-width: 760px){ .decisions { grid-template-columns: repeat(4,1fr); } } + .dec { padding: 15px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface); } + .dec .h { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--accent); margin-bottom: 7px; } + .dec p { margin: 0; font-size: 13px; color: var(--ink-soft); } + .dec p b { color: var(--ink); } + .files { display: grid; gap: 10px; } .file { display: grid; grid-template-columns: auto 1fr; gap: 12px; align-items: baseline; padding: 12px 14px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 10px; } .file .path { font-family: var(--mono); font-size: 12.5px; color: var(--accent-ink); word-break: break-all; } @@ -272,13 +271,6 @@ footer { padding: 40px 0 60px; color: var(--muted); font-size: 13px; border-top: 1px solid var(--line); margin-top: 20px; } footer b { color: var(--ink-soft); } - - .decisions { display: grid; gap: 12px; } - @media (min-width: 760px){ .decisions { grid-template-columns: repeat(3,1fr); } } - .dec { padding: 16px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface); } - .dec .h { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--accent); margin-bottom: 7px; } - .dec p { margin: 0; font-size: 13.5px; color: var(--ink-soft); } - .dec p b { color: var(--ink); } @@ -289,7 +281,7 @@