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..1c8647ef7e 100644 --- a/content/docs/components/basic/icon.mdx +++ b/content/docs/components/basic/icon.mdx @@ -16,9 +16,53 @@ 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'; + +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); +``` + +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/__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', () => { 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 58b877590c..b6f04576bd 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'), });