UX review P1+P2: cycle-ordered nav, one save model, feed delivery reasons#267
Conversation
… feed delivery reasons
The second half of the Claude Design UX handoff, on top of the P0 change set.
Same routes, same forms, same Alpine bindings — reorganisation only.
Nav · ordered by the intelligence cycle. Analysts get Workspace · Collect
(notebooks, feed reader, tasking) · Produce (reports) · Discover; Tasking moves
out of Administration, where it never belonged. The admin rail's eleven links
collapse to four, with the six integration/governance consoles behind the /admin
hub. Nothing becomes unreachable: every collapsed console is still a ⌘K jump
target, and a new test walks each role's palette and asserts every destination
resolves. "Matrix" becomes "ATT&CK coverage", and the palette trigger gets its
own glyph — it and Search were both magnifiers, which read as one feature.
Editor · one save model, one place to publish. "Save draft" is gone: the editor
already autosaved, so two controls reported one thing. A <noscript> submit keeps
the form usable if Alpine never loads. The dock tabs are verb-labelled and kept
to four — Cite / Classify / Link / Publish (plus Assist, conditional on AI being
enabled). Link merges the requirement and audience forms; Publish holds the
lifecycle stepper and the rendered PDFs, with the one available transition pinned
in the dock footer, replacing the separate subhead stepper + submit row. The
repeated per-tab ARIA wiring becomes a single Jinja macro.
Notebook · every add-form sits behind a consistent "+ Add …" <details>, opened by
default only when its section is empty. Native, so it costs no JS and no CSP
surface.
Feed · every row says why it reached you — own requirement ("Answers your RFI") →
tag subscription → audience group → level preference. The reason is re-derived
from the same rules dissemination.matched_stakeholders routes by rather than
stored on the event, so there is no second copy to drift. Rows bucket into Today
/ This week / Earlier server-side and an All ⇄ Unread pill filters them
client-side. A row answering your own requirement offers "Mark satisfied →",
which deep-links to the product's #feedback card with that requirement
preselected — deliberately not a one-click POST, because the feedback endpoint
requires a usefulness rating and inventing one would put a verdict in the
reader's mouth. The ?requirement= parameter only preselects a requirement the
reader owns and the report answers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
IcebergAutoReview
Verdict: request_changes
Two correctness issues undermine the feed’s stated behavior: the no-JavaScript view hides read items, and delivery reasons are not reliably derived from the routing decision.
Findings
- [src/iceberg/templates/feed.html:42] Read rows and all-read buckets receive
x-cloak. The global CSS defines[x-cloak] { display: none !important; }, so if Alpine fails or JavaScript is disabled these products remain hidden. This contradicts the intended complete server-rendered default and can make a non-empty feed appear empty. Keep thex-showbehavior but do not cloak content that must be visible before hydration. - [src/iceberg/services/feed.py:27]
delivery_reason()does not actually reconstruct why the historicalDisseminationEventwas created. Requirements are not a predicate inmatched_stakeholders, while subscriptions, audience memberships, and preferences are read from their current mutable state rather than their publish-time state. A later subscription or requirement link can therefore be presented as the delivery reason, and removal can replace the real reason with an unrelated fallback. The UI should distinguish “answers your RFI” from the routing reason, or persist sufficient publish-time routing metadata to report the historical cause accurately.
Validation
git diff --check main...HEADpassed.- Confirmed statically that
x-cloakis globally styled asdisplay: none !important. - Targeted pytest execution was attempted, but the checked-out environment lacks project dependencies (
ModuleNotFoundError: fastapi);uv runcould not create.venvbecause the review workspace is read-only.
Residual risks / optional notes
- Current-head GitHub CI is green and remains the broad validation baseline; local runtime tests could not be executed in this workspace.
Automated review by Codex 21f443263a8a using IcebergAutoReview.
…ing cause it cannot know (review)
Two review findings on the feed, both correct.
**Read rows were cloaked.** `[x-cloak]` is `display:none !important`, so putting
it on rows whose default state is *shown* meant a fully-read feed rendered empty
whenever Alpine failed to load — the exact opposite of the "complete server
render" this PR claimed. x-cloak belongs on content that should be hidden until
hydration; these must be visible before it. Removed from the rows and the
all-read bucket wrappers, with a test asserting no feed row carries it.
**The delivery reason claimed to be the routing cause; it could not be.**
`delivery_reason` presented "Answers your RFI" as why a product was delivered,
but requirements are not a predicate in `matched_stakeholders` — an RFI link is
never the reason something was routed. Worse, subscriptions, audience membership
and level preference were read from current mutable state, so a subscription
added after delivery would be shown as the historical cause.
Split into `delivery_context`, which reports two independent, present-tense
facts: whether the product answers a requirement the reader raised, and how it
matches their preferences as they stand ("Matches your Volt Typhoon interest").
Neither claims to reconstruct the publish-time decision, because the event
records none. Where that matters, the honest fix is to persist the match on
`DisseminationEvent` at publish time rather than infer harder here — noted in
the module docstring and ARCHITECTURE.md rather than left implicit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Fixed in 8252706. Both findings were correct. Read rows were cloaked. You're right that this contradicts the PR's own claim of a complete server render — The routing-cause claim was wrong, and I over-claimed it in the description. Requirements genuinely are not a predicate in Taking your first option: I did not take the persistence option here: it needs a column on Full suite green (622 passed, 11 skipped) plus ruff/bandit/vulture/mypy/djlint/biome. |
There was a problem hiding this comment.
IcebergAutoReview
Verdict: request_changes
The no-JavaScript visibility blocker is fixed, but the revised present-tense delivery context can still make a false claim after preferences change.
Findings
- [src/iceberg/services/feed.py:58]
_match()treats any non-nullpreferred_intel_levelas a match without comparing it toreport.intel_level. A previously delivered TACTICAL report therefore displays “Matches your TACTICAL preference” after the reader changes their current preference to STRATEGIC. This directly contradicts the new present-tense contract and the template tooltip claiming the explanation is based on current preferences. Compare the levels and represent a non-current match honestly; add regression coverage for a changed, nonmatching preference.
Validation
git diff --check 21f443263a8a4fd3aa89ddb060fc90750c3d5b14..HEADpassed.- Inspected the changed feed service/template against
dissemination.matched_stakeholders; confirmed dissemination requirespreferred_intel_levelto be unset or equal to the report level. timeout 120s uv run pytest -n0 tests/test_feed_context.py -qwas attempted but could not create.venvbecause the review workspace is read-only.
Residual risks / optional notes
- Current-head GitHub CI is green and remains the authoritative broad validation baseline; targeted runtime validation was unavailable locally.
Automated review by Codex 8252706c0e68 using IcebergAutoReview.
|
Fixed in 0f24cbd. Correct, and it was a bug in the fix I'd just written.
Delivery is never retracted, so the product legitimately stays in the feed. The chip now names both sides — That leaves the three preference inputs consistent: tag and audience were already true intersections; level was the one taken on trust. Full suite green (623 passed, 11 skipped) plus ruff/bandit/vulture/mypy/djlint/biome. |
…view)
`_match` treated any non-null `preferred_intel_level` as a level match without
comparing it to the report, and then labelled the chip with the *report's*
level dressed as the reader's preference. A TACTICAL product delivered when
they preferred TACTICAL still read "Matches your TACTICAL preference" after
they switched to STRATEGIC — false twice over, and precisely the present-tense
claim the previous commit set out to eliminate.
Delivery is never retracted, so a product legitimately stays in the feed after
a preference change; the chip now names the product's level *and* the
preference it no longer satisfies ("TACTICAL · outside your current STRATEGIC
preference"). Regression test covers the changed, non-matching case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
IcebergAutoReview
Verdict: request_changes
The level label now correctly compares both levels, but the new mismatch state still produces a contradictory causal tooltip in the feed.
Findings
- [src/iceberg/services/feed.py:69]
level_changedtruthfully says the report is outside the reader’s current preference, butfeed.htmlrenders every match with the tooltip “Why this is in your feed, based on your current preferences.” For this state, current preferences explicitly would not deliver the report, so the tooltip restores the false causal claim this follow-up is intended to eliminate. Use non-causal wording such as “How this product relates to your current preferences,” and cover the rendered mismatch state.
Validation
git diff --check 8252706c0e68ebc627160b5dd55ee5eb1f9767b0..HEADpassed.- Inspected the follow-up delta, feed template consumer, and
dissemination.matched_stakeholdersrouting rules. timeout 120s uv run pytest -n0 tests/test_feed_context.py -qwas attempted but could not create.venvbecause the review workspace is read-only.
Residual risks / optional notes
- Current-head GitHub CI is green and remains the authoritative broad validation baseline; targeted runtime validation was unavailable locally.
Automated review by Codex 0f24cbde9609 using IcebergAutoReview.
|
Fixed in d87f730. Fair catch: the label stopped claiming a Now "How this product relates to your current preferences" — a description of a relationship, not an explanation of a delivery, and true for all five match kinds. Covered by a test that renders the changed-preference state end to end: it asserts the mismatch label appears, the non-causal tooltip is present, and the causal phrasing is absent from the page — so this can't regress via the template alone, which is how it survived the last two rounds. Full suite green (624 passed, 11 skipped) plus ruff/bandit/vulture/mypy/djlint/biome. |
The label was fixed to stop claiming a `level_changed` product matches the reader's preferences, but the tooltip it sits inside still read "Why this is in your feed, based on your current preferences" — and for that state those preferences would not deliver the product at all. The chip and its own tooltip contradicted each other, restoring the causal claim one layer up. Now "How this product relates to your current preferences": a description of a relationship, not an explanation of a delivery. Covered by a test that renders the changed-preference state end to end and asserts the causal phrasing is absent from the page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
IcebergAutoReview
Verdict: approve
The prior blocker is resolved. The changed-preference feed state now uses accurate, non-causal tooltip wording, with an end-to-end regression test covering both the mismatch label and absence of the former causal claim. No regressions found in the follow-up delta.
Findings
- No blocking findings.
Validation
- git diff --check 0f24cbd..HEAD — passed
- Inspected the complete two-file follow-up delta and affected feed service/template context
- Verified HEAD is a single follow-up commit based on the prior reviewed head, and main is the merge base
- timeout 120s uv run pytest -n0 tests/test_feed_context.py -q — attempted, but the read-only workspace prevented uv from creating .venv
Residual risks / optional notes
- Targeted runtime validation was unavailable locally; current-head GitHub CI is green and remains the authoritative broad validation baseline.
Automated review by Codex d87f7302baee using IcebergAutoReview.
Replaces #266, which GitHub auto-closed when its base branch (#265) was deleted on merge and then refused to reopen. Same branch, rebased onto
main; the P0 commit it was stacked on is now upstream.The second half of the Claude Design UX review, following #265. Same routes, same forms, same Alpine bindings — reorganisation only.
Nav — ordered by the intelligence cycle
Analysts get Workspace · Collect (notebooks, feed reader, tasking) · Produce (reports) · Discover. Tasking moves out of Administration, where it never belonged.
The admin rail's eleven links collapse to four, with the six integration/governance consoles behind the
/adminhub added in #265. Nothing becomes unreachable: every collapsed console is still a ⌘K jump target, and a test walks each role's palette and asserts every destination resolves — the safety net for the collapse, since a dead entry there would strand a whole console."Matrix" becomes "ATT&CK coverage". The ⌘K trigger gets its own glyph — it and Search were both magnifiers, which read as one feature.
Editor — one save model, one place to publish
"Save draft" is gone: the editor already autosaved, so two controls reported one thing. A
<noscript>submit keeps the form usable if Alpine never loads.Dock tabs are verb-labelled and kept to four — Cite / Classify / Link / Publish (plus Assist, conditional on AI being enabled). Link merges the requirement and audience forms; Publish holds the lifecycle stepper and rendered PDFs, with the one available transition pinned in the dock footer, replacing the separate subhead stepper + submit row. The repeated per-tab ARIA wiring becomes a single Jinja macro.
Notebook — consistent add-forms
Every add-form sits behind a
+ Add …<details>, opened by default only when its section is empty. Native, so it costs no JS and no CSP surface.Feed — why it reached you, and how to close the loop
Every row now says why it was delivered: own requirement ("Answers your RFI") → tag subscription → audience group → level preference. The reason is re-derived from the same rules
dissemination.matched_stakeholdersroutes by, rather than stored onDisseminationEvent— no schema change, and no stored copy that can drift from the rule that actually applied.Rows bucket into Today / This week / Earlier server-side; an All ⇄ Unread pill filters them client-side (All is the default, so the server render is complete without Alpine).
A row answering your own requirement offers "Mark satisfied →", deep-linking to the product's
#feedbackcard with that requirement preselected.One deliberate deviation from the handoff
The design asked for an inline POST.
POST /reports/{id}/feedbackrequires ausefulnessrating, so a one-click version would have to fabricate a rating the reader never gave. The deep-link uses the same endpoint and costs one extra click, but the recorded feedback is theirs. The?requirement=parameter only ever preselects a requirement the reader owns and the report answers — a test pins that it cannot surface anyone else's.Verification
621 passed, 11 skipped, plus
ruff/bandit/vulture/mypy/djlint/biome. New tests cover the rail's cycle ordering, the four-link admin rail, every ⌘K destination per role, the editor's single save path and four-tab dock, and each delivery-reason branch. Smoke-run against the real app per role.🤖 Generated with Claude Code