From 8256e9464bc2e39dd59a89ed975c49e7f17d6ad3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 12:21:21 +0000 Subject: [PATCH 1/3] feat(types,components)!: `ui:icon` names its glyph with `icon`, not the identity key `name` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `name` is the SDUI identity key every authored node carries alongside `id`, not `ui:icon`'s private prop. An ordinary `{ type:'icon', id:'save_icon', name:'save_icon' }` therefore asked lucide for `SaveIcon`, missed, and rendered nothing — silent to a human and clean to a DOM gate, because a renderer that renders nothing spreads no attributes to find. This is the chartered contract migration, not a renderer patch. `IconSchema` and its zod mirror declare `icon: string` required, exactly as `name` was required before it, and `name` reverts to the optional identity inherited from `BaseSchema`. The mirror's previous `name: z.string()` requirement is what blocked the renderer-only fix: the published contract refused the ruled shape while the renderer read a key the contract never declared. There is deliberately no `icon ?? name` fallback. The break is loud in three places instead: the mirror refuses a legacy node with a message naming the rename, the renderer draws the existing placeholder and warns with the exact rename plus a `data-objectui-icon-legacy-name-key` marker, and `migrateIconNodeKeys` converts stored metadata in bulk, reporting rather than guessing on nodes carrying both keys or no glyph key at all. All 98 authored icon nodes in the repo are converted, along with the registry `inputs` entry and the component docs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L --- .changeset/5631-ui-icon-icon-key-migration.md | 68 ++++++ apps/site/app/playground/page.tsx | 50 ++--- content/docs/blocks/block-schema.mdx | 2 +- content/docs/components/basic/icon.mdx | 42 +++- .../src/schemas/app/application-header.json | 2 +- .../src/schemas/app/sidebar-navigation.json | 12 +- .../block-marketplace-listing.json | 2 +- ...-variable-overrides-analytics-feature.json | 2 +- ...h-variable-overrides-security-feature.json | 2 +- .../block-schema/feature-card-block.json | 2 +- .../block-gallery-stats-card.json | 2 +- .../components-basic-icon/basic-icon.json | 2 +- .../components-basic-icon/colored-icons.json | 6 +- .../components-basic-icon/icon-sizes.json | 8 +- .../src/schemas/ecommerce/order-summary.json | 2 +- .../src/schemas/ecommerce/product-card.json | 14 +- .../src/schemas/ecommerce/product-grid.json | 8 +- .../src/schemas/ecommerce/shopping-cart.json | 4 +- .../src/schemas/forms/newsletter-signup.json | 2 +- .../src/schemas/marketing/call-to-action.json | 6 +- .../src/schemas/marketing/features-grid.json | 12 +- .../src/schemas/marketing/pricing-table.json | 22 +- .../src/schemas/marketing/testimonials.json | 30 +-- .../src/schemas/report/report-scheduling.json | 2 +- .../src/__tests__/basic-renderers.test.tsx | 12 +- .../icon-unresolvable-placeholder.test.tsx | 119 +++++++++-- .../components/src/renderers/basic/icon.tsx | 78 +++++-- .../src/__tests__/icon-key-migration.test.ts | 185 +++++++++++++++++ packages/types/src/icon-key-migration.ts | 194 ++++++++++++++++++ packages/types/src/index.ts | 10 + packages/types/src/layout.ts | 37 +++- packages/types/src/zod/layout.zod.ts | 59 +++++- 32 files changed, 853 insertions(+), 145 deletions(-) create mode 100644 .changeset/5631-ui-icon-icon-key-migration.md create mode 100644 packages/types/src/__tests__/icon-key-migration.test.ts create mode 100644 packages/types/src/icon-key-migration.ts diff --git a/.changeset/5631-ui-icon-icon-key-migration.md b/.changeset/5631-ui-icon-icon-key-migration.md new file mode 100644 index 0000000000..fa31693dee --- /dev/null +++ b/.changeset/5631-ui-icon-icon-key-migration.md @@ -0,0 +1,68 @@ +--- +'@object-ui/types': minor +'@object-ui/components': minor +--- + +**BREAKING (authoring): `ui:icon` names its glyph with `icon`, not `name`** + +`{ "type": "icon", "name": "check" }` no longer renders an icon. Write +`{ "type": "icon", "icon": "check" }`. Stored metadata authored before this +release needs converting — see the migration below. + +Marked `minor` per AGENTS.md §版本号策略 (this repo never publishes `major` +outside an `@objectstack` major sync); the break is real and is stated here. + +**Why** + +`name` is the SDUI identity key every authored node carries, alongside `id` — +it is not `ui:icon`'s private prop. So an ordinary node like +`{ type: 'icon', id: 'save_icon', name: 'save_icon' }` asked lucide for a glyph +called `SaveIcon`, missed, and rendered **nothing at all**: silent to a human, +and clean to a DOM gate, because a renderer that renders nothing spreads no +attributes to find. `action:*` already reads `icon`, so this is the vocabulary's +existing answer, and it leaves no node type on which the identity key is +unusable. + +**What changed** + +- `IconSchema` (types + its zod mirror) declares `icon: string` **required**, + exactly as `name` was required before it — a key rename at constant + strictness. `name` reverts to the optional identity inherited from + `BaseSchema`. The mirror previously *required* `name`, which is why the + renderer could not be migrated on its own: the published contract refused the + correct shape. +- `ui:icon` resolves its glyph from `schema.icon`. There is deliberately **no** + `icon ?? name` fallback: a key meaning "identity" or "glyph" depending on + whether a lucide lookup happened to hit is the ambiguity being removed. +- The registry's `inputs` entry and `content/docs/components/basic/icon.mdx` + moved in the same change as the resolver. +- All 98 authored icon nodes in this repo are converted. + +**The break is loud in three places, never silent** + +1. `IconSchema` **refuses** a legacy node, with a message that names the rename + and points at the converter — not zod's default `expected string, received + undefined`. +2. A legacy node that reaches the renderer unvalidated draws the visible + placeholder shipped in the previous release, and its `console.warn` now + carries the exact rename (`icon: "save_icon"`) plus the converter's name. + Its accessible name says so too, and it gains a + `data-objectui-icon-legacy-name-key` marker so a gate can tell + "unmigrated node" from "glyph that does not resolve". +3. **Migration for stored metadata** — `migrateIconNodeKeys` from + `@object-ui/types`: + + ```ts + import { migrateIconNodeKeys } from '@object-ui/types'; + + const { document, converted, warnings } = migrateIconNodeKeys(storedPage); + if (warnings.length) console.warn(warnings.map((w) => w.message).join('\n')); + if (document !== storedPage) await save(document); + ``` + + It walks the whole document and lifts `name` to `icon` on every icon node. + It is a one-shot conversion a deployer runs over stored documents — **not** a + read-path fallback; nothing calls it during rendering or parsing. It + **reports rather than guesses** for the two cases it will not touch: a node + already declaring both keys (`icon` wins, `name` stays the identity it is), + and a node naming no glyph at all. diff --git a/apps/site/app/playground/page.tsx b/apps/site/app/playground/page.tsx index eb31cf0bfc..f027cdad67 100644 --- a/apps/site/app/playground/page.tsx +++ b/apps/site/app/playground/page.tsx @@ -271,7 +271,7 @@ const EXAMPLE_SCHEMAS = { }, { type: "icon", - name: "DollarSign", + icon: "DollarSign", className: "h-4 w-4 text-muted-foreground" } ] @@ -314,7 +314,7 @@ const EXAMPLE_SCHEMAS = { }, { type: "icon", - name: "Users", + icon: "Users", className: "h-4 w-4 text-muted-foreground" } ] @@ -357,7 +357,7 @@ const EXAMPLE_SCHEMAS = { }, { type: "icon", - name: "CreditCard", + icon: "CreditCard", className: "h-4 w-4 text-muted-foreground" } ] @@ -400,7 +400,7 @@ const EXAMPLE_SCHEMAS = { }, { type: "icon", - name: "Activity", + icon: "Activity", className: "h-4 w-4 text-muted-foreground" } ] @@ -583,7 +583,7 @@ const EXAMPLE_SCHEMAS = { children: [ { type: "icon", - name: "Smartphone", + icon: "Smartphone", className: "h-5 w-5 text-blue-600 dark:text-blue-400" } ] @@ -626,7 +626,7 @@ const EXAMPLE_SCHEMAS = { children: [ { type: "icon", - name: "Laptop", + icon: "Laptop", className: "h-5 w-5 text-purple-600 dark:text-purple-400" } ] @@ -669,7 +669,7 @@ const EXAMPLE_SCHEMAS = { children: [ { type: "icon", - name: "Headphones", + icon: "Headphones", className: "h-5 w-5 text-green-600 dark:text-green-400" } ] @@ -878,7 +878,7 @@ const EXAMPLE_SCHEMAS = { children: [ { type: "icon", - name: "Smartphone", + icon: "Smartphone", className: "h-20 w-20 text-blue-600 dark:text-blue-400" } ] @@ -935,27 +935,27 @@ const EXAMPLE_SCHEMAS = { children: [ { type: "icon", - name: "Star", + icon: "Star", className: "h-4 w-4 fill-yellow-400 text-yellow-400" }, { type: "icon", - name: "Star", + icon: "Star", className: "h-4 w-4 fill-yellow-400 text-yellow-400" }, { type: "icon", - name: "Star", + icon: "Star", className: "h-4 w-4 fill-yellow-400 text-yellow-400" }, { type: "icon", - name: "Star", + icon: "Star", className: "h-4 w-4 fill-yellow-400 text-yellow-400" }, { type: "icon", - name: "Star", + icon: "Star", className: "h-4 w-4 text-gray-300" }, { @@ -985,7 +985,7 @@ const EXAMPLE_SCHEMAS = { children: [ { type: "icon", - name: "Laptop", + icon: "Laptop", className: "h-20 w-20 text-purple-600 dark:text-purple-400" } ] @@ -1037,27 +1037,27 @@ const EXAMPLE_SCHEMAS = { children: [ { type: "icon", - name: "Star", + icon: "Star", className: "h-4 w-4 fill-yellow-400 text-yellow-400" }, { type: "icon", - name: "Star", + icon: "Star", className: "h-4 w-4 fill-yellow-400 text-yellow-400" }, { type: "icon", - name: "Star", + icon: "Star", className: "h-4 w-4 fill-yellow-400 text-yellow-400" }, { type: "icon", - name: "Star", + icon: "Star", className: "h-4 w-4 fill-yellow-400 text-yellow-400" }, { type: "icon", - name: "Star", + icon: "Star", className: "h-4 w-4 fill-yellow-400 text-yellow-400" }, { @@ -1087,7 +1087,7 @@ const EXAMPLE_SCHEMAS = { children: [ { type: "icon", - name: "Headphones", + icon: "Headphones", className: "h-20 w-20 text-green-600 dark:text-green-400" } ] @@ -1139,27 +1139,27 @@ const EXAMPLE_SCHEMAS = { children: [ { type: "icon", - name: "Star", + icon: "Star", className: "h-4 w-4 fill-yellow-400 text-yellow-400" }, { type: "icon", - name: "Star", + icon: "Star", className: "h-4 w-4 fill-yellow-400 text-yellow-400" }, { type: "icon", - name: "Star", + icon: "Star", className: "h-4 w-4 fill-yellow-400 text-yellow-400" }, { type: "icon", - name: "Star", + icon: "Star", className: "h-4 w-4 fill-yellow-400 text-yellow-400" }, { type: "icon", - name: "Star", + icon: "Star", className: "h-4 w-4 fill-yellow-400 text-yellow-400" }, { diff --git a/content/docs/blocks/block-schema.mdx b/content/docs/blocks/block-schema.mdx index 1d0fcb2050..f8fbbef323 100644 --- a/content/docs/blocks/block-schema.mdx +++ b/content/docs/blocks/block-schema.mdx @@ -280,7 +280,7 @@ const cardBlock: BlockSchema = { children: [ { type: 'icon', - name: '${icon}', + icon: '${icon}', size: 48, className: 'text-primary' } diff --git a/content/docs/components/basic/icon.mdx b/content/docs/components/basic/icon.mdx index 3c0b5e8c74..f031aa3e78 100644 --- a/content/docs/components/basic/icon.mdx +++ b/content/docs/components/basic/icon.mdx @@ -16,9 +16,49 @@ description: "Display icons from the Lucide icon library" ```plaintext interface IconSchema { type: 'icon'; - name: string; // Lucide icon name (kebab-case) + icon: string; // Lucide glyph name (kebab-case), REQUIRED size?: number | 'sm' | 'md' | 'lg' | 'xl'; color?: string; // Tailwind color class className?: string; } ``` + +The glyph is named by `icon`: + +```json +{ "type": "icon", "icon": "check" } +``` + +## `name` is identity, not the glyph + +`name` is the SDUI identity key every node carries, alongside `id`. It is **not** +read as a glyph name on `ui:icon`, and writing one there renders no icon: + +```json +{ "type": "icon", "id": "save_icon", "name": "save_icon" } +``` + +That node is **refused** by `IconSchema` — with a message naming this rename — +and, if it reaches the renderer unvalidated, draws a dashed-square placeholder +with an accessible name saying which icon failed, rather than rendering nothing. +There is deliberately no fallback from `icon` to `name`: a key that means +"identity" sometimes and "glyph" other times, depending on whether a lucide +lookup happened to hit, is the ambiguity this contract removes. + +### Migrating stored metadata + +Metadata authored before this change names its glyph with `name`. Convert it in +bulk — once, at the source — with the converter shipped for it: + +```ts +import { migrateIconNodeKeys } from '@object-ui/types'; + +const { document, converted, warnings } = migrateIconNodeKeys(storedPage); +if (warnings.length) console.warn(warnings.map((w) => w.message).join('\n')); +if (document !== storedPage) await save(document); +``` + +It walks the whole document, lifts `name` to `icon` on every icon node, and +**reports rather than guesses** for the two cases it will not touch: a node that +already declares both keys (`icon` wins; `name` stays the identity it is), and a +node that names no glyph at all. diff --git a/examples/schema-catalog/src/schemas/app/application-header.json b/examples/schema-catalog/src/schemas/app/application-header.json index ede75d00bb..28375f17aa 100644 --- a/examples/schema-catalog/src/schemas/app/application-header.json +++ b/examples/schema-catalog/src/schemas/app/application-header.json @@ -11,7 +11,7 @@ "children": [ { "type": "icon", - "name": "box", + "icon": "box", "className": "h-6 w-6 text-primary" }, { diff --git a/examples/schema-catalog/src/schemas/app/sidebar-navigation.json b/examples/schema-catalog/src/schemas/app/sidebar-navigation.json index 01b7da0061..5600b393b2 100644 --- a/examples/schema-catalog/src/schemas/app/sidebar-navigation.json +++ b/examples/schema-catalog/src/schemas/app/sidebar-navigation.json @@ -14,7 +14,7 @@ "children": [ { "type": "icon", - "name": "layout-dashboard", + "icon": "layout-dashboard", "className": "h-4 w-4" }, { @@ -32,7 +32,7 @@ "children": [ { "type": "icon", - "name": "users", + "icon": "users", "className": "h-4 w-4 text-muted-foreground" }, { @@ -56,7 +56,7 @@ "children": [ { "type": "icon", - "name": "target", + "icon": "target", "className": "h-4 w-4 text-muted-foreground" }, { @@ -83,7 +83,7 @@ "children": [ { "type": "icon", - "name": "megaphone", + "icon": "megaphone", "className": "h-4 w-4 text-muted-foreground" }, { @@ -101,7 +101,7 @@ "children": [ { "type": "icon", - "name": "mail", + "icon": "mail", "className": "h-4 w-4 text-muted-foreground" }, { @@ -123,7 +123,7 @@ "children": [ { "type": "icon", - "name": "settings", + "icon": "settings", "className": "h-4 w-4 text-muted-foreground" }, { diff --git a/examples/schema-catalog/src/schemas/block-schema/block-marketplace-listing.json b/examples/schema-catalog/src/schemas/block-schema/block-marketplace-listing.json index ca44ba7eb3..7d6294cab2 100644 --- a/examples/schema-catalog/src/schemas/block-schema/block-marketplace-listing.json +++ b/examples/schema-catalog/src/schemas/block-schema/block-marketplace-listing.json @@ -11,7 +11,7 @@ "children": [ { "type": "icon", - "name": "layout-template", + "icon": "layout-template", "className": "h-12 w-12 text-primary/60" } ] diff --git a/examples/schema-catalog/src/schemas/block-schema/block-with-variable-overrides-analytics-feature.json b/examples/schema-catalog/src/schemas/block-schema/block-with-variable-overrides-analytics-feature.json index 8e77fbe295..41a1d6a6e2 100644 --- a/examples/schema-catalog/src/schemas/block-schema/block-with-variable-overrides-analytics-feature.json +++ b/examples/schema-catalog/src/schemas/block-schema/block-with-variable-overrides-analytics-feature.json @@ -17,7 +17,7 @@ "children": [ { "type": "icon", - "name": "chart-column", + "icon": "chart-column", "className": "h-6 w-6 text-purple-600" } ] diff --git a/examples/schema-catalog/src/schemas/block-schema/block-with-variable-overrides-security-feature.json b/examples/schema-catalog/src/schemas/block-schema/block-with-variable-overrides-security-feature.json index e20597199d..1b1b10c4f9 100644 --- a/examples/schema-catalog/src/schemas/block-schema/block-with-variable-overrides-security-feature.json +++ b/examples/schema-catalog/src/schemas/block-schema/block-with-variable-overrides-security-feature.json @@ -17,7 +17,7 @@ "children": [ { "type": "icon", - "name": "shield", + "icon": "shield", "className": "h-6 w-6 text-green-600" } ] diff --git a/examples/schema-catalog/src/schemas/block-schema/feature-card-block.json b/examples/schema-catalog/src/schemas/block-schema/feature-card-block.json index 4283c73e8b..a50f6dfe1a 100644 --- a/examples/schema-catalog/src/schemas/block-schema/feature-card-block.json +++ b/examples/schema-catalog/src/schemas/block-schema/feature-card-block.json @@ -17,7 +17,7 @@ "children": [ { "type": "icon", - "name": "zap", + "icon": "zap", "className": "h-6 w-6 text-primary" } ] diff --git a/examples/schema-catalog/src/schemas/blocks-gallery/block-gallery-stats-card.json b/examples/schema-catalog/src/schemas/blocks-gallery/block-gallery-stats-card.json index f1fc56e939..6d686a4c30 100644 --- a/examples/schema-catalog/src/schemas/blocks-gallery/block-gallery-stats-card.json +++ b/examples/schema-catalog/src/schemas/blocks-gallery/block-gallery-stats-card.json @@ -19,7 +19,7 @@ }, { "type": "icon", - "name": "dollar-sign", + "icon": "dollar-sign", "className": "h-4 w-4 text-muted-foreground" } ] diff --git a/examples/schema-catalog/src/schemas/components-basic-icon/basic-icon.json b/examples/schema-catalog/src/schemas/components-basic-icon/basic-icon.json index 4e2badb25f..dc9baaa661 100644 --- a/examples/schema-catalog/src/schemas/components-basic-icon/basic-icon.json +++ b/examples/schema-catalog/src/schemas/components-basic-icon/basic-icon.json @@ -1,4 +1,4 @@ { "type": "icon", - "name": "home" + "icon": "home" } diff --git a/examples/schema-catalog/src/schemas/components-basic-icon/colored-icons.json b/examples/schema-catalog/src/schemas/components-basic-icon/colored-icons.json index d6e9183185..afabaa1f0c 100644 --- a/examples/schema-catalog/src/schemas/components-basic-icon/colored-icons.json +++ b/examples/schema-catalog/src/schemas/components-basic-icon/colored-icons.json @@ -4,17 +4,17 @@ "children": [ { "type": "icon", - "name": "heart", + "icon": "heart", "color": "text-red-500" }, { "type": "icon", - "name": "star", + "icon": "star", "color": "text-yellow-500" }, { "type": "icon", - "name": "check", + "icon": "check", "color": "text-green-500" } ] diff --git a/examples/schema-catalog/src/schemas/components-basic-icon/icon-sizes.json b/examples/schema-catalog/src/schemas/components-basic-icon/icon-sizes.json index 0fe21e76a9..5c517fd52f 100644 --- a/examples/schema-catalog/src/schemas/components-basic-icon/icon-sizes.json +++ b/examples/schema-catalog/src/schemas/components-basic-icon/icon-sizes.json @@ -5,22 +5,22 @@ "children": [ { "type": "icon", - "name": "star", + "icon": "star", "size": "sm" }, { "type": "icon", - "name": "star", + "icon": "star", "size": "md" }, { "type": "icon", - "name": "star", + "icon": "star", "size": "lg" }, { "type": "icon", - "name": "star", + "icon": "star", "size": "xl" } ] diff --git a/examples/schema-catalog/src/schemas/ecommerce/order-summary.json b/examples/schema-catalog/src/schemas/ecommerce/order-summary.json index 1169bfb007..5008f8ca82 100644 --- a/examples/schema-catalog/src/schemas/ecommerce/order-summary.json +++ b/examples/schema-catalog/src/schemas/ecommerce/order-summary.json @@ -90,7 +90,7 @@ "children": [ { "type": "icon", - "name": "credit-card", + "icon": "credit-card", "className": "h-6 w-6" } ] diff --git a/examples/schema-catalog/src/schemas/ecommerce/product-card.json b/examples/schema-catalog/src/schemas/ecommerce/product-card.json index d9685d9bc2..7f67396828 100644 --- a/examples/schema-catalog/src/schemas/ecommerce/product-card.json +++ b/examples/schema-catalog/src/schemas/ecommerce/product-card.json @@ -11,7 +11,7 @@ "children": [ { "type": "icon", - "name": "package", + "icon": "package", "className": "h-24 w-24 text-muted-foreground" } ] @@ -43,27 +43,27 @@ "children": [ { "type": "icon", - "name": "star", + "icon": "star", "className": "h-4 w-4 fill-yellow-500 text-yellow-500" }, { "type": "icon", - "name": "star", + "icon": "star", "className": "h-4 w-4 fill-yellow-500 text-yellow-500" }, { "type": "icon", - "name": "star", + "icon": "star", "className": "h-4 w-4 fill-yellow-500 text-yellow-500" }, { "type": "icon", - "name": "star", + "icon": "star", "className": "h-4 w-4 fill-yellow-500 text-yellow-500" }, { "type": "icon", - "name": "star", + "icon": "star", "className": "h-4 w-4 text-muted" }, { @@ -114,7 +114,7 @@ "children": [ { "type": "icon", - "name": "heart", + "icon": "heart", "className": "h-4 w-4" } ] diff --git a/examples/schema-catalog/src/schemas/ecommerce/product-grid.json b/examples/schema-catalog/src/schemas/ecommerce/product-grid.json index 20740f9dec..ee867c0337 100644 --- a/examples/schema-catalog/src/schemas/ecommerce/product-grid.json +++ b/examples/schema-catalog/src/schemas/ecommerce/product-grid.json @@ -43,7 +43,7 @@ "children": [ { "type": "icon", - "name": "smartphone", + "icon": "smartphone", "className": "h-16 w-16 text-muted-foreground" } ] @@ -86,7 +86,7 @@ "children": [ { "type": "icon", - "name": "watch", + "icon": "watch", "className": "h-16 w-16 text-muted-foreground" } ] @@ -129,7 +129,7 @@ "children": [ { "type": "icon", - "name": "laptop", + "icon": "laptop", "className": "h-16 w-16 text-muted-foreground" } ] @@ -172,7 +172,7 @@ "children": [ { "type": "icon", - "name": "headphones", + "icon": "headphones", "className": "h-16 w-16 text-muted-foreground" } ] diff --git a/examples/schema-catalog/src/schemas/ecommerce/shopping-cart.json b/examples/schema-catalog/src/schemas/ecommerce/shopping-cart.json index 74519ea336..42bf4867b3 100644 --- a/examples/schema-catalog/src/schemas/ecommerce/shopping-cart.json +++ b/examples/schema-catalog/src/schemas/ecommerce/shopping-cart.json @@ -43,7 +43,7 @@ "children": [ { "type": "icon", - "name": "smartphone", + "icon": "smartphone", "className": "h-8 w-8 text-muted-foreground" } ] @@ -127,7 +127,7 @@ "children": [ { "type": "icon", - "name": "headphones", + "icon": "headphones", "className": "h-8 w-8 text-muted-foreground" } ] diff --git a/examples/schema-catalog/src/schemas/forms/newsletter-signup.json b/examples/schema-catalog/src/schemas/forms/newsletter-signup.json index 68b455cb20..8381eb0120 100644 --- a/examples/schema-catalog/src/schemas/forms/newsletter-signup.json +++ b/examples/schema-catalog/src/schemas/forms/newsletter-signup.json @@ -13,7 +13,7 @@ "children": [ { "type": "icon", - "name": "mail", + "icon": "mail", "className": "h-12 w-12 mx-auto text-primary" }, { diff --git a/examples/schema-catalog/src/schemas/marketing/call-to-action.json b/examples/schema-catalog/src/schemas/marketing/call-to-action.json index 9321aee1c1..627425e333 100644 --- a/examples/schema-catalog/src/schemas/marketing/call-to-action.json +++ b/examples/schema-catalog/src/schemas/marketing/call-to-action.json @@ -51,7 +51,7 @@ "children": [ { "type": "icon", - "name": "check-circle", + "icon": "check-circle", "className": "h-4 w-4" }, { @@ -67,7 +67,7 @@ "children": [ { "type": "icon", - "name": "check-circle", + "icon": "check-circle", "className": "h-4 w-4" }, { @@ -83,7 +83,7 @@ "children": [ { "type": "icon", - "name": "check-circle", + "icon": "check-circle", "className": "h-4 w-4" }, { diff --git a/examples/schema-catalog/src/schemas/marketing/features-grid.json b/examples/schema-catalog/src/schemas/marketing/features-grid.json index bce9afcc28..1180791645 100644 --- a/examples/schema-catalog/src/schemas/marketing/features-grid.json +++ b/examples/schema-catalog/src/schemas/marketing/features-grid.json @@ -43,7 +43,7 @@ "children": [ { "type": "icon", - "name": "zap", + "icon": "zap", "className": "h-6 w-6 text-primary" } ] @@ -78,7 +78,7 @@ "children": [ { "type": "icon", - "name": "shield", + "icon": "shield", "className": "h-6 w-6 text-primary" } ] @@ -113,7 +113,7 @@ "children": [ { "type": "icon", - "name": "smartphone", + "icon": "smartphone", "className": "h-6 w-6 text-primary" } ] @@ -148,7 +148,7 @@ "children": [ { "type": "icon", - "name": "palette", + "icon": "palette", "className": "h-6 w-6 text-primary" } ] @@ -183,7 +183,7 @@ "children": [ { "type": "icon", - "name": "settings", + "icon": "settings", "className": "h-6 w-6 text-primary" } ] @@ -218,7 +218,7 @@ "children": [ { "type": "icon", - "name": "headphones", + "icon": "headphones", "className": "h-6 w-6 text-primary" } ] diff --git a/examples/schema-catalog/src/schemas/marketing/pricing-table.json b/examples/schema-catalog/src/schemas/marketing/pricing-table.json index 073b611da5..0d717caa5e 100644 --- a/examples/schema-catalog/src/schemas/marketing/pricing-table.json +++ b/examples/schema-catalog/src/schemas/marketing/pricing-table.json @@ -86,7 +86,7 @@ "children": [ { "type": "icon", - "name": "check", + "icon": "check", "className": "h-4 w-4 text-primary mr-2" }, { @@ -102,7 +102,7 @@ "children": [ { "type": "icon", - "name": "check", + "icon": "check", "className": "h-4 w-4 text-primary mr-2" }, { @@ -118,7 +118,7 @@ "children": [ { "type": "icon", - "name": "check", + "icon": "check", "className": "h-4 w-4 text-primary mr-2" }, { @@ -206,7 +206,7 @@ "children": [ { "type": "icon", - "name": "check", + "icon": "check", "className": "h-4 w-4 text-primary mr-2" }, { @@ -222,7 +222,7 @@ "children": [ { "type": "icon", - "name": "check", + "icon": "check", "className": "h-4 w-4 text-primary mr-2" }, { @@ -238,7 +238,7 @@ "children": [ { "type": "icon", - "name": "check", + "icon": "check", "className": "h-4 w-4 text-primary mr-2" }, { @@ -254,7 +254,7 @@ "children": [ { "type": "icon", - "name": "check", + "icon": "check", "className": "h-4 w-4 text-primary mr-2" }, { @@ -329,7 +329,7 @@ "children": [ { "type": "icon", - "name": "check", + "icon": "check", "className": "h-4 w-4 text-primary mr-2" }, { @@ -345,7 +345,7 @@ "children": [ { "type": "icon", - "name": "check", + "icon": "check", "className": "h-4 w-4 text-primary mr-2" }, { @@ -361,7 +361,7 @@ "children": [ { "type": "icon", - "name": "check", + "icon": "check", "className": "h-4 w-4 text-primary mr-2" }, { @@ -377,7 +377,7 @@ "children": [ { "type": "icon", - "name": "check", + "icon": "check", "className": "h-4 w-4 text-primary mr-2" }, { diff --git a/examples/schema-catalog/src/schemas/marketing/testimonials.json b/examples/schema-catalog/src/schemas/marketing/testimonials.json index 8ea9d7240b..f108e8e64e 100644 --- a/examples/schema-catalog/src/schemas/marketing/testimonials.json +++ b/examples/schema-catalog/src/schemas/marketing/testimonials.json @@ -41,27 +41,27 @@ "children": [ { "type": "icon", - "name": "star", + "icon": "star", "className": "h-4 w-4 fill-yellow-500 text-yellow-500" }, { "type": "icon", - "name": "star", + "icon": "star", "className": "h-4 w-4 fill-yellow-500 text-yellow-500" }, { "type": "icon", - "name": "star", + "icon": "star", "className": "h-4 w-4 fill-yellow-500 text-yellow-500" }, { "type": "icon", - "name": "star", + "icon": "star", "className": "h-4 w-4 fill-yellow-500 text-yellow-500" }, { "type": "icon", - "name": "star", + "icon": "star", "className": "h-4 w-4 fill-yellow-500 text-yellow-500" } ] @@ -117,27 +117,27 @@ "children": [ { "type": "icon", - "name": "star", + "icon": "star", "className": "h-4 w-4 fill-yellow-500 text-yellow-500" }, { "type": "icon", - "name": "star", + "icon": "star", "className": "h-4 w-4 fill-yellow-500 text-yellow-500" }, { "type": "icon", - "name": "star", + "icon": "star", "className": "h-4 w-4 fill-yellow-500 text-yellow-500" }, { "type": "icon", - "name": "star", + "icon": "star", "className": "h-4 w-4 fill-yellow-500 text-yellow-500" }, { "type": "icon", - "name": "star", + "icon": "star", "className": "h-4 w-4 fill-yellow-500 text-yellow-500" } ] @@ -193,27 +193,27 @@ "children": [ { "type": "icon", - "name": "star", + "icon": "star", "className": "h-4 w-4 fill-yellow-500 text-yellow-500" }, { "type": "icon", - "name": "star", + "icon": "star", "className": "h-4 w-4 fill-yellow-500 text-yellow-500" }, { "type": "icon", - "name": "star", + "icon": "star", "className": "h-4 w-4 fill-yellow-500 text-yellow-500" }, { "type": "icon", - "name": "star", + "icon": "star", "className": "h-4 w-4 fill-yellow-500 text-yellow-500" }, { "type": "icon", - "name": "star", + "icon": "star", "className": "h-4 w-4 fill-yellow-500 text-yellow-500" } ] diff --git a/examples/schema-catalog/src/schemas/report/report-scheduling.json b/examples/schema-catalog/src/schemas/report/report-scheduling.json index 034fa5199c..7afd55a4f0 100644 --- a/examples/schema-catalog/src/schemas/report/report-scheduling.json +++ b/examples/schema-catalog/src/schemas/report/report-scheduling.json @@ -9,7 +9,7 @@ "children": [ { "type": "icon", - "name": "clock", + "icon": "clock", "className": "h-5 w-5 text-primary" }, { diff --git a/packages/components/src/__tests__/basic-renderers.test.tsx b/packages/components/src/__tests__/basic-renderers.test.tsx index d1a9b18fc4..42b7fb6fff 100644 --- a/packages/components/src/__tests__/basic-renderers.test.tsx +++ b/packages/components/src/__tests__/basic-renderers.test.tsx @@ -183,26 +183,34 @@ describe('Basic Renderers - Display Issue Detection', () => { expect(validation.hasConfig).toBe(true); }); + // objectui#5631: the glyph key is `icon`. These two cases authored `name` + // and asserted only that SOME `svg` rendered — an assertion that cannot + // fail here any more, because the UNRESOLVED placeholder is an `svg` too. + // So the key is renamed AND the assertion is given teeth: the absence of + // the placeholder marker is what separates "resolved the requested glyph" + // from "drew the dashed box that means it did not". it('should render icon without issues', () => { const { container } = renderComponent({ type: 'icon', - name: 'star', + icon: 'star', }); // Icon should render an SVG const svg = container.querySelector('svg'); expect(svg).toBeTruthy(); + expect(container.querySelector('[data-objectui-icon-unresolved]')).toBeNull(); }); it('should apply size classes correctly', () => { const { container } = renderComponent({ type: 'icon', - name: 'heart', + icon: 'heart', size: 24, }); const svg = container.querySelector('svg'); expect(svg).toBeTruthy(); + expect(container.querySelector('[data-objectui-icon-unresolved]')).toBeNull(); }); }); diff --git a/packages/components/src/renderers/basic/__tests__/icon-unresolvable-placeholder.test.tsx b/packages/components/src/renderers/basic/__tests__/icon-unresolvable-placeholder.test.tsx index 26a92531e7..eb19c7b581 100644 --- a/packages/components/src/renderers/basic/__tests__/icon-unresolvable-placeholder.test.tsx +++ b/packages/components/src/renderers/basic/__tests__/icon-unresolvable-placeholder.test.tsx @@ -10,13 +10,18 @@ * "an unresolvable icon renders a visible placeholder instead of `null`, * **regardless** of the key question"). * - * ## What this file pins, and what it deliberately does not + * ## What this file pins * - * It pins the END OF THE SILENCE — item 3 of the ruling, which is unconditional - * and independent of which schema key names the glyph. It does NOT pin - * `schema.icon` as the glyph key (item 1): that migration is blocked on a - * measured corpus, and the PR body carries the reading. So every case below - * still authors `name`, exactly as the renderer still reads it. + * Two things now, where it pinned one. The END OF THE SILENCE — item 3 of the + * ruling, unconditional and independent of which schema key names the glyph — + * and, since the 2026-08-24 ruling 「5631 A′,按一次正经的契约迁移立项。」, + * that the glyph key IS `icon`: every case below authors `icon`, and the legacy + * `name` spelling has its own describe block asserting it does NOT resolve. + * + * That second half is the reason the cases were not simply spelling-swapped. + * Several of them asserted "an `svg` rendered", which the placeholder satisfies + * too — an assertion that survives the migration by being unable to fail. Each + * one below now distinguishes the two outcomes by the placeholder marker. * * ## Why the warning is asserted through an explicit spy * @@ -62,7 +67,7 @@ const MARKER = '[data-objectui-icon-unresolved]'; describe('ui:icon — unresolvable glyph', () => { it('renders a visible SVG placeholder instead of nothing', () => { - const { container } = renderIcon({ name: 'definitely-not-a-lucide-icon' }); + const { container } = renderIcon({ icon: 'definitely-not-a-lucide-icon' }); const placeholder = container.querySelector(MARKER); expect(placeholder).not.toBeNull(); @@ -75,7 +80,7 @@ describe('ui:icon — unresolvable glyph', () => { }); it('names the unresolved icon in its accessible name', () => { - renderIcon({ name: 'definitely-not-a-lucide-icon' }); + renderIcon({ icon: 'definitely-not-a-lucide-icon' }); // `role="img"` + `aria-label`: the placeholder is perceivable, and it says // WHICH icon failed rather than being an anonymous box. @@ -84,8 +89,8 @@ describe('ui:icon — unresolvable glyph', () => { ).toBeTruthy(); }); - it('warns, naming the identity-key collision that is objectui#5631', () => { - const { warn } = renderIcon({ name: 'save_icon' }); + it('warns, naming the glyph that did not resolve', () => { + const { warn } = renderIcon({ icon: 'definitely-not-a-lucide-icon' }); // Not a pinned call COUNT: React invokes the render function more than // once here (measured: two calls for one `render`), and pinning the number @@ -94,17 +99,13 @@ describe('ui:icon — unresolvable glyph', () => { // it says. expect(warn).toHaveBeenCalled(); const message = String(warn.mock.calls[0]?.[0]); - expect(message).toContain('save_icon'); + expect(message).toContain('definitely-not-a-lucide-icon'); expect(message).toContain('objectui#5631'); - // The warning must say why an ordinary authored identity lands here — the - // reader of this warning is an author who wrote `name: 'save_icon'` and is - // looking at a placeholder. - expect(message).toContain('identity key'); }); it('keeps the authored box: className and size still reach the placeholder', () => { const { container } = renderIcon({ - name: 'definitely-not-a-lucide-icon', + icon: 'definitely-not-a-lucide-icon', className: 'text-red-500', size: 48, }); @@ -117,12 +118,16 @@ describe('ui:icon — unresolvable glyph', () => { expect(placeholder?.getAttribute('style')).toContain('48px'); }); - it('renders the placeholder — not a thrown error — when `name` is absent entirely', () => { + it('renders the placeholder — not a thrown error — when `icon` is absent entirely', () => { // Pre-fix this reached `toPascalCase(undefined)` and threw on // `undefined.split`, which the SchemaErrorBoundary then swallowed: a THIRD - // way for this renderer to fail without saying so. `name` is typed + // way for this renderer to fail without saying so. `icon` is typed // `string` on `IconSchema`, but it arrives from authored JSON. - const { container, warn } = renderIcon({ id: 'no_name_node' }); + // + // This node carries NO glyph key of either spelling — no `icon`, and no + // `name` either — so it is the plain absent case, kept distinct from the + // legacy-spelling case in the describe block below. + const { container, warn } = renderIcon({ id: 'no_glyph_key_node' }); const placeholder = container.querySelector(MARKER); expect(placeholder).not.toBeNull(); @@ -133,7 +138,7 @@ describe('ui:icon — unresolvable glyph', () => { describe('ui:icon — resolvable glyph is untouched by objectui#5631', () => { it('renders the real lucide glyph with no placeholder and no warning', () => { - const { container, warn } = renderIcon({ name: 'check', className: 'text-green-500' }); + const { container, warn } = renderIcon({ icon: 'check', className: 'text-green-500' }); expect(container.querySelector(MARKER)).toBeNull(); const svg = container.querySelector('svg'); @@ -145,9 +150,81 @@ describe('ui:icon — resolvable glyph is untouched by objectui#5631', () => { it('still resolves the kebab-case and renamed-icon paths', () => { // `home` -> `Home` -> mapped to `House`: the `iconNameMap` hop, which the // placeholder branch must not have short-circuited. - const { container, warn } = renderIcon({ name: 'home' }); + const { container, warn } = renderIcon({ icon: 'home' }); + + expect(container.querySelector(MARKER)).toBeNull(); + expect(container.querySelector('svg')).not.toBeNull(); + expect(warn).not.toHaveBeenCalled(); + }); +}); + +describe('the LEGACY `name`-as-glyph spelling does not resolve (objectui#5631)', () => { + /** + * The shape this migration retired: `name` naming the glyph. These cases are + * the load-bearing half of the ruling — 「⛔ no tolerant `icon ?? name` + * fallback」 is only true if a node authoring `name` demonstrably fails to + * resolve, so it is asserted here rather than assumed from reading the + * renderer. + * + * `name: 'check'` is chosen deliberately over a nonsense string: `check` IS a + * real lucide glyph, so a tolerant fallback would resolve it and this case + * would go green. A nonsense name could not tell a fallback apart from a + * miss, and would pass either way. + */ + it('renders the placeholder even when `name` holds a REAL lucide glyph name', () => { + const { container } = renderIcon({ name: 'check' }); + + const placeholder = container.querySelector(MARKER); + expect(placeholder).not.toBeNull(); + // `(none)` — no glyph was requested at all, because `name` is not a glyph + // key. If a fallback existed this would read `check` and resolve. + expect(placeholder?.getAttribute('data-objectui-icon-unresolved')).toBe('(none)'); + }); + + it('marks the legacy node so the migration is visible to a gate, not just a human', () => { + const { container } = renderIcon({ name: 'check' }); + + // Distinguishes "authored a glyph name that does not resolve" from "has + // not been migrated yet" — two different repairs. + expect( + container.querySelector('[data-objectui-icon-legacy-name-key]')?.getAttribute( + 'data-objectui-icon-legacy-name-key', + ), + ).toBe('check'); + }); + + it('warns with the RENAME, not just "no glyph resolves"', () => { + const { warn } = renderIcon({ name: 'save_icon' }); + + expect(warn).toHaveBeenCalled(); + const message = String(warn.mock.calls[0]?.[0]); + // The reader of this warning is an author whose node used to work. Telling + // them only that nothing resolved would make a mechanical rename look like + // a missing icon. + expect(message).toContain('save_icon'); + expect(message).toContain('identity key'); + expect(message).toContain('icon: "save_icon"'); + expect(message).toContain('migrateIconNodeKeys'); + expect(message).toContain('objectui#5631'); + }); + + it('says so in the accessible name too', () => { + renderIcon({ name: 'save_icon' }); + + expect( + screen.getByRole('img', { + name: 'Unresolved icon: `name` is no longer the icon key, rename it to `icon` (save_icon)', + }), + ).toBeTruthy(); + }); + + it('a node with BOTH keys resolves from `icon` and is not marked legacy', () => { + // `name` is a perfectly legitimate identity alongside `icon`. It must not + // drag a working node into the legacy branch. + const { container, warn } = renderIcon({ icon: 'check', name: 'save_icon' }); expect(container.querySelector(MARKER)).toBeNull(); + expect(container.querySelector('[data-objectui-icon-legacy-name-key]')).toBeNull(); expect(container.querySelector('svg')).not.toBeNull(); expect(warn).not.toHaveBeenCalled(); }); diff --git a/packages/components/src/renderers/basic/icon.tsx b/packages/components/src/renderers/basic/icon.tsx index 1aeb518591..0ccff66923 100644 --- a/packages/components/src/renderers/basic/icon.tsx +++ b/packages/components/src/renderers/basic/icon.tsx @@ -92,16 +92,31 @@ const IconRenderer = forwardRef 0 + ? schema.name + : ''; // Convert icon name to PascalCase for Lucide lookup const iconName = toPascalCase(requested); // Apply icon name mapping for renamed icons @@ -112,9 +127,18 @@ const IconRenderer = forwardRef "${mappedIconName}"` : ''})` : ''}. ` + - `Rendering a visible placeholder instead of nothing (objectui#5631). ` + - `Note: this renderer reads the SDUI identity key \`name\` as its glyph name, ` + - `so an ordinary authored identity such as "save_icon" lands here.` + `Rendering a visible placeholder instead of nothing (objectui#5631).` + + // The migration half. An author looking at a placeholder on a node + // that used to work needs to be told the key moved — saying only + // "no glyph resolves" would make a mechanical rename look like a + // missing icon. Named separately from the generic case so it cannot + // be mistaken for one. + (!requested && legacyGlyphName + ? ` This node names its glyph with the SDUI identity key \`name\`` + + ` ("${legacyGlyphName}"), which is no longer read as a glyph name.` + + ` Rename it: \`icon: "${legacyGlyphName}"\`. Stored metadata converts in` + + ` bulk with \`migrateIconNodeKeys\` from \`@object-ui/types\`.` + : '') ); // Same host element and the same authored box as a resolved icon, so the @@ -126,7 +150,13 @@ const IconRenderer = forwardRef ); @@ -177,17 +213,19 @@ ComponentRegistry.register('icon', icon: 'face-slightly-smiling', category: 'basic', inputs: [ - // ⚠️ objectui#5631 — this entry STILL declares `name`, and that is - // deliberate in this change rather than an oversight. + // objectui#5631 — this entry declares `icon`, and it moved in the SAME + // change as the resolver above, by construction. + // + // It advertised `name` up to PR #5959, deliberately: renaming it while + // the resolver still read `name` would have been this card's own defect + // pointing the other way — a declared input list naming a key nothing + // reads. That note retires here, under the 2026-08-24 ruling + // 「5631 A′,按一次正经的契约迁移立项。」, because the contract, the + // resolver, the corpus and this list all move together. // - // The 2026-08-22 ruling is that `icon` is the glyph key and `name` is - // identity always, which makes this entry owed a rename to `icon`. It is - // NOT renamed here because the resolver above still reads `name`, and a - // declared input list that advertises a key the resolver does not read is - // the same defect this card is about, pointing the other way. The two - // must move together, with the corpus migration — see the PR body's sweep - // reading for the measured population that blocks it. - { name: 'name', type: 'string', label: 'Icon Name', defaultValue: 'face-slightly-smiling' }, + // The `name:` on the left is the INPUT DESCRIPTOR's own key — which + // schema property this input edits. Its value is what changed. + { name: 'icon', type: 'string', label: 'Icon Name', defaultValue: 'face-slightly-smiling' }, { name: 'size', type: 'number', label: 'Size (px)' }, { name: 'color', type: 'string', label: 'Color Class' }, { name: 'className', type: 'string', label: 'CSS Class' } diff --git a/packages/types/src/__tests__/icon-key-migration.test.ts b/packages/types/src/__tests__/icon-key-migration.test.ts new file mode 100644 index 0000000000..73f96fe53e --- /dev/null +++ b/packages/types/src/__tests__/icon-key-migration.test.ts @@ -0,0 +1,185 @@ +/** + * 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. + * + * `ui:icon`'s glyph key is `icon` — the contract half (objectui#5631). + * + * Two things are pinned here, and they are different in kind: + * + * 1. **The published mirror accepts the ruled shape and refuses the legacy + * one.** This is the half that blocked the 2026-08-22 ruling from landing + * at the renderer: `IconSchema` REQUIRED `name`, so an author writing the + * ruled `{ type:'icon', icon:'check' }` was refused by the very contract + * that was supposed to declare it. The measurement is asserted rather than + * described, in both directions, so a silent revert to `name` fails here. + * 2. **The conversion for stored metadata does what it claims**, including + * the two cases it deliberately refuses to guess at. + */ + +import { describe, it, expect } from 'vitest'; + +import { IconSchema } from '../zod/layout.zod.js'; +import { migrateIconNodeKeys } from '../icon-key-migration.js'; + +/** The `code`/`path` of each issue, which is what the shape question is about. */ +function issues(result: ReturnType) { + return result.success + ? [] + : result.error.issues.map((issue) => ({ + code: issue.code, + path: issue.path.join('.'), + message: issue.message, + })); +} + +describe('IconSchema — `icon` is the glyph key (objectui#5631)', () => { + it('ACCEPTS the ruled shape', () => { + expect(IconSchema.safeParse({ type: 'icon', icon: 'check' }).success).toBe(true); + }); + + it('ACCEPTS `name` alongside `icon` — it is the identity key, not a rival', () => { + // The whole point of the ruling: `name` stops being special-cased on this + // node and goes back to being the identity every node may carry. + const parsed = IconSchema.safeParse({ type: 'icon', id: 'save_icon', icon: 'save', name: 'save_icon' }); + expect(parsed.success).toBe(true); + }); + + it('REFUSES the legacy `name`-as-glyph shape, at `icon`', () => { + const result = IconSchema.safeParse({ type: 'icon', name: 'check' }); + expect(result.success).toBe(false); + // Refused for the ABSENCE OF `icon` — not accepted-and-warned, and not + // refused somewhere unrelated. ⛔ No `icon ?? name` read exists. + expect(issues(result).map((i) => i.path)).toEqual(['icon']); + }); + + it('says WHAT TO DO in the refusal, not just `expected string`', () => { + const [issue] = issues(IconSchema.safeParse({ type: 'icon', name: 'check' })); + // Zod's default here is `Invalid input: expected string, received + // undefined`, which is true and useless to an author whose stored metadata + // just stopped validating. + expect(issue?.message).toContain('`icon`'); + expect(issue?.message).toContain('`name`'); + expect(issue?.message).toContain('objectui#5631'); + expect(issue?.message).toContain('migrateIconNodeKeys'); + }); + + it('leaves a genuine type error with zod’s own precise message', () => { + // The custom message is scoped to the ABSENT case. A node writing + // `icon: 42` has not failed to migrate — it has a type error, and telling + // it about a rename would be a wrong diagnosis. + const [issue] = issues(IconSchema.safeParse({ type: 'icon', icon: 42 })); + expect(issue?.path).toBe('icon'); + expect(issue?.message).toContain('expected string'); + expect(issue?.message).not.toContain('objectui#5631'); + }); + + it('is REQUIRED, exactly as `name` was — this is a rename, not a loosening', () => { + expect(IconSchema.safeParse({ type: 'icon' }).success).toBe(false); + }); +}); + +describe('migrateIconNodeKeys — the conversion for stored metadata', () => { + it('lifts `name` to `icon` and removes the old key', () => { + const { document, converted, warnings } = migrateIconNodeKeys({ type: 'icon', name: 'check' }); + + expect(document).toEqual({ type: 'icon', icon: 'check' }); + expect(converted).toBe(1); + expect(warnings).toEqual([]); + // Removed, not left beside `icon`: leaving it preserves the exact + // ambiguity the migration exists to end. + expect('name' in (document as object)).toBe(false); + }); + + it('produces documents the migrated contract ACCEPTS', () => { + // The conversion and the contract are two halves of one change; this is + // the assertion that they actually meet. + const before = { type: 'icon', name: 'check' }; + expect(IconSchema.safeParse(before).success).toBe(false); + expect(IconSchema.safeParse(migrateIconNodeKeys(before).document).success).toBe(true); + }); + + it('walks arbitrarily nested documents, not just a `children` chain', () => { + const { document, converted } = migrateIconNodeKeys({ + type: 'page', + regions: { + header: { toolbar: [{ type: 'ui:icon', name: 'save' }] }, + main: [{ type: 'card', children: [{ type: 'icon', name: 'star', size: 16 }] }], + }, + }); + + expect(converted).toBe(2); + expect(document).toEqual({ + type: 'page', + regions: { + header: { toolbar: [{ type: 'ui:icon', icon: 'save' }] }, + main: [{ type: 'card', children: [{ type: 'icon', icon: 'star', size: 16 }] }], + }, + }); + }); + + it('returns the SAME reference when nothing changed, so a caller can skip a write', () => { + const already = { type: 'icon', icon: 'check' }; + const result = migrateIconNodeKeys(already); + expect(result.document).toBe(already); + expect(result.converted).toBe(0); + }); + + it('REFUSES to guess when a node declares both keys, and says so', () => { + const both = { type: 'icon', id: 'save_icon', icon: 'save', name: 'save_icon' }; + const { document, converted, warnings } = migrateIconNodeKeys(both); + + // Overwriting `icon` from `name` here would replace a working glyph with + // an identity string — the silent data loss the report exists to prevent. + expect(document).toBe(both); + expect(converted).toBe(0); + expect(warnings).toHaveLength(1); + expect(warnings[0]?.reason).toBe('both-keys'); + expect(warnings[0]?.id).toBe('save_icon'); + }); + + it('REFUSES to guess when a node names no glyph at all, and says so', () => { + const { converted, warnings } = migrateIconNodeKeys({ + type: 'card', + children: [{ type: 'icon', id: 'empty_icon' }], + }); + + expect(converted).toBe(0); + expect(warnings).toHaveLength(1); + expect(warnings[0]?.reason).toBe('no-glyph-key'); + // The path is how a caller finds the node in a document of any size. + expect(warnings[0]?.path).toBe('children.0'); + }); + + it('reports every unconvertible node rather than stopping at the first', () => { + const { converted, warnings } = migrateIconNodeKeys({ + children: [ + { type: 'icon', name: 'check' }, + { type: 'icon', icon: 'save', name: 'save_icon' }, + { type: 'icon' }, + ], + }); + + expect(converted).toBe(1); + expect(warnings.map((w) => w.reason)).toEqual(['both-keys', 'no-glyph-key']); + }); + + it('leaves non-icon nodes alone, including their `name`', () => { + const form = { type: 'form', children: [{ type: 'input', name: 'email' }] }; + const { document, converted } = migrateIconNodeKeys(form); + + // `name` on a field IS the field name. A converter that swept every `name` + // would corrupt every form in storage. + expect(document).toBe(form); + expect(converted).toBe(0); + }); + + it('treats an empty-string `name` as no glyph, not as a glyph called ""', () => { + const { converted, warnings } = migrateIconNodeKeys({ type: 'icon', name: '' }); + + expect(converted).toBe(0); + expect(warnings[0]?.reason).toBe('no-glyph-key'); + }); +}); diff --git a/packages/types/src/icon-key-migration.ts b/packages/types/src/icon-key-migration.ts new file mode 100644 index 0000000000..db67fa098f --- /dev/null +++ b/packages/types/src/icon-key-migration.ts @@ -0,0 +1,194 @@ +/** + * 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. + */ + +/** + * `@object-ui/types` - `ui:icon` glyph-key conversion (objectui#5631) + * + * The one-shot conversion for STORED metadata written before `ui:icon`'s glyph + * key moved from `name` to `icon`. + * + * Deliberately ZOD-FREE and deliberately not in `zod/layout.zod.ts` next to the + * schema it serves, for the reason `dashboard-filter-alias.ts` states for the + * same choice: `@object-ui/types`' main entry re-exports runtime helpers, and + * pulling one from a `*.zod.ts` module would drag the whole zod graph into + * every consumer of that entry. + * + * ## ⛔ This is a CONVERSION, not a fallback — the distinction is the ruling + * + * The maintainer ruled out `schema.icon ?? schema.name` by name (2026-08-22, + * restated 2026-08-24). Nothing here is wired into a read path: no renderer + * calls it, no parse calls it, and it is not re-exported from `/zod`. It is a + * function a deployer runs ONCE over stored documents, whose result is written + * back to storage. That is what makes it a migration instead of a second + * de-facto contract — a tolerant read would leave `name` meaning two different + * things forever, depending on whether a lucide lookup happened to hit. + * + * ## Why a conversion exists at all + * + * The in-repo corpus was measured at 98 authored icon nodes, 100% naming the + * glyph with `name`, zero already using `icon` — the whole population, not a + * legacy tail. Those 98 are converted in the same change as the contract. + * Deployed tenant metadata is UNMEASURED, and the prior is high reliance for + * exactly the same reason. The ruling requires that break be loud and carry a + * conversion path rather than land silently, so stored nodes get three things: + * + * 1. a REFUSAL from `IconSchema` naming the rename (see `zod/layout.zod.ts`); + * 2. a VISIBLE placeholder plus a warning if an unvalidated node reaches the + * renderer anyway (PR #5959's placeholder, unchanged by this migration); + * 3. this converter, so the fix is mechanical rather than hand-editing. + * + * ## What it refuses to convert, and why that is reported rather than guessed + * + * A node carrying BOTH keys is left untouched and reported. `icon` is already + * authoritative there, and `name` is a legitimate identity — there is nothing + * to lift, and overwriting `icon` from `name` could replace a working glyph + * with an identity string. A node carrying NEITHER is left untouched and + * reported too: it has no glyph name anywhere to move, so it is a node the + * contract will refuse on its own merits, not one this converter can repair. + * Both surface in {@link IconKeyMigrationResult.warnings} so a caller can act + * on them instead of discovering later that a "successful" migration skipped + * rows in silence. + * + * @module icon-key-migration + * @packageDocumentation + */ + +/** One node this converter declined to convert, and the reason. */ +export interface IconKeyMigrationWarning { + /** + * Why the node was left alone. + * + * - `both-keys` — the node already has `icon`; `name` is being kept as the + * identity it is. Nothing to do, reported so it is not mistaken for a miss. + * - `no-glyph-key` — neither `icon` nor a string `name`, so there is no + * glyph name to move. The contract refuses this node; a human decides what + * it should have said. + */ + reason: 'both-keys' | 'no-glyph-key'; + /** JSON path to the node within the document handed in, e.g. `children.2.children.0`. */ + path: string; + /** The node's `id`, when it declares one — usually the fastest way to find it. */ + id?: string; + /** Human-readable, already carrying the path and the reason. */ + message: string; +} + +/** What {@link migrateIconNodeKeys} did, and what it refused to do. */ +export interface IconKeyMigrationResult { + /** + * The converted document. A NEW object when anything changed; the SAME + * reference when nothing did, so callers can use identity to skip a write. + */ + document: T; + /** How many `ui:icon` nodes had `name` lifted to `icon`. */ + converted: number; + /** Nodes deliberately left alone. Empty means every icon node was mechanical. */ + warnings: IconKeyMigrationWarning[]; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Is this an icon node? Accepts both the bare registry key and the namespaced + * spelling, because authored documents carry both (`type: 'icon'` in the + * schema catalog, `ui:icon` where the namespace is written out). + */ +function isIconNode(value: unknown): value is Record { + if (!isPlainObject(value)) return false; + return value.type === 'icon' || value.type === 'ui:icon'; +} + +/** + * Convert stored SDUI metadata from `ui:icon`'s legacy `name`-as-glyph spelling + * to the ruled `icon` key (objectui#5631). + * + * Walks the whole document — every array element and every object value, not + * just a `children` chain, because icon nodes are authored inside slots, + * toolbars, column definitions and block variables as well. Non-icon nodes are + * copied structurally and otherwise untouched. + * + * On a converted node `name` is REMOVED rather than left in place beside + * `icon`: leaving it would preserve the exact ambiguity this migration exists + * to end. If the value was doing double duty as a real identity, `id` is where + * that belongs, and the conversion is reported so the caller can look. + * + * @example + * ```ts + * const { document, converted, warnings } = migrateIconNodeKeys(storedPage); + * if (warnings.length) console.warn(warnings.map(w => w.message).join('\n')); + * if (document !== storedPage) await save(document); + * ``` + */ +export function migrateIconNodeKeys(document: T): IconKeyMigrationResult { + const warnings: IconKeyMigrationWarning[] = []; + let converted = 0; + + function walk(value: unknown, path: string): unknown { + if (Array.isArray(value)) { + let changed = false; + const next = value.map((entry, index) => { + const result = walk(entry, path ? `${path}.${index}` : String(index)); + if (result !== entry) changed = true; + return result; + }); + return changed ? next : value; + } + + if (!isPlainObject(value)) return value; + + // Recurse first, so a node's own conversion below sees already-converted + // descendants and the returned document is new only where it had to be. + let changed = false; + const next: Record = {}; + for (const [key, entry] of Object.entries(value)) { + const result = walk(entry, path ? `${path}.${key}` : key); + if (result !== entry) changed = true; + next[key] = result; + } + + if (isIconNode(value)) { + const hasIcon = next.icon !== undefined; + const hasGlyphName = typeof next.name === 'string' && next.name.length > 0; + const id = typeof next.id === 'string' ? next.id : undefined; + const where = `${path || '(root)'}${id ? ` (id: ${id})` : ''}`; + + if (hasIcon && typeof next.name === 'string') { + warnings.push({ + reason: 'both-keys', + path: path || '(root)', + id, + message: + `ui:icon at ${where} declares BOTH \`icon\` and \`name\`. Left unchanged: ` + + '`icon` is already the glyph key and `name` is a valid identity. ' + + 'Nothing was overwritten (objectui#5631).', + }); + } else if (!hasIcon && !hasGlyphName) { + warnings.push({ + reason: 'no-glyph-key', + path: path || '(root)', + id, + message: + `ui:icon at ${where} names no glyph — neither \`icon\` nor a non-empty ` + + '`name`. Left unchanged: there is nothing to convert. The node is refused ' + + 'by `IconSchema` and renders a visible placeholder (objectui#5631).', + }); + } else if (!hasIcon && hasGlyphName) { + next.icon = next.name; + delete next.name; + converted++; + changed = true; + } + } + + return changed ? next : value; + } + + return { document: walk(document, '') as T, converted, warnings }; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index c3256c98d1..543112d5c7 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -78,6 +78,16 @@ export { liftLegacyDashboardFilterDefaults, } from './dashboard-filter-alias.js'; +// `ui:icon` glyph-key conversion (objectui#5631) — stored `{ type:'icon', +// name:'check' }` -> `{ type:'icon', icon:'check' }`. A one-shot converter a +// deployer runs over stored metadata; ⛔ NOT a read-path fallback, which the +// ruling excluded by name. Zod-free, so it belongs on the main entry. +export { migrateIconNodeKeys } from './icon-key-migration.js'; +export type { + IconKeyMigrationResult, + IconKeyMigrationWarning, +} from './icon-key-migration.js'; + // ============================================================================ // Base Types - The Foundation // ============================================================================ diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts index d626b83a07..fb017e81e4 100644 --- a/packages/types/src/layout.ts +++ b/packages/types/src/layout.ts @@ -98,9 +98,40 @@ export interface ImageSchema extends BaseSchema { export interface IconSchema extends BaseSchema { type: 'icon'; /** - * Icon name (lucide-react icon name) - */ - name: string; + * The lucide-react glyph to render, kebab-case (`check`, `arrow-right`). + * + * ## Why this key is `icon` and not `name` (objectui#5631) + * + * It used to be `name`, and `name` is not this node's private prop — it is + * the SDUI IDENTITY key every authored node carries from + * {@link BaseSchema.name}, alongside `id`. So an ordinary authored node like + * `{ type: 'icon', id: 'save_icon', name: 'save_icon' }` asked lucide for a + * glyph called `SaveIcon`, missed, and rendered NOTHING — silent at runtime + * and clean-looking to a DOM gate, because a renderer that renders nothing + * spreads no attributes to find. + * + * The maintainer ruled the contract question twice: 2026-08-22 (option A — + * "`icon` is the icon key; `name` is identity, always") and again 2026-08-24 + * ("5631 A′,按一次正经的契约迁移立项。") at the full measured price, + * once it was established that the renderer alone could not carry it: the + * published mirror REQUIRED `name`, so the ruled shape was refused by the + * contract while the renderer read a key the contract never declared. + * + * `action:*` already reads `icon`, so this is the vocabulary's existing + * answer rather than a new one — and it leaves no node type on which the + * identity key is unavailable. + * + * ⚠️ REQUIRED, exactly as `name` was required before it. A stored node that + * still names its glyph with `name` is REFUSED by the zod mirror with a + * message that names the migration, and renders the visible placeholder from + * PR #5959 if it reaches the renderer unvalidated. Both are loud on purpose; + * ⛔ there is no tolerant `icon ?? name` read anywhere — that shape was ruled + * out explicitly, and it would make `name` mean two things depending on + * whether a lookup happened to hit. To convert stored metadata, see + * `migrateIconNodeKeys` in `./icon-key-migration.ts` — an explicit one-shot + * conversion, never a read-path fallback. + */ + icon: string; /** * Icon size in pixels * @default 24 diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts index 0d17906c6f..232d2c070f 100644 --- a/packages/types/src/zod/layout.zod.ts +++ b/packages/types/src/zod/layout.zod.ts @@ -68,10 +68,67 @@ export const ImageSchema = BaseSchema.extend({ /** * Icon Schema - Icon component (Lucide icons) + * + * ## `icon`, not `name` — and why the rejection message carries a migration + * + * The glyph key on this node is `icon` (objectui#5631, maintainer rulings + * 2026-08-22 option A and 2026-08-24 「5631 A′,按一次正经的契约迁移立项。」). + * `name` reverts to the SDUI identity key it always was, inherited optional + * from {@link BaseSchema}. The declaration in `../layout.ts` carries the full + * reasoning; this mirror carries the enforcement. + * + * This mirror is the half that had to move for the ruling to be landable at + * all. It previously declared `name: z.string()` REQUIRED, which measured as: + * + * ```text + * REJECT { type:'icon', icon:'check' } -> invalid_type at [name] + * ACCEPT { type:'icon', name:'check' } + * ``` + * + * i.e. the published contract refused the ruled shape and required the broken + * one — contract-first exactly backwards, and the reason the renderer could + * not be migrated on its own. + * + * `icon` is REQUIRED here, exactly as `name` was: this is a key rename at + * constant strictness, not a loosening. Keeping the same requiredness is also + * what keeps the `__tests__/zod-mirror-parity.test.ts` ledger silent — an + * optional mirror key against a required declaration is drift that guard + * measures and would demand a `KnownDrift` entry for. + * + * ## The rejection message IS the conversion story's first half + * + * A stored node authored before this migration reaches here as + * `{ type:'icon', name:'check' }` and is refused. Zod's default message for + * that is `invalid_type at [icon]: expected string, received undefined`, which + * is true and tells the author nothing about what happened to their metadata. + * The custom `error` below replaces it, for the ABSENT case only, with the + * rename and where to convert in bulk. Deliberate mechanics: + * + * - it fires only when `icon` is `undefined`, so a genuine type error + * (`icon: 42`) still gets zod's own precise message — returning `undefined` + * from the callback falls back to the default; + * - it is a MESSAGE, not an accept. ⛔ There is no `icon ?? name` read here + * or in the renderer; the legacy shape is refused, loudly, by design. That + * tolerant shape was ruled out by name on 2026-08-22 and the ruling of + * 2026-08-24 restates it; + * - it lives on the FIELD rather than in an object-level `.check()`, because + * zod 4 skips object-level checks once a field issue exists — an + * object-level diagnostic for a missing key would never run. Measured. */ export const IconSchema = BaseSchema.extend({ type: z.literal('icon'), - name: z.string().describe('Icon name (lucide-react)'), + icon: z + .string({ + error: (issue) => + issue.input === undefined + ? "ui:icon names its glyph with `icon` (e.g. `icon: 'check'`). If this node still " + + 'names it with `name`, that key moved: `name` is the SDUI identity key on every ' + + 'node and is no longer read as a glyph name (objectui#5631). Rename `name` to ' + + '`icon`, or convert stored metadata in bulk with `migrateIconNodeKeys` from ' + + '`@object-ui/types`.' + : undefined, + }) + .describe('Lucide glyph name, kebab-case (objectui#5631: was `name`)'), size: z.number().optional().default(24).describe('Icon size in pixels'), color: z.string().optional().describe('Icon color'), }); From f4bd018996d20679672f77ab76051dccd763190f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 12:28:23 +0000 Subject: [PATCH 2/3] test(components): follow the `ui:icon` glyph-input rename in the #5622 default pin `icon-renderer-declared-default.test.ts` selects the registry input that drives a dropped component's default by key. That key moved from `name` to `icon`, so the selector came back `undefined` and the pin lost its subject. Only the selector and the prose naming it moved; the file's subject is still objectui#5622's retired-spelling question, and its controls are untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L --- .../icon-renderer-declared-default.test.ts | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/components/src/__tests__/icon-renderer-declared-default.test.ts b/packages/components/src/__tests__/icon-renderer-declared-default.test.ts index e16dd435f9..ef727907ea 100644 --- a/packages/components/src/__tests__/icon-renderer-declared-default.test.ts +++ b/packages/components/src/__tests__/icon-renderer-declared-default.test.ts @@ -14,11 +14,15 @@ import '../renderers/basic/icon'; // --------------------------------------------------------------------------- // objectui#5622 — the `icon` renderer's OWN declared default has to resolve. // -// Two metadata spots feed the designer: the registration's `icon` (the glyph on -// the palette entry) and the `name` input's `defaultValue` (what an `icon` -// dropped from that palette renders before anyone types a name). Both are -// looked up in lucide's runtime `icons` record by `IconRenderer`, which -// `console.warn`s and returns `null` on a miss. +// Two metadata spots feed the designer: the registration's `icon` meta (the +// glyph on the palette entry) and the glyph INPUT's `defaultValue` (what an +// `icon` dropped from that palette renders before anyone types a name). Both +// are looked up in lucide's runtime `icons` record by `IconRenderer`, which +// `console.warn`s and draws a visible placeholder on a miss. +// +// ⚠️ That input is keyed `icon` since objectui#5631 — it was `name` until the +// glyph key migrated off the SDUI identity key. Only the SELECTOR below moved; +// this file's subject is still objectui#5622's retired-spelling question. // // lucide retires a spelling by dropping it from that record while keeping it as // a deprecated named export, and `smile` was retired — so the palette entry's @@ -62,12 +66,12 @@ const recordKeyFor = (name: string): string => { }; const meta = ComponentRegistry.getMeta('icon', 'ui'); -const nameInput = meta?.inputs?.find(input => input.name === 'name'); +const glyphInput = meta?.inputs?.find(input => input.name === 'icon'); /** Both declared spellings, each labelled by the surface it drives. */ const DECLARED_DEFAULTS: Array<[string, string | undefined]> = [ ['registration `icon` (the palette entry glyph)', meta?.icon], - ['`name` input `defaultValue` (what a dropped `icon` renders)', nameInput?.defaultValue as string | undefined], + ['`icon` input `defaultValue` (what a dropped `icon` renders)', glyphInput?.defaultValue as string | undefined], ]; describe('the `ui:icon` renderer\'s declared default is a live `icons` key (objectui#5622)', () => { @@ -77,8 +81,8 @@ describe('the `ui:icon` renderer\'s declared default is a live `icons` key (obje // failure mode this shape invites. expect(meta, '`ui:icon` is not registered — the import above no longer registers it.').toBeDefined(); expect( - nameInput, - 'the `name` input is gone from the `ui:icon` registration — fix the reader or the registration.', + glyphInput, + 'the `icon` input is gone from the `ui:icon` registration — fix the reader or the registration.', ).toBeDefined(); for (const [surface, spelling] of DECLARED_DEFAULTS) { expect(typeof spelling, `${surface} declares no icon name at all`).toBe('string'); @@ -106,7 +110,7 @@ describe('the `ui:icon` renderer\'s declared default is a live `icons` key (obje // The defect was one name in two places; the repair is only correct if they // stay one name. Split them and the palette advertises a glyph the dropped // component does not render. - expect(meta?.icon).toBe(nameInput?.defaultValue); + expect(meta?.icon).toBe(glyphInput?.defaultValue); }); it('rejects a name the record does not carry — the control', () => { From 40d733ead413e62ac27895124210fbaebb38c44c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 12:38:44 +0000 Subject: [PATCH 3/3] docs(components): make the icon migration snippet compile against built types `check-doc-snippet-types.mjs` type-checks documentation code blocks, and the `migrateIconNodeKeys` example referenced two undeclared names. Declared them so the snippet is judged rather than excluded. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L --- content/docs/components/basic/icon.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/content/docs/components/basic/icon.mdx b/content/docs/components/basic/icon.mdx index f031aa3e78..1c8647ef7e 100644 --- a/content/docs/components/basic/icon.mdx +++ b/content/docs/components/basic/icon.mdx @@ -53,7 +53,11 @@ bulk — once, at the source — with the converter shipped for it: ```ts import { migrateIconNodeKeys } from '@object-ui/types'; +declare const storedPage: unknown; +declare function save(document: unknown): Promise; + const { document, converted, warnings } = migrateIconNodeKeys(storedPage); +console.log(`converted ${converted} icon node(s)`); if (warnings.length) console.warn(warnings.map((w) => w.message).join('\n')); if (document !== storedPage) await save(document); ```