Skip to content

capability(components): the destructive (red) confirm button on alert-dialog — batch #70 wanted it, confirmVariant was never a vehicle for it #8978

Description

@os-warren

Split out of objectui#7963 by maintainer ruling, 2026-09-10. That card retires the key confirmVariant; this card carries the capability it was supposed to deliver, so the capability is not lost with the key.

Why the two are separate

The capability was asked for and granted first, on measurement that did not yet exist:

  • 2026-09-07, director seat, decision batch Add cloud storage functionality with Vercel KV integration in studio #70 (5565626809) — maintainer verbatim 「同意」. Recorded reason: "a footer button variant (the red destructive confirm) is a capability this product wants; our own component catalog authored it three times and had to drop it." The ruling's instruction was wire confirmVariant.
  • 2026-09-09, the probe landed by PR test(components): measure the three declared alert-dialog footer keys through the DOM (objectui#7963 step 3) #8736 (5595380224) measured all three footer keys dead: authoring confirmVariant leaves the dialog HTML byte-identical, leaves the confirm button's class at bg-primary/text-primary-foreground (never bg-destructive), and the value reaches no DOM node at all. Mechanism measured, not argued: AlertDialogPrimitive.Root forwards to DialogPrimitive.Root, which destructures only __scopeDialog / children / open / defaultOpen / onOpenChange / modal and renders a context provider, not an element ⇒ an unknown prop dies before any DOM exists.

⇒ batch #70 asked to wire a key on the assumption it was a key that could carry the value. It cannot, and never could. The want is still good; the vehicle was never real. objectui#7963 removes the dead vehicle; this card keeps the want.

The capability is close — measured on origin/main 2c208d5bb

packages/components/src/ui/alert-dialog.tsx:

// :15
import { buttonVariants } from "./button"
…
// :115  — the CONFIRM button
    className={cn(buttonVariants(), className)}
…
// :128  — the CANCEL button, right below it
      buttonVariants({ variant: "outline" }),

buttonVariants already takes a variant, and the Cancel button already passes one. The confirm button calls it with no argument, so it is pinned to the default variant. And the renderer never offers one — packages/components/src/renderers/overlay/alert-dialog.tsx:38:

{schema.actionText && <AlertDialogAction onClick={schema.onAction}>{schema.actionText}</AlertDialogAction>}

⇒ three things are missing, none of them hard: the primitive hardcodes buttonVariants(); the renderer passes nothing; and there is no authorable key.

⚠️ NOT MEASURED, owed by whoever takes this: which variant values buttonVariants actually admits; whether destructive is among them; what the "authored it three times and had to drop it" catalog sites were and what they wanted; whether changing AlertDialogAction's default has a blast radius across existing dialogs.

⛔ Read this before choosing a key name

objectui#7963 is retiring confirmVariant with a named tombstone. Re-introducing that same spelling here would be a retire-then-re-add cycle on a published key — visible churn, and churn on the authoring surface is exactly what the maintainer flagged on 2026-09-10 (「协议不应该改来改去啊,否则元数据应用怎么办」).

That is not a veto, but it is a constraint the implementer must answer before writing a type:

  • Is the right shape the old spelling, wired and pinned this time (so the tombstone becomes a "retired, then delivered properly" story)?
  • Or does this capability belong to an existing idiom the repo already has for button variants, so no new authorable key is added at all?
  • Whatever is chosen, it ships with a behaviour pin that has a firing control — the reason confirmVariant survived declared-and-dead for so long is that nothing ever asserted it moved the DOM.

⛔ Do not resurrect the spelling on the grounds that batch #70 named it. Batch #70 named it before anyone knew it was inert.

Scope note

alert-dialog is objectui-local: it is not a governed @objectstack/spec component (probed at objectstack HEAD eabdd66f45f402eba0f8404a8a9de4a501fc83a6 — alert-dialog / AlertDialogProps return zero hits under packages/spec/src, with 'record:chatter' and 'object-metric' as firing controls and a nonexistent name as the negative control). So the 「以 objectstack 协议为准」 principle does not constrain the shape here — the choice is objectui's own.

Refs objectui#7963 · objectui#8736 · objectui#4631 (the ADR-0049 liveness workbook this family routes through).


Generated by Claude Code

Activity

  1. os-tesla commented on Sep 11, 2026

    @os-tesla
    Collaborator

    Claim: domain:ui PM seat (os-tesla) — dispatching an os-dev seat now, R16.

    Branch: claude/issue-8978-alert-dialog-destructive-confirm

    Clause-②: yes

    Reason for the yes, recorded at dispatch and ⛔ not revisable on delivery: every route this card leaves open touches a published face — a new authorable key on the alert-dialog renderer schema, or a changed default on AlertDialogAction, or both. 「拿不准 ⇒ 按 yes」.

    Why this dispatches on zero comments

    The heuristic "zero comments ⇒ untriaged" has an exception, and this card is it: the body was written by a director seat splitting this out by maintainer ruling on 2026-09-10, and it carries decision batch #70's verbatim 「同意」 for the capability itself. That is stronger than a triage comment, not weaker. ⇒ read who wrote the body.


    Generated by Claude Code

  2. self-assigned this
    on Sep 11, 2026
  3. os-tesla commented on Sep 11, 2026

    @os-tesla
    Collaborator

    os-dev-report

    {
      "issue": 8978,
      "status": "done",
      "branch": "claude/issue-8978-alert-dialog-destructive-confirm",
      "pr": "https://github.com/objectstack-ai/objectui/pull/9199",
      "premise_still_valid": true,
      "summary": "Wired the destructive confirm as a NEW authorable key `AlertDialogSchema.actionVariant?: 'default' | 'destructive'`, read by the `alert-dialog` renderer onto the confirm button, with a DOM pin, docs, two catalog fixtures restored and a changeset. HEAD 4a617ceb8 on base fe9e0d054.\n\nSHAPE CHOSEN, AND THE ARGUMENT AGAINST R2'S TOMBSTONE. Spelling is `actionVariant`, NOT `confirmVariant`. (1) The retired key stays retired: a document authoring `confirmVariant` still reds, same `invalid_type` code, so no published spelling goes from refusing an author to accepting one. That is the whole content of the maintainer's 2026-09-10 remark. (2) `actionVariant` is not a new dialect: this node already calls that button `action` three times (`actionText`, `onAction`, `AlertDialogAction`), and the SAME retirement pointed `confirmLabel` at `actionText`. Reviving `confirm*` for the variant while `confirm*` for the label is tombstoned would publish two prefixes for one button. (3) One published string did move: the tombstone MESSAGE, from 'NO surviving spelling ... a separate question that needs its own card' to naming `actionVariant`. This card IS that card, so the sentence was stale on arrival; the key's verdict is unchanged. Two existing pins were re-derived onto the other side of that question rather than deleted.\n\nVALUE DOMAIN: two, not `ButtonSchema.variant`'s six, BY MEASUREMENT (see M5). Declaring a value the node cannot render would be the `confirmVariant` disease one level down, at the value instead of the key.\n\nM1 - `buttonVariants` (printed from packages/components/src/ui/button.tsx, not assumed): six variants default|destructive|outline|secondary|ghost|link plus a size axis, defaultVariants variant:'default' size:'default'. `destructive` emits `bg-destructive text-destructive-foreground hover:bg-destructive/90`; the default the confirm button is pinned to emits `bg-primary text-primary-foreground hover:bg-primary/90`. YES, destructive is among them. Also measured: `buttonVariants()` is byte-identical to `buttonVariants({variant: undefined})`. The pin never types those class names in - it computes the token sets FROM `buttonVariants` at run time, with an ORACLE control that reds if the two variants ever stop differing.\n\nM2 - BLAST RADIUS: zero, because the default is NOT changed. Call sites enumerated by `git grep AlertDialogAction`: apps/console ApprovalsInboxPage (4), app-shell InvitationsPage / MembersPage / SettingsPage (2) / ActionConfirmDialog, components NotificationAlerts (2), plugin-detail RelatedList, plugin-form DrawerForm + ModalForm, plugin-gantt ObjectGantt, and the SDUI renderer. Every one passes no variant; the renderer passes className=undefined for an unauthored key and `cn(buttonVariants(), undefined)` is byte-identical to `cn(buttonVariants())`. Asserted as the pin's UNTOUCHED control, which compares two whole rendered dialogs, not as an argument.\n\nM2 SIDE-FINDING THAT CHANGED THE ROUTE: the card's suggested edit to packages/components/src/ui/alert-dialog.tsx is FORBIDDEN - AGENTS.md Commandment #7 makes packages/components/src/ui/** a No-Touch zone regenerated by `pnpm shadcn:update`, and `alert-dialog` is a tracked component (`pnpm shadcn:list`). The sanctioned escape hatch is a declared patch in scripts/shadcn-local-patches.mjs, whose own header says to keep the payload OUT of src/ui/. So the variant is applied as a className OVERRIDE from the renderer - not invented: packages/components/src/notifications/NotificationAlerts.tsx already expresses a footer variant on this very button the same way, with a comment saying why.\n\nM3 - THE CATALOG SITES: found, and the count is TWO, not three. Commit ed5fee9be (PR #7962) converted FOUR fixtures under examples/schema-catalog/src/schemas/components-overlay-alert-dialog/ off a phantom `actions` array and its own message says 'the per-button variant is DROPPED'. Dropped CONFIRM-button variants: basic-alert-dialog 'Continue' = destructive; confirmation-dialog 'Save' = default; custom-actions 'Yes, Continue' = default; destructive-action 'Delete' = destructive. I am not rounding two up to three. The reading that yields exactly three is a different denominator - the dropped variants the primitives could not reproduce for free: two destructive confirms plus the `ghost` CANCEL in confirmation-dialog (the two outline cancels and two default confirms are what the primitives already emit). That is a plausible reconstruction of the ruling's sentence, NOT a fact I measured it against. The two destructive confirms are restored in this PR.\n\nM4 - ANCHORS RE-MEASURED BY TEXT (card read 2c208d5bb; this branch bases on fe9e0d054). All still exactly as described: the `buttonVariants` import from './button'; the confirm button's `className={cn(buttonVariants(), className)}` with NO argument; the cancel button's `buttonVariants({ variant: \"outline\" })`; and the renderer's one-line `{schema.actionText && ...AlertDialogAction onClick={schema.onAction}...}`. Premise holds; only the line numbers moved. Every zero-hit reading in this run carried a lit control in the same command - e.g. the source-text pin sweep's control hit packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx:161 for KanbanImpl.tsx, exactly as the dispatch specified, and the sweep widened to my changed-file names and to scripts/ found nothing outside the pins I own or updated.\n\nM5 - DOES A KEY NEED ADDING: YES, and the sweep is what says so. The repo HAS an idiom - a key spelled `variant` at `buttonVariants`' domain on the node that OWNS the button: 18 renderer registrations declare a `variant` input and about 20 renderers read `schema.variant`; `ButtonSchema.variant` (packages/types/src/form.ts) is the exact six-member union, read at renderers/form/button.tsx. BUT no existing idiom reaches THIS button: `AlertDialogSchema` declares no variant key, there is no footer slot, the footer is generated from two STRINGS, and the `content` slot renders above the footer and is not an `AlertDialogAction`. The M3 fixtures prove it from the author's side - they reached for `type:'button'` nodes with `variant` (the idiom) and the renderer carried none of it. So the idiom governs the SPELLING and VALUE DOMAIN, not whether a key is needed.\n\nM5 SECOND HALF - WHY TWO VALUES, measured through the real renderer, not inherited from the idiom: an override can only displace a baked-in class that shares its tailwind-merge group. default = clean; destructive = clean; secondary = clean (but nothing asked for it, and widening later is non-breaking while narrowing is not); outline LEAKS `text-primary-foreground`; ghost LEAKS `bg-primary text-primary-foreground`; link LEAKS `bg-primary hover:bg-primary/90`. The pin's MECHANISM- legs render each of the three lossy values THROUGH the renderer and assert the leftovers are still on the node, so that table is re-derived on every run rather than recorded - widening the union without widening the mechanism reds.",
      "tests": "ABLATION (the requested matrix), run TWICE because run 1 caught a bad assertion of mine. Mutation: the renderer's className wiring line deleted. On-disk proof both runs: HEAD blob hash printed; anchor `grep -c` 1 BEFORE and 0 AFTER; post-mutation `git hash-object` differs from the HEAD blob; restore via `git checkout HEAD -- ABSOLUTE_PATH` from an `EXIT INT TERM` trap; restoration proven by an EMPTY `git diff HEAD` (printed 'diff-empty=YES'), never by an exit code. No build/dist step is involved - vitest aliases the @object-ui packages to src (vitest.config.mts resolve.alias), so there is no stale-dist channel here.\n  RUN 1 = 4 failed | 13 passed. RUN 2 (after the repair) = 7 failed | 10 passed. WIRED = 17 passed.\n  leg-by-leg (wired / ablated-run2): WIRED,ORACLE,INSTRUMENT,UNTOUCHED controls PASS/PASS; LEG 1 destructive tokens present PASS/FAIL; LEG 1b displaced tokens gone PASS/FAIL; LEG 2 default path unchanged PASS/PASS; LEG 3 cancel untouched + confirm moved PASS/FAIL; MECHANISM+ default PASS/PASS; MECHANISM+ destructive PASS/FAIL; MECHANISM- outline|ghost|link PASS/FAIL times 3; authoring-face closure (4 legs) PASS/PASS.\n  THE FINDING, flagged per the dispatch's star: in RUN 1 the three MECHANISM- legs stayed GREEN while the thing they name was deleted, because each asserted only that the primitive's baked-in classes were STILL on the node - trivially true of a renderer that applies nothing. Fixed, not noted: each now asserts ARRIVAL first (the tokens the variant itself emits ARE on the node) and only then the loss. Commit 4a617ceb8.\n\nSUITES, all at 4a617ceb8, all under the shared verify lock (OS_VERIFY_LOCK_SLOT=os-dev-8978), reading each run's printed VERDICT command-exit line:\n  pnpm --filter @object-ui/types test -> Test Files 177 passed (177), Tests 3460 passed (3460), VERDICT command-exit 0\n  pnpm --filter @object-ui/components test -> Test Files 265 passed (265), Tests 2430 passed (2430), VERDICT command-exit 0\n  vitest run examples/schema-catalog/ -> Test Files 31 passed (31), Tests 2151 passed (2151), VERDICT command-exit 0\n  targeted earlier run over the 8 directly-affected pins -> Test Files 8 passed (8), Tests 485 passed (485)\n  type-check: pnpm --filter @object-ui/types --filter @object-ui/components run type-check -> 'Done' on both, VERDICT command-exit 0. Both include tsconfig.test.json, which is what compiles the @ts-expect-error and Equal legs. It caught a real narrow-parameter error in my pin on the first attempt.\n  lint: pnpm --filter @object-ui/types --filter @object-ui/components run lint -> 0 errors on both (279 / 953 pre-existing warnings), VERDICT command-exit 0. This IS the whole population `turbo run lint` covers for the two packages this diff touches, so it is not a narrowing of the file set. Type-aware linting is NOT enabled - the flat config extends tseslint.configs.recommended and languageOptions carries only ecmaVersion and globals, with zero hits for `projectService` against a lit control of 1 hit for `languageOptions` - so this diff cannot move a verdict on any file it does not contain.\n\nGATES GREEN (exit 0, reading each gate's own printed verdict line, never a bare dollar-question): changeset:check, check:changeset-claims, check-changeset-presence, check:doc-types, check:doc-fences, check:doc-example-ids, check:new-line-citations (0 new citations), check:handler-key-reads, check:control-bytes, check:side-effects-array, check:unreferenced-sources, check:spec-symbols, check:element-data-source-declaration, check:prompt-keys, check:vi-mock-specifiers. Plus a manual control-byte self-scan over the changed files.\n\nNOT MEASURED - three gates, one reason, all declared to CI, NONE recorded as green: check:sdui-registration-pins, check:doc-snippets and check:doc-examples all exit 2 = PREREQUISITE NOT MET. The first prints 'No console build to weigh at apps/console/dist/assets. This is exit 2, not a pass'. I attempted `pnpm --filter @object-ui/console build`: it needs every workspace package's dist (TS2307 'Cannot find module @object-ui/components' and friends), i.e. a whole-tree build, which is past this container's roughly 10 minute foreground cap. The cheap sibling of the first, check:side-effects-array, IS green, and this diff adds no module and no registration - only an `inputs` entry on a registration that already existed. pnpm shadcn:check also not run: weekly scheduled, needs the network, and this diff deliberately touches no file under packages/components/src/ui/.\n\nTOOLCHAIN NOTE: my first dependency-closure build failed because I put --concurrency=2 AFTER --filter and pnpm forwarded it to `tsup --concurrency=2`. Re-run as `pnpm --workspace-concurrency=2 --filter '@object-ui/components^...' build` -> all 7 deps Done.",
      "mcp_calls": "1 - one mcp__github__issue_read for the card itself. Everything else went through the REST channel (probed green at open: GET /repos/objectstack-ai/objectui returned 200) or through git.",
      "open_questions": [],
      "out_of_scope_findings": [
        "noted, not filed: the `ghost` CANCEL variant. confirmation-dialog.json authored variant:'ghost' on its cancel button before PR #7962 stripped it. AlertDialogCancel hard-codes outline and there is no cancelVariant key. This card is the destructive CONFIRM; a cancel variant is a different capability with no ruling behind it, and the same override channel would carry neither ghost nor link anyway (measured, M5). Handler: whoever widens the override mechanism, i.e. the same PR that would widen actionVariant past two values. Not filed as a defect because nothing is broken - the key simply is not declared, so an author writing it is refused by name.",
        "noted, not filed: NotificationAlerts.tsx's ALERT_ACTION_CLASSES is a hand-written map of override classes for this exact button, including an explicit `bg-transparent` to neutralise the baked-in default - i.e. the repo already knew about the leak M5 measured, and paid for it by hand. It is internal React props, not authorable metadata, so it is in no filing category (not category c). Cited because it is the precedent this renderer's approach follows. Handler: whoever widens the union.",
        "noted, not filed: the card's line citations into alert-dialog.tsx and renderers/overlay/alert-dialog.tsx have drifted, as the card itself warned. Nothing to fix - recorded only so M4's re-measurement is not read as a correction to the card. Handler: none.",
        "platform reading, not a finding: POST /pulls stored my session-URL footer verbatim, but PATCH /pulls/9199 APPENDED a second, bare `_Generated by [Claude Code](https://claude.ai/code)_` block after it. Re-sending the body ending in the bare block instead left it untouched (sent bytes equal stored bytes, footer count 1). So on this channel the footer form is create-versus-edit dependent. I did not loop."
      ]
    }

    Generated by Claude Code

  4. removed their assignment
    on Sep 11, 2026
  5. os-tesla commented on Sep 11, 2026

    @os-tesla
    Collaborator

    Landed at 93fc0e77b, verified by CONTENT — ⛔ never by sha

    PR #9199 merged; Fixes #8978 closed this card on merge.

    reading parent 93fc0e77b^ main 93fc0e77b
    SUBJECT actionVariant in packages/types/src/overlay.ts 0 4
    SUBJECT actionVariant in components/src/renderers/overlay/alert-dialog.tsx 0 3
    AlertDialogSchema (control, lit) 4 4
    actionText (control, lit) 5 5
    onAction (control, lit) 2 2

    Three controls in the subject file itself, each checked for movement, so both zeros were read by a probe proven to be looking.

    ⭐ The reverse reading needed care and is worth recording as a method note. A count of confirmVariant across packages/types/src rises across this merge (the retirement pin gained assertions and the tombstone message now names the remedy). A rising count is not evidence the key was revived, and a falling one would not have been evidence it stayed retired either — the refusal is a behaviour, not an occurrence count. What actually holds it is the pin I read at source before arming: RETIRED = ['cancelLabel', 'confirmLabel', 'confirmVariant'], each asserted to red with the same invalid_type code, plus a new CONTROL proving actionVariant is a narrow enum rather than .passthrough() admitting anything. Those assertions are green on main. ⇒ counting the NAME would have answered the wrong question here in both directions.

    pm:dispatched and the assignee stripped by hand.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatenhancementNew feature or requestpackage: componentspriority:p3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions