Proposal card

AI proposes, people decide (UX guide P7, §11.2; Architecture §7.2). Irreversible actions requested by an AI or an agent — sending e-mail, writing to the CRM (Webling), booking invoices, printing — are never executed directly. The API turns them into a *pending action* with a dry-run; this card shows it to the person who may approve it, in the notification centre and on their home ("Vorschläge zur Freigabe").

The card must say what will happen (effects, taken from the dry-run — not from the agent's own description), who requested it (the agent *and* the person it acts for), when, and when it expires. render refuses AI/agent proposals without requestedBy. The dashed accent border and the source line mark it as "not done yet".

Actions: "Freigeben" (approve, primary, last), "Ablehnen" (reject) and optionally "Anpassen" (adjust before approving, when adjustLabel is set). Buttons carry data-action="approve|reject|adjust"; the card carries data-proposal="{id}". The page handles them: approve runs the action through the same confirm/dry-run rules as a manual action of that risk class (e.g. a confirm dialog listing the consequences); reject opens a dialog with an optional reason that is fed back to the agent; adjust opens the editor with the proposal's values. Everything is written to the audit log.

States: when the underlying data changed since the dry-run (stale), approval is replaced by "Neu prüfen" (data-action="revalidate") with a warning. After a decision (status approved/rejected/expired) the card gets data-state, loses the dashed accent border (it becomes an ordinary flat card with a --color-border outline, effects muted), and shows a status badge instead of actions — the accent marks only what still needs a decision (design guide §1.3).

Examples

AI proposal with adjust

Vorschlag vom Assistenten · für Sandra Keller · vor 5 Min.

Gurtprüfung Nov. 2026

Prüfungsgruppen für den 14.11. bilden

Was passiert, wenn du zustimmst:

  • 3 Gruppen (Kinder, Jugend, Erwachsene) mit 9, 7 und 5 Personen
  • Startreihenfolge nach Gurt
  • Nichts wird an Webling gesendet

Gültig bis 16.10.

Data
render({
  "id": "pa-2041",
  "source": "Vorschlag vom Assistenten",
  "requestedBy": "Sandra Keller",
  "requestedAt": "vor 5 Min.",
  "context": "Gurtprüfung Nov. 2026",
  "title": "Prüfungsgruppen für den 14.11. bilden",
  "effects": [
    "3 Gruppen (Kinder, Jugend, Erwachsene) mit 9, 7 und 5 Personen",
    "Startreihenfolge nach Gurt",
    "Nichts wird an Webling gesendet"
  ],
  "expires": "Gültig bis 16.10.",
  "approveLabel": "Übernehmen",
  "adjustLabel": "Anpassen"
})
Markup
<article class="proposal" aria-labelledby="pa-2041-title" data-proposal="pa-2041">
<span class="proposal__source"><svg class="icon" data-size="sm" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#sparkles"/></svg>Vorschlag vom Assistenten · für Sandra Keller · vor 5 Min.</span>
<p class="proposal__context">Gurtprüfung Nov. 2026</p>
<h3 class="proposal__title" id="pa-2041-title">Prüfungsgruppen für den 14.11. bilden</h3>
<div class="proposal__effects"><p><strong>Was passiert, wenn du zustimmst:</strong></p><ul><li>3 Gruppen (Kinder, Jugend, Erwachsene) mit 9, 7 und 5 Personen</li><li>Startreihenfolge nach Gurt</li><li>Nichts wird an Webling gesendet</li></ul></div>
<p class="proposal__expiry"><svg class="icon" data-size="sm" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#clock"/></svg>Gültig bis 16.10.</p>
<div class="button-row"><button class="button" type="button" data-variant="ghost" data-action="adjust">Anpassen</button><button class="button" type="button" data-action="reject">Ablehnen</button><button class="button" type="button" data-variant="primary" data-action="approve">Übernehmen</button></div>
</article>

Tenant agent: send replies (irreversible)

Vorschlag vom Anfrage-Assistenten · für Sekretariat · heute, 08:12

3 Antworten auf Probetraining-Anfragen senden

Was passiert, wenn du zustimmst:

  • 3 E-Mails an Familie Gerber, Roth und Schmid
  • Absender: [email protected]
  • Einladung zum Probetraining Mo 17:30
Data
render({
  "id": "pa-2043",
  "origin": "agent",
  "source": "Vorschlag vom Anfrage-Assistenten",
  "requestedBy": "Sekretariat",
  "requestedAt": "heute, 08:12",
  "title": "3 Antworten auf Probetraining-Anfragen senden",
  "effects": [
    "3 E-Mails an Familie Gerber, Roth und Schmid",
    "Absender: [email protected]",
    "Einladung zum Probetraining Mo 17:30"
  ],
  "approveLabel": "3 E-Mails senden",
  "adjustLabel": "Entwürfe ansehen"
})
Markup
<article class="proposal" aria-labelledby="pa-2043-title" data-proposal="pa-2043">
<span class="proposal__source"><svg class="icon" data-size="sm" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#sparkles"/></svg>Vorschlag vom Anfrage-Assistenten · für Sekretariat · heute, 08:12</span>
<h3 class="proposal__title" id="pa-2043-title">3 Antworten auf Probetraining-Anfragen senden</h3>
<div class="proposal__effects"><p><strong>Was passiert, wenn du zustimmst:</strong></p><ul><li>3 E-Mails an Familie Gerber, Roth und Schmid</li><li>Absender: [email protected]</li><li>Einladung zum Probetraining Mo 17:30</li></ul></div>
<div class="button-row"><button class="button" type="button" data-variant="ghost" data-action="adjust">Entwürfe ansehen</button><button class="button" type="button" data-action="reject">Ablehnen</button><button class="button" type="button" data-variant="primary" data-action="approve">3 E-Mails senden</button></div>
</article>

Stale (data changed)

Vorschlag vom Assistenten · für Sandra Keller · gestern

Rechnungen Prüfungsgebühr erstellen

Was passiert, wenn du zustimmst:

  • 21 Rechnungen à CHF 60.00 in Webling
  • Versand per E-Mail an die Rechnungsadressen

Daten haben sich geändert

Seit dem Vorschlag wurde etwas geändert. Prüfe den Vorschlag neu, bevor du ihn freigibst.

Data
render({
  "id": "pa-2039",
  "source": "Vorschlag vom Assistenten",
  "requestedBy": "Sandra Keller",
  "requestedAt": "gestern",
  "title": "Rechnungen Prüfungsgebühr erstellen",
  "effects": [
    "21 Rechnungen à CHF 60.00 in Webling",
    "Versand per E-Mail an die Rechnungsadressen"
  ],
  "stale": true
})
Markup
<article class="proposal" aria-labelledby="pa-2039-title" data-proposal="pa-2039">
<span class="proposal__source"><svg class="icon" data-size="sm" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#sparkles"/></svg>Vorschlag vom Assistenten · für Sandra Keller · gestern</span>
<h3 class="proposal__title" id="pa-2039-title">Rechnungen Prüfungsgebühr erstellen</h3>
<div class="proposal__effects"><p><strong>Was passiert, wenn du zustimmst:</strong></p><ul><li>21 Rechnungen à CHF 60.00 in Webling</li><li>Versand per E-Mail an die Rechnungsadressen</li></ul></div>
<div class="alert" data-status="warning" role="status"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#alert-triangle"/></svg><div class="alert__body"><p class="alert__title">Daten haben sich geändert</p><p>Seit dem Vorschlag wurde etwas geändert. Prüfe den Vorschlag neu, bevor du ihn freigibst.</p></div></div>
<div class="button-row"><button class="button" type="button" data-action="reject">Ablehnen</button><button class="button" type="button" data-variant="primary" data-action="revalidate"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#refresh"/></svg>Neu prüfen</button></div>
</article>

Person proposal, approved

Vorschlag von Marco Huber · 28.09.

Lea Müller zur Prüfung 5. Kup zulassen

Was passiert, wenn du zustimmst:

  • Anmeldung zur Gurtprüfung 14.11.
  • Rechnung CHF 60.00 an Familie Müller
Freigegeben
Data
render({
  "id": "pa-2030",
  "origin": "person",
  "source": "Vorschlag von Marco Huber",
  "requestedAt": "28.09.",
  "title": "Lea Müller zur Prüfung 5. Kup zulassen",
  "effects": [
    "Anmeldung zur Gurtprüfung 14.11.",
    "Rechnung CHF 60.00 an Familie Müller"
  ],
  "status": "approved"
})
Markup
<article class="proposal" aria-labelledby="pa-2030-title" data-proposal="pa-2030" data-state="approved">
<span class="proposal__source"><svg class="icon" data-size="sm" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#user"/></svg>Vorschlag von Marco Huber · 28.09.</span>
<h3 class="proposal__title" id="pa-2030-title">Lea Müller zur Prüfung 5. Kup zulassen</h3>
<div class="proposal__effects"><p><strong>Was passiert, wenn du zustimmst:</strong></p><ul><li>Anmeldung zur Gurtprüfung 14.11.</li><li>Rechnung CHF 60.00 an Familie Müller</li></ul></div>
<div class="button-row" data-align="start"><span class="badge" data-status="success"><svg class="icon" data-size="sm" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#check-circle"/></svg>Freigegeben</span></div>
</article>

Data contract

What render(data) accepts — validated against this JSON Schema in development and tests. Unknown fields are rejected. The same schema is in components.json.

FieldTypeRequiredDefaultDescription
idstringrequired—Pending-action id; written to data-proposal and used for {id}-title.
pattern ^[A-Za-z][\w-]*$
origin"ai" | "agent" | "person"optional"ai"ai: the assistant acting for a signed-in user; agent: a tenant agent (e.g. the inquiry auto-drafter); person: another human (e.g. a trainer proposing to the board).
sourcestringrequired—Who proposes it ("Vorschlag vom Assistenten", "Vorschlag von Marco Huber").
min 1 chars
requestedBystringoptional—The person the AI/agent acts for, or who asked for it ("Sandra Keller"). Required for ai and agent.
requestedAtstringoptional—When it was requested, display text ("vor 5 Min.", "01.10., 14:32").
contextstringoptional—Object or area it belongs to, shown above the title ("Gurtprüfung Nov. 2026").
titlestringrequired—What is proposed, as an action ("Prüfungsgruppen für den 14.11. bilden").
min 1 chars
headingLevelintegeroptional3Heading level of the title.
≥ 2 · ≤ 4
effectsTitlestringoptional"Was passiert, wenn du zustimmst:"Heading of the effects box.
effectsarray of stringrequired—Concrete consequences from the dry-run, one per line. Also say what does *not* happen when it matters ("Nichts wird an Webling gesendet").
min 1 items
expiresstringoptional—Visible expiry ("Gültig bis 16.10."). Proposals expire; say when.
stalebooleanoptionalfalseData changed since the dry-run: approval is replaced by "Neu prüfen" (data-action="revalidate") with a warning.
status"pending" | "approved" | "rejected" | "expired"optional"pending"Decision state. Anything but pending shows a status badge and hides the actions.
approveLabelstringoptional"Freigeben"Approve button text; a verb naming the result ("Übernehmen", "3 E-Mails senden").
rejectLabelstringoptional"Ablehnen"Reject button text.
adjustLabelstringoptional—Adjust button text ("Anpassen"); the button is shown only when set (proposal can be edited first).
JSON Schema
{
  "type": "object",
  "additionalProperties": false,
  "required": [
    "id",
    "source",
    "title",
    "effects"
  ],
  "properties": {
    "id": {
      "type": "string",
      "pattern": "^[A-Za-z][\\w-]*$",
      "description": "Pending-action id; written to `data-proposal` and used for `{id}-title`."
    },
    "origin": {
      "type": "string",
      "enum": [
        "ai",
        "agent",
        "person"
      ],
      "default": "ai",
      "description": "`ai`: the assistant acting for a signed-in user; `agent`: a tenant agent (e.g. the inquiry auto-drafter); `person`: another human (e.g. a trainer proposing to the board)."
    },
    "source": {
      "type": "string",
      "minLength": 1,
      "description": "Who proposes it (\"Vorschlag vom Assistenten\", \"Vorschlag von Marco Huber\")."
    },
    "requestedBy": {
      "type": "string",
      "description": "The person the AI/agent acts for, or who asked for it (\"Sandra Keller\"). Required for `ai` and `agent`."
    },
    "requestedAt": {
      "type": "string",
      "description": "When it was requested, display text (\"vor 5 Min.\", \"01.10., 14:32\")."
    },
    "context": {
      "type": "string",
      "description": "Object or area it belongs to, shown above the title (\"Gurtprüfung Nov. 2026\")."
    },
    "title": {
      "type": "string",
      "minLength": 1,
      "description": "What is proposed, as an action (\"Prüfungsgruppen für den 14.11. bilden\")."
    },
    "headingLevel": {
      "type": "integer",
      "minimum": 2,
      "maximum": 4,
      "default": 3,
      "description": "Heading level of the title."
    },
    "effectsTitle": {
      "type": "string",
      "default": "Was passiert, wenn du zustimmst:",
      "description": "Heading of the effects box."
    },
    "effects": {
      "type": "array",
      "minItems": 1,
      "description": "Concrete consequences from the dry-run, one per line. Also say what does *not* happen when it matters (\"Nichts wird an Webling gesendet\").",
      "items": {
        "type": "string",
        "minLength": 1,
        "description": "One consequence."
      }
    },
    "expires": {
      "type": "string",
      "description": "Visible expiry (\"Gültig bis 16.10.\"). Proposals expire; say when."
    },
    "stale": {
      "type": "boolean",
      "default": false,
      "description": "Data changed since the dry-run: approval is replaced by \"Neu prüfen\" (`data-action=\"revalidate\"`) with a warning."
    },
    "status": {
      "type": "string",
      "enum": [
        "pending",
        "approved",
        "rejected",
        "expired"
      ],
      "default": "pending",
      "description": "Decision state. Anything but `pending` shows a status badge and hides the actions."
    },
    "approveLabel": {
      "type": "string",
      "default": "Freigeben",
      "description": "Approve button text; a verb naming the result (\"Übernehmen\", \"3 E-Mails senden\")."
    },
    "rejectLabel": {
      "type": "string",
      "default": "Ablehnen",
      "description": "Reject button text."
    },
    "adjustLabel": {
      "type": "string",
      "description": "Adjust button text (\"Anpassen\"); the button is shown only when set (proposal can be edited first)."
    }
  }
}

Markup & states

Root: article.proposal — usable without render() by writing the markup directly.

Attributes

AttributeValuesDefaultDescription
aria-labelledby"{id}-title"—Card named by its title.
data-proposalpending-action id—Read by the page's action handler.
data-action (buttons)approve | reject | adjust | revalidate—Which decision a button makes.

Parts

PartDescription
.proposal__sourceIcon (sparkles for AI/agent, user for a person) + who proposed, for whom, when (muted, small).
.proposal__contextObject/area above the title (muted, small).
.proposal__titleTitle heading h2–h4 (id {id}-title), --text-lg semibold.
.proposal__effectsBox with "Was passiert, wenn du zustimmst:" and the dry-run consequences.
.proposal__expiryExpiry line with a clock icon (pending only).
.alert[data-status="warning"]Stale warning.
.button-rowAdjust (ghost), reject, approve (primary, last); or a status badge after the decision.

States

StateDescription
(pending)2 px dashed --color-accent border.
staleWarning + "Neu prüfen" instead of approve.
[data-state="approved|rejected|expired"]Decided: solid --color-border outline like a card, muted effects, status badge, no buttons.

Behaviour & events

Module: (page code)

No built-in behaviour. One delegated listener: el.addEventListener("click", e => { const b = e.target.closest("[data-action]"); const id = b?.closest("[data-proposal]")?.dataset.proposal; … }). approve → confirm dialog with the same effects (confirmDialog({ consequences })), then POST /pending-actions/{id}/approve; reject → dialog with optional reason, then …/reject; adjust → open the editor; revalidate → re-run the dry-run and re-render. Show the result with a toast and update the card status.

Events

EventDetailDescription
click on [data-action]action (`approve|reject|adjust|revalidate`) + `data-proposal` idA decision button was pressed.

Accessibility

  • An <article> named by its title; the source line is read first, so "Vorschlag vom Assistenten · für Sandra Keller" precedes the title.
  • AI origin is stated in text, not only by the sparkles icon or the dashed border.
  • Button labels name the outcome ("3 E-Mails senden"), and the primary is last in DOM and visual order.
  • The stale warning is a role="status" alert so it is announced when it appears after re-validation.

Guidance

DoTake effects from the server's dry-run, including what will not happen.
DoName both the agent and the person it acts for.
DoShow expiry and re-validate stale proposals before approval.
Don'tNever execute an irreversible AI action without this approval step.
Don'tDon't let the agent write the effects text in its own words.
Don'tDon't offer "Alle freigeben" for proposals of different kinds.

Related: Card · Button · Status badge · Coach note