Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .changeset/7654-floating-chatbot-trigger-icon-tombstone.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }).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();
});
});
67 changes: 64 additions & 3 deletions packages/types/src/complex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading