fix(engagement): the six defects the browser pass found (Phase 4b)
All checks were successful
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 2m35s
PR Checks / bot-tests (pull_request) Successful in 8m34s

Driving the two screens in Chrome, after the API walk had already found the two
in Phase 4a's code. None of these is visible from a test or from curl.

Two cost an operator something real:

  - The Audience dropdown rendered EMPTY before a trigger was chosen. There is
    genuinely nothing it may offer without a ceiling, but a select with zero
    options reads as broken rather than as waiting. It now says "Choose a
    trigger first..." and is disabled.
  - A `members` audience with no saved audience reaches NOBODY, and only the
    preview button said so. That is the design, but it is also the default the
    instant a members-ceiling trigger is picked - so the rule saves, gets
    switched on, and mails nobody with nothing on screen saying so. The editor
    now says it inline, and stands down once a preview has answered the same
    question more precisely.

One the server was already refusing, just too late:

  - The composer offered "exclude" on the only row, building an `and` whose
    every child is a complement. The server refuses it correctly but only after
    a save, and it is one checkbox away at all times. Now refused inline, in the
    operator's words.

Three wording and layout:

  - the template-key input truncated its placeholder, and said "optional until
    Phase 5" - a sentence about the plan document, not about the deployment
  - "segment" leaked into a screen that says "saved audience" everywhere else.
    The API, schema and docs keep saying segment (one word for one table);
    translated at the point of display only
  - the composer repeated its AUDIENCE heading above every row

Client only - no server change, so swagger and the route manifest are untouched.
Client suite 316/316; all six verified in the browser after the fix.

- [x] AI-assisted: written with Claude Code (Opus)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-29 12:26:29 -05:00
parent 4b45eddb5d
commit 3a7a08425c
4 changed files with 105 additions and 8 deletions

View File

@@ -50,12 +50,13 @@ const toGroup = (expression) =>
// ── One leaf: an audience and its declared parameters ──────────────────────
function LeafRow({ audiences, node, onChange, onRemove, negated, onToggleNegate, canNegate }) {
function LeafRow({ audiences, node, onChange, onRemove, negated, onToggleNegate, canNegate, first }) {
const declared = audiences.find((a) => a.id === node.audienceId)
return (
<div style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<label style={{ flex: '1 1 240px' }}>
<span className="field-label">Audience</span>
{/* The heading belongs to the group, not to every line in it. */}
{first && <span className="field-label">Audience</span>}
<select
className="select"
value={node.audienceId || ''}
@@ -198,6 +199,7 @@ function SegmentEditor({ audiences, segment, onSaved, onCancel }) {
return (
<LeafRow
key={i}
first={i === 0}
audiences={audiences}
node={leaf}
negated={negated}

View File

@@ -8,6 +8,7 @@ import {
segmentChoicesFor,
describeReach,
describeRule,
audienceWarning,
conditionRowsFrom,
conditionsFromRows,
operatorsForType,
@@ -201,9 +202,14 @@ function RuleEditor({ catalog, segments, rule, onSaved, onCancel }) {
<select
className="select"
value={form.audienceSegmentId ? '' : form.audience}
disabled={Boolean(form.audienceSegmentId)}
disabled={Boolean(form.audienceSegmentId) || !audienceChoices.length}
onChange={(e) => { set({ audience: e.target.value, audienceSegmentId: null }); setPreview(null) }}
>
{/* Without a trigger there is no ceiling, so there is nothing this
may legitimately offer — and a select with zero options renders
as a control that is broken rather than as one that is waiting. */}
{!audienceChoices.length && <option value="">Choose a trigger first</option>}
{Boolean(form.audienceSegmentId) && <option value="">Using the saved audience </option>}
{audienceChoices.map((c) => (
<option key={c.id} value={c.id}>{c.label}</option>
))}
@@ -241,6 +247,15 @@ function RuleEditor({ catalog, segments, rule, onSaved, onCancel }) {
{describeReach(preview)}
</p>
)}
{/* The `members`-with-no-saved-audience trap, said before the save rather
than discovered after it. It is the DEFAULT the moment a
members-ceiling trigger is chosen, and the rule it produces saves,
switches on and mails nobody. */}
{!preview && audienceWarning(form) && (
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.84rem', color: 'var(--accent)' }}>
{audienceWarning(form)}
</p>
)}
{trigger && audienceChoices.length <= 1 && (
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.8rem', color: 'var(--muted)' }}>
This event only permits {trigger.ceiling}. The audience a rule may use is capped by the
@@ -260,8 +275,8 @@ function RuleEditor({ catalog, segments, rule, onSaved, onCancel }) {
{form.channels.includes(c.id) && (
<input
className="input"
style={{ marginTop: 6 }}
placeholder="template key (optional until Phase 5)"
style={{ marginTop: 6, width: '100%' }}
placeholder="template key (optional)"
value={form.templateKeys[c.id] || ''}
onChange={(e) => set({ templateKeys: { ...form.templateKeys, [c.id]: e.target.value } })}
/>