diff --git a/.changeset/7654-floating-chatbot-trigger-icon-tombstone.md b/.changeset/7654-floating-chatbot-trigger-icon-tombstone.md new file mode 100644 index 000000000..9b382d360 --- /dev/null +++ b/.changeset/7654-floating-chatbot-trigger-icon-tombstone.md @@ -0,0 +1,70 @@ +--- +'@object-ui/types': minor +--- + +Retire `FloatingChatbotConfig.triggerIcon` (objectui#7654, ADR-0049 enforce-or-remove). + +`triggerIcon` was declared `?: string` with `@default 'MessageCircle'` and read by nothing. +`FloatingChatbot` destructures six of the interface's seven keys — `position`, +`defaultOpen`, `panelWidth`, `panelHeight`, `title`, `triggerSize` — and never this one, +and `FloatingChatbotTrigger` takes no icon prop at all, so the advertised default never +rendered either. Re-measured on this branch's base rather than inherited from the card: a +whole-repo `git grep` census over tracked files, build output excluded, returns the +declaration and one historical CHANGELOG line and nothing else, while the same pass over +`triggerSize` — a key that IS read — returns ten sites across four files, so the instrument +was not blind. + +It is also absent from the `chatbot-floating` registration's `inputs` AND from its +`defaultProps` (`packages/plugin-chatbot/src/renderer.tsx`), both re-confirmed here. No +designer control ever offered it and no designer-created node carries it, so TypeScript was +the only way to reach the key. That is what makes this half of objectui#7654 an ordinary +retirement; the card's other key, `displayMode`, is seeded into `defaultProps` and is NOT +touched here. + +FROM → TO: `triggerIcon?: string` → **tombstoned**, `?: never` on the interface. The FAB +trigger renders a fixed icon and takes no icon prop; there is no authored spelling that +changes it. + +## This tombstone has NO Zod half, deliberately + +Every other tombstone in this package pairs `?: never` with a `retirementTombstone()` +refusal on the Zod twin. There is no twin here to carry one: `FloatingChatbotConfig` has no +Zod mirror at all, and `floatingConfig` sits in the `UnmirroredDeclared` ledger +(`zod-mirror-parity.test.ts`, `complex.zod.ts#ChatbotSchema`). `BaseSchema` is +`.passthrough()`, so the whole `floatingConfig` object rides through unvalidated — before +this change and after it. Minting a mirror to host a refusal would be the +declared-but-UNMIRRORED axis (objectui#6152), a different defect: a key can be mirrored and +inert, or unmirrored and live, and fixing one says nothing about the other. This change +does not widen into it. + +**Accept-set change, stated plainly for reviewers:** on the TypeScript face, a write of +`FloatingChatbotConfig.triggerIcon` used to compile and now does not. On the runtime face, +nothing changes at all — the key parsed green before and parses green after. The refusal is +TYPE-LEVEL ONLY, which is narrower than this package's other tombstones and is the reason +this carries a contract-review label rather than being filed as an internal tidy-up. + +## Why a tombstone and not a deletion, when the usual argument does not apply + +The usual case for `?: never` argues from the mirror: an undeclared key is silently +STRIPPED by a non-strict `z.object`, so deleting trades one silent no-op for another. With +no mirror, that argument is unavailable, so the route was measured on the `tsc` channel +alone instead: + +| route | fresh object literal | widened (non-fresh) value | +|---|---|---| +| deleted | `TS2353` excess-property error | **compiles CLEAN** | +| tombstoned | `TS2322` | `TS2322` | + +Excess-property checking only reaches a fresh literal, so deletion would have left the +widened path — `const raw = { triggerIcon: 'Sparkles' }; const cfg: FloatingChatbotConfig = +raw;` — silently accepting a key nothing reads. The declared `never` makes the assignment +itself ill-typed, so freshness stops mattering. Both rows are pinned in +`packages/types/src/__tests__/floating-chatbot-trigger-icon-retired.test.ts`, the "deleted" +row as a live control on a genuinely undeclared key rather than as prose, so the contrast +cannot rot. + +That file also pins the runtime half as a **tripwire**: it asserts that a node carrying +`floatingConfig.triggerIcon` still parses green. If objectui#6152 ever mints a +`FloatingChatbotConfigSchema`, it goes red — the intended signal that whoever lands the +mirror must add the `retirementTombstone()` half at the same time and flip the control +rather than delete it into a vacuum. diff --git a/packages/types/src/__tests__/floating-chatbot-trigger-icon-retired.test.ts b/packages/types/src/__tests__/floating-chatbot-trigger-icon-retired.test.ts new file mode 100644 index 000000000..59279daeb --- /dev/null +++ b/packages/types/src/__tests__/floating-chatbot-trigger-icon-retired.test.ts @@ -0,0 +1,148 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `FloatingChatbotConfig.triggerIcon` is an ADR-0049 RETIREMENT TOMBSTONE + * (objectui#7654), and — unlike every other tombstone in this package — its + * refusal is TYPE-LEVEL ONLY. Both halves of that sentence are pinned here. + * + * ## What was measured + * + * `triggerIcon` was declared `?: string` with `@default 'MessageCircle'` and + * read by nothing. `FloatingChatbot` destructures six of the interface's seven + * keys (`position`, `defaultOpen`, `panelWidth`, `panelHeight`, `title`, + * `triggerSize`) and never this one, and `FloatingChatbotTrigger` takes no icon + * prop, so the advertised default never rendered. A whole-repo `git grep` + * census over tracked files, build output excluded, returned the declaration + * and one historical CHANGELOG line and nothing else; the same pass over + * `triggerSize` returned ten sites across four files, so the instrument was not + * blind. It is absent from the `chatbot-floating` registration's `inputs` and + * from its `defaultProps`, so no designer control offered it and no + * designer-created node carries it — TypeScript was the only way to reach it. + * + * ## Why a tombstone, when the usual reason does not apply + * + * The other tombstones here argue from the mirror: an undeclared key is + * silently STRIPPED by a non-strict `z.object`, so deletion trades one silent + * no-op for another. That argument needs a mirror, and this key has none (see + * the runtime section below). The tombstone earns its place on the `tsc` + * channel alone, measured both ways on the retiring PR's merge-base: + * + * | route | fresh object literal | widened (non-fresh) value | + * |------------|-------------------------------|---------------------------| + * | deleted | TS2353 excess-property error | **compiles CLEAN** | + * | tombstoned | TS2322 | TS2322 | + * + * Excess-property checking only reaches a FRESH literal, so deleting the key + * would have left the widened path silently accepting it. The declared `never` + * makes the assignment itself ill-typed, so freshness stops mattering. That + * contrast is not prose here — it is pinned live: the `bogusUndeclared` control + * below IS the "deleted" row, carrying no directive because a key this + * interface does not declare really does ride the widened path unchallenged. + * + * The `@ts-expect-error` directives are REAL enforcement: this package + * type-checks its tests through `tsconfig.test.json`, so re-widening the + * declaration fails the build on the unused directive. A green `vitest` run is + * NOT evidence about them — type assertions are erased before it runs. + */ + +import { describe, it, expect } from 'vitest'; +import type { FloatingChatbotConfig } from '../complex'; +import { ChatbotSchema } from '../zod/complex.zod'; + +/* ── type-level pins: the `tsc` channel ──────────────────────────────────── */ + +describe('the `triggerIcon` tombstone makes authoring a `tsc` error', () => { + it('refuses it in a FRESH object literal', () => { + const config: FloatingChatbotConfig = { + title: 'Chat', + // @ts-expect-error `triggerIcon` is a retirement tombstone (objectui#7654) + triggerIcon: 'Sparkles', + }; + expect(config.title).toBe('Chat'); + }); + + it('refuses it through a WIDENED value too — the half a deletion would have missed', () => { + const raw = { title: 'Chat', triggerIcon: 'Sparkles' }; + // @ts-expect-error `triggerIcon` is a retirement tombstone (objectui#7654) + const config: FloatingChatbotConfig = raw; + expect(config.title).toBe('Chat'); + }); + + it('keeps the six LIVE keys writable — the non-vacuity control', () => { + // Without this, a change that broke the whole interface would satisfy both + // assertions above by accident. These six are the keys `FloatingChatbot` + // actually destructures. + const config: FloatingChatbotConfig = { + position: 'bottom-right', + defaultOpen: false, + panelWidth: 400, + panelHeight: 520, + title: 'Chat', + triggerSize: 56, + }; + expect(config.triggerSize).toBe(56); + }); + + it('a key the interface never declared still rides the widened path — the DELETED row', () => { + // This carries NO directive on purpose. It is the measured contrast that + // justifies `?: never` over deletion: an undeclared key IS refused in a + // fresh literal but is NOT refused here. Had `triggerIcon` been deleted + // rather than tombstoned, it would sit exactly where this line sits. + const raw = { title: 'Chat', bogusUndeclared: 1 }; + const config: FloatingChatbotConfig = raw; + expect(config.title).toBe('Chat'); + }); +}); + +/* ── the runtime channel: DELIBERATELY unchanged, and a tripwire if that ends ─ */ + +describe('there is NO zod refusal, and that is deliberate (objectui#7654)', () => { + const node = { + type: 'chatbot' as const, + messages: [{ id: 'm1', role: 'user' as const, content: 'hi' }], + }; + + it('a chatbot node carrying `floatingConfig.triggerIcon` still parses GREEN', () => { + // `FloatingChatbotConfig` has NO zod mirror: `floatingConfig` sits in the + // `UnmirroredDeclared` ledger (`zod-mirror-parity.test.ts`, + // `complex.zod.ts#ChatbotSchema`), and `BaseSchema` is `.passthrough()`, so + // the whole object rides through unvalidated. This was green before the + // tombstone and is green after it — the retirement changed the TypeScript + // face only, and this pins that it changed no parse outcome. + // + // ⚠️ TRIPWIRE: if objectui#6152 ever mints a `FloatingChatbotConfigSchema`, + // this goes RED. That is the intended signal, not a nuisance — whoever + // lands the mirror must add the `retirementTombstone()` half for + // `triggerIcon` at the same time, and flip this control rather than delete + // it into a vacuum. + const result = ChatbotSchema.safeParse({ + ...node, + floatingConfig: { title: 'Chat', triggerIcon: 'Sparkles' }, + }); + expect(result.success).toBe(true); + }); + + it('a live `floatingConfig` parses green too — the non-vacuity control', () => { + const result = ChatbotSchema.safeParse({ + ...node, + floatingConfig: { title: 'Chat', triggerSize: 56 }, + }); + expect(result.success).toBe(true); + }); + + it('the mirror really has no `floatingConfig` key at all', () => { + // The load-bearing fact behind everything above, asserted rather than + // assumed: a key the mirror declares would appear in its shape. + const shape = (ChatbotSchema as unknown as { shape: Record }).shape; + expect(shape.floatingConfig).toBeUndefined(); + // Lit control: a key the mirror DOES declare is present, so the reading + // above is a measurement and not an empty object. + expect(shape.messages).toBeDefined(); + }); +}); diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index b0d9db278..453512295 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -922,10 +922,71 @@ export interface FloatingChatbotConfig { */ title?: string; /** - * Custom icon name for the FAB trigger (Lucide icon name). - * @default 'MessageCircle' + * ADR-0049 RETIREMENT TOMBSTONE — `triggerIcon` (objectui#7654). + * + * `?: never` is this package's tombstone convention (see {@link + * ComponentInput} in `base.ts`, `crud.ts` `confirm`, and {@link + * StaticTableColumn} in `data-display.ts`): the key stays DECLARED and + * becomes UNWRITABLE, so authoring one is a `tsc` error here. + * + * What was measured, on the merge-base of the retiring PR: nothing reads it. + * `FloatingChatbot` (`plugin-chatbot/src/FloatingChatbot.tsx`) destructures + * six of this interface's seven keys — `position`, `defaultOpen`, + * `panelWidth`, `panelHeight`, `title`, `triggerSize` — and never this one; + * `FloatingChatbotTrigger` takes no icon prop at all, so the promised + * `'MessageCircle'` default never rendered either. A whole-repo `git grep` + * census over tracked files, build output excluded, returned exactly this + * declaration and one historical CHANGELOG line; the same pass over + * `triggerSize`, a key that IS read, returned ten sites across four files, so + * the instrument was demonstrably not blind. + * + * It is also absent from the `chatbot-floating` registration's `inputs` AND + * its `defaultProps` (`plugin-chatbot/src/renderer.tsx`), so no designer + * control ever offered it and no designer-created node carries it. The key + * was reachable from TypeScript alone — which is exactly the surface this + * tombstone closes. + * + * ## Why there is NO `retirementTombstone()` half, and why that is not an omission + * + * The other tombstones in this package pair `?: never` with a + * `retirementTombstone()` refusal on the Zod twin. There is no twin to carry + * one here: `FloatingChatbotConfig` has NO Zod mirror at all, and + * `floatingConfig` sits in the `UnmirroredDeclared` ledger + * (`__tests__/zod-mirror-parity.test.ts`, `complex.zod.ts#ChatbotSchema`). + * `BaseSchema` is `.passthrough()`, so the whole `floatingConfig` object + * rides through unvalidated — before this change and after it, byte for + * byte. Minting a mirror to host a refusal would be the declared-but- + * UNMIRRORED axis (objectui#6152), a different defect from this one: a key + * can be mirrored and inert, or unmirrored and live, and fixing one says + * nothing about the other. This retirement deliberately does not widen into + * it, so `triggerIcon`'s refusal is TYPE-LEVEL ONLY. Runtime parse behaviour + * is unchanged. + * + * ## Why a tombstone and not a deletion, with only the `tsc` channel available + * + * The usual argument for `?: never` over deletion is about the mirror (an + * undeclared key is silently STRIPPED by a non-strict `z.object`), and with + * no mirror here that argument does not apply. The tombstone earns its place + * on the TypeScript channel alone instead, measured both ways: + * + * - DELETED, a fresh object literal is refused — `TS2353: Object literal + * may only specify known properties` — but a WIDENED value is not. + * `const raw = { triggerIcon: 'Sparkles' }; const c: FloatingChatbotConfig + * = raw;` compiled CLEAN, because excess-property checking does not reach + * a non-fresh type. That is the silent no-op traded for another one. + * - TOMBSTONED, both paths are refused: the declared `never` makes the + * assignment itself ill-typed, so freshness stops mattering. + * + * Pinned in `__tests__/floating-chatbot-trigger-icon-retired.test.ts`, + * including the deletion contrast, so nobody can "simplify" this back into a + * deletion without that file going red. + * + * RETIRED (objectui#7654, ADR-0049) — never read: the FAB trigger renders a + * fixed icon and takes no icon prop. There is no authored spelling that + * changes it; the trigger's markup is the only place to change it. + * @deprecated Not part of `FloatingChatbotConfig`'s contract — the value was inert. */ - triggerIcon?: string; + triggerIcon?: never; /** * Custom size for the FAB trigger button in pixels. * @default 56