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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/5935-one-icon-resolver-seam.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
'@object-ui/components': minor
'@object-ui/app-shell': minor
'@object-ui/plugin-detail': minor
'@object-ui/plugin-list': minor
'@object-ui/plugin-view': minor
---

Consolidate the seven lucide icon-name resolvers into one seam (objectui#5935).

Seven modules resolved authored icon names into lucide's runtime `icons` record, each
with its own copy of the logic: **three different tokenisers** (`split('-')` on five of
them, `split(/[-_\s]/)` on one, `split(/[-_\s]+/)` on one) and the `Home` -> `House`
rename on only **four** of the seven. The same authored name therefore rendered on one
surface and not another — the sidebar-vs-action-bar disagreement objectui#5633 opened
with. There is now one resolver, `resolveIcon`, exported from `@object-ui/components`,
and the other six call it.

**The tokeniser is `split(/[-_\s]+/)` with `Home` -> `House` applied universally, and it
was measured rather than chosen.** Its regression set is empty three independent ways:
against the authored population, against a maximally-pessimistic every-authored-name x
every-surface cross-product, and against a bound-free differential over 8,298 spellings
derived from all 1,767 live record keys — each with a discrimination control that fired
in the same run. `split('-')` was **not** adoptable: it regresses 4,748 name-surface
pairs in that last reading, stripping two surfaces of every snake_case and
space-separated spelling they resolve today.

**What changes for you — all of it widening, none of it removal.** No name that resolved
before stops resolving: no key of lucide's record contains `_`, whitespace or `-`
(measured: 0 of 1,767), so whenever the old narrow tokeniser produced a live key the
wider one produces the same key. Sixteen name-surface pairs start resolving where they
rendered a fallback or nothing before:

- `layout_dashboard` and `building_2` (and every other snake_case or space-separated
spelling) now resolve on the shared resolver, `ui:icon`, `ListView`'s empty state,
`TabBar` and `ViewSwitcher` — they previously resolved only on the action preview and
the related list.
- `home` / `Home` now resolves on `RelatedList`, `ListView` and `TabBar`, which carried
no rename map. `Home` is not a live record key, so this could only ever be a widening.

**What does NOT change: what each surface draws when a name does not resolve.** The seam
answers `name -> component`, returning `null`, and decides nothing else (maintainer
ruling 2026-09-03 on objectui#5935). Every call site keeps its own fallback, visibly, at
the call site: `ui:icon` keeps its `SquareDashed` placeholder and its warning
(objectui#5631, untouched), `RelatedList` and `ListView` keep their `Inbox` glyph,
`ActionPreview` keeps its three-character name chip, and the shared resolver, `TabBar`
and `ViewSwitcher` keep `null`. A two-valued `onUnresolvable` parameter was ruled on and
then dropped once the tree was measured to have four such behaviours rather than two: a
lookup function is the wrong place to publish a presentation decision.

`resolveIcon` is newly exported from `@object-ui/components`, which is the only surface
this adds. `scripts/check-lucide-icon-record-names.mjs` is simplified in the same change:
its census goes from seven sites to one, and its normalisation stops being a
widest-common approximation of three disagreeing resolvers — so the under-reporting that
gate disclosed at objectui#5932 is closed rather than merely bounded.
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,8 +40,8 @@ import {
Sparkles,
Square,
Workflow,
icons as lucideIcons,
} from 'lucide-react';
import { resolveIcon } from '@object-ui/components';
import type { ActionParam } from '@object-ui/types';
import { paramDegradesWithoutTarget, resolveParamWidgetType } from '../../../utils/paramToField.js';
import type { MetadataPreviewProps } from '../preview-registry.js';
Expand DownExpand Up@@ -321,15 +321,23 @@ function FauxButton({
* the author still sees that an icon binding is in place.
*/
function IconHint({ name }: { name: string }) {
const pascal = name
.split(/[-_\s]+/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join('');
const resolved = pascal === 'Home' ? 'House' : pascal;
const Glyph = (lucideIcons as Record<string, React.ComponentType<{ className?: string }>>)[resolved];
// objectui#5935: the normalisation moved to the ONE seam. This site's own
// copy was the WIDEST of the three tokenisers in the tree, so the seam adopts
// its behaviour rather than replacing it — nothing this preview resolved
// before stops resolving now.
//
// ⛔ The seam does not decide the fallback. The name chip below stays here,
// unchanged: an author looking at an action preview needs to see that an icon
// binding is in place even when the glyph does not resolve (maintainer
// ruling 2026-09-03, objectui#5935, option C).
const Glyph = resolveIcon(name);

if (Glyph) {
// The same annotation the other five seam call sites carry: `resolveIcon`
// returns a STABLE component out of lucide's static record, it does not
// create one during render. The rule cannot see that through a call, where
// it could through the record index this line replaced.
// eslint-disable-next-line react-hooks/static-components
return <Glyph className="h-4 w-4" aria-hidden />;
}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#5935 — `ActionPreview` takes the shared seam and KEEPS its name chip.
*
* ## Why this file's rows read differently from the other four surfaces'
*
* This site's own normalisation was `split(/[-_\s]+/)` plus the `Home -> House`
* rename — i.e. it was ALREADY the rule the pre-dispatch enumeration went on to
* measure as the zero-regression one (comment 5522254814). The seam adopted
* this site's width rather than the `split('-')` the other five used.
*
* ⇒ There is NO behavioural row here that could be red before the change, and
* this file does not pretend otherwise. Its discriminating row is STRUCTURAL:
* the seam is SPIED, and before the consolidation this renderer never called it
* — the spy recorded zero calls. That row pins the only thing that actually
* moved at this site: which function the glyph came out of.
*
* Every other row is green in both worlds by construction, which is the point:
* the acceptance criterion for this card is that nothing observable changed on
* the fallback behaviours, and the fourth of the tree's four is this chip.
*
* ## Why the module is SPIED rather than stubbed
*
* `importOriginal` keeps the REAL resolver running against the REAL lucide
* record, so the behaviour rows still test resolution rather than a fixture. A
* stub returning a fixed component would have deleted the half that matters —
* that an unresolvable name yields the chip and not a wrong glyph.
*/

import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';

vi.mock('@object-ui/components', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/components')>();
return { ...actual, resolveIcon: vi.fn(actual.resolveIcon) };
});

import { resolveIcon } from '@object-ui/components';
import { ActionPreview } from '../ActionPreview';

const seam = vi.mocked(resolveIcon);

beforeEach(() => seam.mockClear());
afterEach(cleanup);

function renderPreview(icon: string) {
return render(
<ActionPreview
type="action"
name="new_task"
draft={{ name: 'new_task', label: 'New Task', type: 'script', target: 'true', icon }}
/>,
);
}

describe('ActionPreview icon binding (objectui#5935)', () => {
it('CONTROL — the preview renders its action, whatever the icon does', () => {
// Without this, every "the chip is shown" row below could pass against a
// preview that fell into an error boundary and drew nothing at all.
renderPreview('definitely-not-a-lucide-icon');
expect(screen.getAllByText('New Task').length).toBeGreaterThan(0);
});

describe('routing — the only row here that discriminates', () => {
it('resolves the authored name through the SHARED seam', () => {
// RED before the consolidation: this file carried its own tokeniser and
// rename ternary and never called this function.
renderPreview('file-text');
expect(seam).toHaveBeenCalledWith('file-text');
});

it('draws the glyph the seam returned, not one of its own', () => {
// ⚠️ `toHaveBeenCalled` FIRST. Reading `mock.results[0]?.value` straight
// away is a BLIND instrument — with zero calls it is `undefined`, and
// `expect(undefined).not.toBeNull()` passes.
const { container } = renderPreview('file-text');
expect(seam).toHaveBeenCalled();
expect(seam.mock.results[0].value).not.toBeNull();
expect(container.querySelector('svg.lucide-file-text')).not.toBeNull();
});
});

describe('the name chip — GREEN IN BOTH WORLDS, pinning that nothing moved', () => {
it('falls back to the 3-character chip when the name does not resolve', () => {
// ⭐ The fourth of the tree's four unresolvable behaviours, and the one
// that killed the 2026-08-31 `onUnresolvable: "placeholder" | "null"`
// domain: it is neither. The chip stays HERE (maintainer ruling
// 2026-09-03, comment 5523286738, option C) so an author can still see
// that an icon binding is in place.
const { container } = renderPreview('definitely-not-a-lucide-icon');
expect(container.querySelector('svg.lucide-definitely-not-a-lucide-icon')).toBeNull();
// Positive: the chip is the first three characters, uppercased by CSS but
// authored verbatim in the DOM.
expect(screen.getAllByText('def').length).toBeGreaterThan(0);
});

it('shows no chip when the name DOES resolve', () => {
// The control that makes the row above a reading: the chip is not simply
// always present.
renderPreview('file-text');
expect(screen.queryByText('fil')).toBeNull();
});

it('keeps resolving the spellings this site already accepted', () => {
// Green in both worlds, and load-bearing: the shared tokeniser had to
// ADOPT this site's width. A narrowing to `split('-')` would land here
// first, and it is the regression the enumeration measured at 4,748
// name-surface pairs.
expect(renderPreview('file_text').container.querySelector('svg.lucide-file-text')).not.toBeNull();
cleanup();
expect(renderPreview('file text').container.querySelector('svg.lucide-file-text')).not.toBeNull();
cleanup();
expect(renderPreview('home').container.querySelector('svg.lucide-house')).not.toBeNull();
});
});
});
16 changes: 16 additions & 0 deletions packages/components/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,6 +59,22 @@ export { getLazyIcon, isLucideIconName, LazyIcon, toKebabIconName } from './lib/
// already depends on this package, so the direction costs nothing new.
export { hasDeclaredVisibilityGate } from './renderers/action/visibility-gate';

// THE icon-name seam (objectui#5935) — `name -> LucideIcon | null`, one
// tokeniser and one rename map for the whole repo.
//
// Exported for the same reason `hasDeclaredVisibilityGate` above is: the family
// has members outside this package. Seven modules used to hand-roll this lookup
// with THREE different tokenisers and the `Home -> House` rename on only four of
// them, so the same authored name rendered on one surface and not another —
// `app-shell`'s ActionPreview, `plugin-detail`'s RelatedList, `plugin-list`'s
// ListView and TabBar, and `plugin-view`'s ViewSwitcher now import this one.
// All five already depend on this package, so the direction costs nothing new.
//
// ⛔ Nothing about the FALLBACK is exported, because there is none to export:
// the seam returns `null` and each surface keeps its own visible fallback
// (maintainer ruling 2026-09-03, objectui#5935, option C).
export { resolveIcon } from './renderers/action/resolve-icon';

// Export placeholder registration
export { registerPlaceholders } from './renderers/placeholders';

Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
/**
* 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.
*/

/**
* objectui#5935 — the ONE icon-name seam.
*
* Seven modules used to resolve authored icon names, with THREE tokenisers
* (`split('-')` on five, `split(/[-_\s]/)` on one, `split(/[-_\s]+/)` on one)
* and the `Home -> House` rename on only four, so the same authored name
* rendered on one surface and not another. This file pins what the surviving
* one does.
*
* ## What is being proved, and what could not be proved by rendering
*
* The per-surface suites prove that each call site still draws ITS OWN fallback
* (`null`, the objectui#5631 placeholder, `Inbox`, a name chip). They cannot
* prove the seam's own algebra, because a surface only ever shows "resolved" or
* "did not" — so the tokeniser rows below are here, where the answer is the
* component itself.
*
* ## The adopted rule is MEASURED, not chosen
*
* `split(/[-_\s]+/)` with `Home -> House` universal, from the pre-dispatch
* enumeration the 2026-08-31 ruling required (comment 5522254814). Its
* regression set is EMPTY three ways over: against the authored population,
* against the every-name-x-every-surface cross-product, and against a
* bound-free differential over 8,298 spellings derived from all 1,767 live
* record keys. `split('-')` regresses 4,748 pairs in that last reading, which
* is why it is not adoptable and why the rows below assert the WIDER rule
* rather than the more common one.
*/

import { describe, it, expect } from 'vitest';
import { icons } from 'lucide-react';
import { resolveIcon, describeIconLookup } from '../resolve-icon';

describe('the icon-name seam resolves (objectui#5935)', () => {
/**
* ⭐ Non-vacuity for every "resolves" row below. A `resolveIcon` that returned
* some component for EVERY input would pass them all; a `resolveIcon` that
* returned `null` for every input would pass every fallback row in every
* per-surface suite. Both directions are excluded here, in the same run.
*/
it('DISCRIMINATES — a live name resolves and a dead one does not', () => {
expect(resolveIcon('file-text')).not.toBeNull();
expect(resolveIcon('not-a-real-icon')).toBeNull();
});

it('accepts all four authored spellings of one glyph', () => {
const canonical = icons.ArrowRight;
expect(canonical).toBeDefined();
// kebab — what the docs and most fixtures author.
expect(resolveIcon('arrow-right')).toBe(canonical);
// snake — resolved on TWO of the seven surfaces before this card and on
// five of them not at all. This row is the consolidation.
expect(resolveIcon('arrow_right')).toBe(canonical);
// space-separated — same story.
expect(resolveIcon('arrow right')).toBe(canonical);
// already-Pascal — authored in real fixtures, must not be mangled.
expect(resolveIcon('ArrowRight')).toBe(canonical);
});

it('collapses repeated and mixed separators', () => {
// `+` in the tokeniser. The equivalent spelling without it produced empty
// tokens, which capitalise to nothing and join to nothing — measured
// identical over 51,449 hostile spellings, and pinned here so the two
// spellings are not "fixed" apart later.
expect(resolveIcon('arrow--right')).toBe(icons.ArrowRight);
expect(resolveIcon('arrow-_ right')).toBe(icons.ArrowRight);
});

it('applies the `Home` -> `House` rename, which is the ONLY rename', () => {
// lucide dropped `Home` from its runtime record and kept `House`. The map
// exists so a name that used to resolve still does — it is not a general
// alias table, and nothing else belongs in it.
expect(icons).not.toHaveProperty('Home');
expect(resolveIcon('home')).toBe(icons.House);
expect(resolveIcon('Home')).toBe(icons.House);
expect(describeIconLookup('home')).toEqual({ pascal: 'Home', key: 'House' });
// The control: an UNMAPPED name passes through both halves unchanged, so
// the row above is about the map and not about `describeIconLookup` always
// answering `House`.
expect(describeIconLookup('file-text')).toEqual({ pascal: 'FileText', key: 'FileText' });
});

it('returns null — never a fallback glyph — for absent and unresolvable names', () => {
// ⭐ The contract the 2026-09-03 maintainer ruling (option C) fixed: the
// seam does `name -> component`, and NOTHING about what a surface draws
// when there is no component. Each call site keeps its own fallback, so
// this function must never acquire one, and must never acquire a parameter
// for choosing one either.
expect(resolveIcon(undefined)).toBeNull();
expect(resolveIcon('')).toBeNull();
expect(resolveIcon('definitely-not-a-lucide-icon')).toBeNull();
// A RETIRED spelling: `Edit` still imports and still renders, but its key
// is gone from the runtime record. Rules out a resolver that reached for
// the named exports instead — a third, more forgiving vocabulary.
expect(resolveIcon('edit')).toBeNull();
expect(resolveIcon('square-pen')).toBe(icons.SquarePen);
});

it('takes the seam FUNCTION, not a re-derived string, as the answer', () => {
// `describeIconLookup` exists only so `renderers/basic/icon.tsx` can name
// both halves in its objectui#5631 warning without a second copy of the
// tokeniser. Pinned as CONSISTENT with `resolveIcon` so the diagnostic can
// never describe a lookup that did not happen.
for (const authored of ['home', 'file-text', 'arrow_right', 'not-a-real-icon']) {
const { key } = describeIconLookup(authored);
const expected = Object.prototype.hasOwnProperty.call(icons, key)
? (icons as Record<string, unknown>)[key]
: null;
expect(resolveIcon(authored)).toBe(expected);
}
});

it('is what the widening promised: the OLD resolving sets are strict subsets', () => {
// Why no name could regress, made concrete. The old narrow tokeniser is
// re-implemented HERE, in the test, so the claim is checked rather than
// asserted — every name it resolved must still resolve, and the two names
// the enumeration named as newly-resolving must now do so.
const narrow = (name: string) => {
const pascal = name.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
const mapped = pascal === 'Home' ? 'House' : pascal;
return Object.prototype.hasOwnProperty.call(icons, mapped)
? (icons as Record<string, unknown>)[mapped]
: null;
};
let carried = 0;
for (const key of Object.keys(icons)) {
// The kebab spelling of every live glyph — what the narrow tokeniser
// could resolve at all.
const kebab = key.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
const before = narrow(kebab);
if (before === null) continue;
carried += 1;
expect(resolveIcon(kebab), `${kebab} stopped resolving`).toBe(before);
}
// Non-vacuity: a loop that skipped everything would pass silently.
expect(carried).toBeGreaterThan(1000);
// And the widening the enumeration measured, in both of its named cases.
expect(narrow('building_2')).toBeNull();
expect(resolveIcon('building_2')).toBe(icons.Building2);
expect(narrow('layout_dashboard')).toBeNull();
expect(resolveIcon('layout_dashboard')).toBe(icons.LayoutDashboard);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/5935-one-icon-resolver-seam.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
'@object-ui/components': minor
'@object-ui/app-shell': minor
'@object-ui/plugin-detail': minor
'@object-ui/plugin-list': minor
'@object-ui/plugin-view': minor
---

Consolidate the seven lucide icon-name resolvers into one seam (objectui#5935).

Seven modules resolved authored icon names into lucide's runtime `icons` record, each
with its own copy of the logic: **three different tokenisers** (`split('-')` on five of
them, `split(/[-_\s]/)` on one, `split(/[-_\s]+/)` on one) and the `Home` -> `House`
rename on only **four** of the seven. The same authored name therefore rendered on one
surface and not another — the sidebar-vs-action-bar disagreement objectui#5633 opened
with. There is now one resolver, `resolveIcon`, exported from `@object-ui/components`,
and the other six call it.

**The tokeniser is `split(/[-_\s]+/)` with `Home` -> `House` applied universally, and it
was measured rather than chosen.** Its regression set is empty three independent ways:
against the authored population, against a maximally-pessimistic every-authored-name x
every-surface cross-product, and against a bound-free differential over 8,298 spellings
derived from all 1,767 live record keys — each with a discrimination control that fired
in the same run. `split('-')` was **not** adoptable: it regresses 4,748 name-surface
pairs in that last reading, stripping two surfaces of every snake_case and
space-separated spelling they resolve today.

**What changes for you — all of it widening, none of it removal.** No name that resolved
before stops resolving: no key of lucide's record contains `_`, whitespace or `-`
(measured: 0 of 1,767), so whenever the old narrow tokeniser produced a live key the
wider one produces the same key. Sixteen name-surface pairs start resolving where they
rendered a fallback or nothing before:

- `layout_dashboard` and `building_2` (and every other snake_case or space-separated
spelling) now resolve on the shared resolver, `ui:icon`, `ListView`'s empty state,
`TabBar` and `ViewSwitcher` — they previously resolved only on the action preview and
the related list.
- `home` / `Home` now resolves on `RelatedList`, `ListView` and `TabBar`, which carried
no rename map. `Home` is not a live record key, so this could only ever be a widening.

**What does NOT change: what each surface draws when a name does not resolve.** The seam
answers `name -> component`, returning `null`, and decides nothing else (maintainer
ruling 2026-09-03 on objectui#5935). Every call site keeps its own fallback, visibly, at
the call site: `ui:icon` keeps its `SquareDashed` placeholder and its warning
(objectui#5631, untouched), `RelatedList` and `ListView` keep their `Inbox` glyph,
`ActionPreview` keeps its three-character name chip, and the shared resolver, `TabBar`
and `ViewSwitcher` keep `null`. A two-valued `onUnresolvable` parameter was ruled on and
then dropped once the tree was measured to have four such behaviours rather than two: a
lookup function is the wrong place to publish a presentation decision.

`resolveIcon` is newly exported from `@object-ui/components`, which is the only surface
this adds. `scripts/check-lucide-icon-record-names.mjs` is simplified in the same change:
its census goes from seven sites to one, and its normalisation stops being a
widest-common approximation of three disagreeing resolvers — so the under-reporting that
gate disclosed at objectui#5932 is closed rather than merely bounded.
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,8 +40,8 @@ import {
Sparkles,
Square,
Workflow,
icons as lucideIcons,
} from 'lucide-react';
import { resolveIcon } from '@object-ui/components';
import type { ActionParam } from '@object-ui/types';
import { paramDegradesWithoutTarget, resolveParamWidgetType } from '../../../utils/paramToField.js';
import type { MetadataPreviewProps } from '../preview-registry.js';
Expand DownExpand Up@@ -321,15 +321,23 @@ function FauxButton({
* the author still sees that an icon binding is in place.
*/
function IconHint({ name }: { name: string }) {
const pascal = name
.split(/[-_\s]+/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join('');
const resolved = pascal === 'Home' ? 'House' : pascal;
const Glyph = (lucideIcons as Record<string, React.ComponentType<{ className?: string }>>)[resolved];
// objectui#5935: the normalisation moved to the ONE seam. This site's own
// copy was the WIDEST of the three tokenisers in the tree, so the seam adopts
// its behaviour rather than replacing it — nothing this preview resolved
// before stops resolving now.
//
// ⛔ The seam does not decide the fallback. The name chip below stays here,
// unchanged: an author looking at an action preview needs to see that an icon
// binding is in place even when the glyph does not resolve (maintainer
// ruling 2026-09-03, objectui#5935, option C).
const Glyph = resolveIcon(name);

if (Glyph) {
// The same annotation the other five seam call sites carry: `resolveIcon`
// returns a STABLE component out of lucide's static record, it does not
// create one during render. The rule cannot see that through a call, where
// it could through the record index this line replaced.
// eslint-disable-next-line react-hooks/static-components
return <Glyph className="h-4 w-4" aria-hidden />;
}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#5935 — `ActionPreview` takes the shared seam and KEEPS its name chip.
*
* ## Why this file's rows read differently from the other four surfaces'
*
* This site's own normalisation was `split(/[-_\s]+/)` plus the `Home -> House`
* rename — i.e. it was ALREADY the rule the pre-dispatch enumeration went on to
* measure as the zero-regression one (comment 5522254814). The seam adopted
* this site's width rather than the `split('-')` the other five used.
*
* ⇒ There is NO behavioural row here that could be red before the change, and
* this file does not pretend otherwise. Its discriminating row is STRUCTURAL:
* the seam is SPIED, and before the consolidation this renderer never called it
* — the spy recorded zero calls. That row pins the only thing that actually
* moved at this site: which function the glyph came out of.
*
* Every other row is green in both worlds by construction, which is the point:
* the acceptance criterion for this card is that nothing observable changed on
* the fallback behaviours, and the fourth of the tree's four is this chip.
*
* ## Why the module is SPIED rather than stubbed
*
* `importOriginal` keeps the REAL resolver running against the REAL lucide
* record, so the behaviour rows still test resolution rather than a fixture. A
* stub returning a fixed component would have deleted the half that matters —
* that an unresolvable name yields the chip and not a wrong glyph.
*/

import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';

vi.mock('@object-ui/components', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/components')>();
return { ...actual, resolveIcon: vi.fn(actual.resolveIcon) };
});

import { resolveIcon } from '@object-ui/components';
import { ActionPreview } from '../ActionPreview';

const seam = vi.mocked(resolveIcon);

beforeEach(() => seam.mockClear());
afterEach(cleanup);

function renderPreview(icon: string) {
return render(
<ActionPreview
type="action"
name="new_task"
draft={{ name: 'new_task', label: 'New Task', type: 'script', target: 'true', icon }}
/>,
);
}

describe('ActionPreview icon binding (objectui#5935)', () => {
it('CONTROL — the preview renders its action, whatever the icon does', () => {
// Without this, every "the chip is shown" row below could pass against a
// preview that fell into an error boundary and drew nothing at all.
renderPreview('definitely-not-a-lucide-icon');
expect(screen.getAllByText('New Task').length).toBeGreaterThan(0);
});

describe('routing — the only row here that discriminates', () => {
it('resolves the authored name through the SHARED seam', () => {
// RED before the consolidation: this file carried its own tokeniser and
// rename ternary and never called this function.
renderPreview('file-text');
expect(seam).toHaveBeenCalledWith('file-text');
});

it('draws the glyph the seam returned, not one of its own', () => {
// ⚠️ `toHaveBeenCalled` FIRST. Reading `mock.results[0]?.value` straight
// away is a BLIND instrument — with zero calls it is `undefined`, and
// `expect(undefined).not.toBeNull()` passes.
const { container } = renderPreview('file-text');
expect(seam).toHaveBeenCalled();
expect(seam.mock.results[0].value).not.toBeNull();
expect(container.querySelector('svg.lucide-file-text')).not.toBeNull();
});
});

describe('the name chip — GREEN IN BOTH WORLDS, pinning that nothing moved', () => {
it('falls back to the 3-character chip when the name does not resolve', () => {
// ⭐ The fourth of the tree's four unresolvable behaviours, and the one
// that killed the 2026-08-31 `onUnresolvable: "placeholder" | "null"`
// domain: it is neither. The chip stays HERE (maintainer ruling
// 2026-09-03, comment 5523286738, option C) so an author can still see
// that an icon binding is in place.
const { container } = renderPreview('definitely-not-a-lucide-icon');
expect(container.querySelector('svg.lucide-definitely-not-a-lucide-icon')).toBeNull();
// Positive: the chip is the first three characters, uppercased by CSS but
// authored verbatim in the DOM.
expect(screen.getAllByText('def').length).toBeGreaterThan(0);
});

it('shows no chip when the name DOES resolve', () => {
// The control that makes the row above a reading: the chip is not simply
// always present.
renderPreview('file-text');
expect(screen.queryByText('fil')).toBeNull();
});

it('keeps resolving the spellings this site already accepted', () => {
// Green in both worlds, and load-bearing: the shared tokeniser had to
// ADOPT this site's width. A narrowing to `split('-')` would land here
// first, and it is the regression the enumeration measured at 4,748
// name-surface pairs.
expect(renderPreview('file_text').container.querySelector('svg.lucide-file-text')).not.toBeNull();
cleanup();
expect(renderPreview('file text').container.querySelector('svg.lucide-file-text')).not.toBeNull();
cleanup();
expect(renderPreview('home').container.querySelector('svg.lucide-house')).not.toBeNull();
});
});
});
16 changes: 16 additions & 0 deletions packages/components/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,6 +59,22 @@ export { getLazyIcon, isLucideIconName, LazyIcon, toKebabIconName } from './lib/
// already depends on this package, so the direction costs nothing new.
export { hasDeclaredVisibilityGate } from './renderers/action/visibility-gate';

// THE icon-name seam (objectui#5935) — `name -> LucideIcon | null`, one
// tokeniser and one rename map for the whole repo.
//
// Exported for the same reason `hasDeclaredVisibilityGate` above is: the family
// has members outside this package. Seven modules used to hand-roll this lookup
// with THREE different tokenisers and the `Home -> House` rename on only four of
// them, so the same authored name rendered on one surface and not another —
// `app-shell`'s ActionPreview, `plugin-detail`'s RelatedList, `plugin-list`'s
// ListView and TabBar, and `plugin-view`'s ViewSwitcher now import this one.
// All five already depend on this package, so the direction costs nothing new.
//
// ⛔ Nothing about the FALLBACK is exported, because there is none to export:
// the seam returns `null` and each surface keeps its own visible fallback
// (maintainer ruling 2026-09-03, objectui#5935, option C).
export { resolveIcon } from './renderers/action/resolve-icon';

// Export placeholder registration
export { registerPlaceholders } from './renderers/placeholders';

Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
/**
* 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.
*/

/**
* objectui#5935 — the ONE icon-name seam.
*
* Seven modules used to resolve authored icon names, with THREE tokenisers
* (`split('-')` on five, `split(/[-_\s]/)` on one, `split(/[-_\s]+/)` on one)
* and the `Home -> House` rename on only four, so the same authored name
* rendered on one surface and not another. This file pins what the surviving
* one does.
*
* ## What is being proved, and what could not be proved by rendering
*
* The per-surface suites prove that each call site still draws ITS OWN fallback
* (`null`, the objectui#5631 placeholder, `Inbox`, a name chip). They cannot
* prove the seam's own algebra, because a surface only ever shows "resolved" or
* "did not" — so the tokeniser rows below are here, where the answer is the
* component itself.
*
* ## The adopted rule is MEASURED, not chosen
*
* `split(/[-_\s]+/)` with `Home -> House` universal, from the pre-dispatch
* enumeration the 2026-08-31 ruling required (comment 5522254814). Its
* regression set is EMPTY three ways over: against the authored population,
* against the every-name-x-every-surface cross-product, and against a
* bound-free differential over 8,298 spellings derived from all 1,767 live
* record keys. `split('-')` regresses 4,748 pairs in that last reading, which
* is why it is not adoptable and why the rows below assert the WIDER rule
* rather than the more common one.
*/

import { describe, it, expect } from 'vitest';
import { icons } from 'lucide-react';
import { resolveIcon, describeIconLookup } from '../resolve-icon';

describe('the icon-name seam resolves (objectui#5935)', () => {
/**
* ⭐ Non-vacuity for every "resolves" row below. A `resolveIcon` that returned
* some component for EVERY input would pass them all; a `resolveIcon` that
* returned `null` for every input would pass every fallback row in every
* per-surface suite. Both directions are excluded here, in the same run.
*/
it('DISCRIMINATES — a live name resolves and a dead one does not', () => {
expect(resolveIcon('file-text')).not.toBeNull();
expect(resolveIcon('not-a-real-icon')).toBeNull();
});

it('accepts all four authored spellings of one glyph', () => {
const canonical = icons.ArrowRight;
expect(canonical).toBeDefined();
// kebab — what the docs and most fixtures author.
expect(resolveIcon('arrow-right')).toBe(canonical);
// snake — resolved on TWO of the seven surfaces before this card and on
// five of them not at all. This row is the consolidation.
expect(resolveIcon('arrow_right')).toBe(canonical);
// space-separated — same story.
expect(resolveIcon('arrow right')).toBe(canonical);
// already-Pascal — authored in real fixtures, must not be mangled.
expect(resolveIcon('ArrowRight')).toBe(canonical);
});

it('collapses repeated and mixed separators', () => {
// `+` in the tokeniser. The equivalent spelling without it produced empty
// tokens, which capitalise to nothing and join to nothing — measured
// identical over 51,449 hostile spellings, and pinned here so the two
// spellings are not "fixed" apart later.
expect(resolveIcon('arrow--right')).toBe(icons.ArrowRight);
expect(resolveIcon('arrow-_ right')).toBe(icons.ArrowRight);
});

it('applies the `Home` -> `House` rename, which is the ONLY rename', () => {
// lucide dropped `Home` from its runtime record and kept `House`. The map
// exists so a name that used to resolve still does — it is not a general
// alias table, and nothing else belongs in it.
expect(icons).not.toHaveProperty('Home');
expect(resolveIcon('home')).toBe(icons.House);
expect(resolveIcon('Home')).toBe(icons.House);
expect(describeIconLookup('home')).toEqual({ pascal: 'Home', key: 'House' });
// The control: an UNMAPPED name passes through both halves unchanged, so
// the row above is about the map and not about `describeIconLookup` always
// answering `House`.
expect(describeIconLookup('file-text')).toEqual({ pascal: 'FileText', key: 'FileText' });
});

it('returns null — never a fallback glyph — for absent and unresolvable names', () => {
// ⭐ The contract the 2026-09-03 maintainer ruling (option C) fixed: the
// seam does `name -> component`, and NOTHING about what a surface draws
// when there is no component. Each call site keeps its own fallback, so
// this function must never acquire one, and must never acquire a parameter
// for choosing one either.
expect(resolveIcon(undefined)).toBeNull();
expect(resolveIcon('')).toBeNull();
expect(resolveIcon('definitely-not-a-lucide-icon')).toBeNull();
// A RETIRED spelling: `Edit` still imports and still renders, but its key
// is gone from the runtime record. Rules out a resolver that reached for
// the named exports instead — a third, more forgiving vocabulary.
expect(resolveIcon('edit')).toBeNull();
expect(resolveIcon('square-pen')).toBe(icons.SquarePen);
});

it('takes the seam FUNCTION, not a re-derived string, as the answer', () => {
// `describeIconLookup` exists only so `renderers/basic/icon.tsx` can name
// both halves in its objectui#5631 warning without a second copy of the
// tokeniser. Pinned as CONSISTENT with `resolveIcon` so the diagnostic can
// never describe a lookup that did not happen.
for (const authored of ['home', 'file-text', 'arrow_right', 'not-a-real-icon']) {
const { key } = describeIconLookup(authored);
const expected = Object.prototype.hasOwnProperty.call(icons, key)
? (icons as Record<string, unknown>)[key]
: null;
expect(resolveIcon(authored)).toBe(expected);
}
});

it('is what the widening promised: the OLD resolving sets are strict subsets', () => {
// Why no name could regress, made concrete. The old narrow tokeniser is
// re-implemented HERE, in the test, so the claim is checked rather than
// asserted — every name it resolved must still resolve, and the two names
// the enumeration named as newly-resolving must now do so.
const narrow = (name: string) => {
const pascal = name.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
const mapped = pascal === 'Home' ? 'House' : pascal;
return Object.prototype.hasOwnProperty.call(icons, mapped)
? (icons as Record<string, unknown>)[mapped]
: null;
};
let carried = 0;
for (const key of Object.keys(icons)) {
// The kebab spelling of every live glyph — what the narrow tokeniser
// could resolve at all.
const kebab = key.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
const before = narrow(kebab);
if (before === null) continue;
carried += 1;
expect(resolveIcon(kebab), `${kebab} stopped resolving`).toBe(before);
}
// Non-vacuity: a loop that skipped everything would pass silently.
expect(carried).toBeGreaterThan(1000);
// And the widening the enumeration measured, in both of its named cases.
expect(narrow('building_2')).toBeNull();
expect(resolveIcon('building_2')).toBe(icons.Building2);
expect(narrow('layout_dashboard')).toBeNull();
expect(resolveIcon('layout_dashboard')).toBe(icons.LayoutDashboard);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/5935-one-icon-resolver-seam.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
'@object-ui/components': minor
'@object-ui/app-shell': minor
'@object-ui/plugin-detail': minor
'@object-ui/plugin-list': minor
'@object-ui/plugin-view': minor
---

Consolidate the seven lucide icon-name resolvers into one seam (objectui#5935).

Seven modules resolved authored icon names into lucide's runtime `icons` record, each
with its own copy of the logic: **three different tokenisers** (`split('-')` on five of
them, `split(/[-_\s]/)` on one, `split(/[-_\s]+/)` on one) and the `Home` -> `House`
rename on only **four** of the seven. The same authored name therefore rendered on one
surface and not another — the sidebar-vs-action-bar disagreement objectui#5633 opened
with. There is now one resolver, `resolveIcon`, exported from `@object-ui/components`,
and the other six call it.

**The tokeniser is `split(/[-_\s]+/)` with `Home` -> `House` applied universally, and it
was measured rather than chosen.** Its regression set is empty three independent ways:
against the authored population, against a maximally-pessimistic every-authored-name x
every-surface cross-product, and against a bound-free differential over 8,298 spellings
derived from all 1,767 live record keys — each with a discrimination control that fired
in the same run. `split('-')` was **not** adoptable: it regresses 4,748 name-surface
pairs in that last reading, stripping two surfaces of every snake_case and
space-separated spelling they resolve today.

**What changes for you — all of it widening, none of it removal.** No name that resolved
before stops resolving: no key of lucide's record contains `_`, whitespace or `-`
(measured: 0 of 1,767), so whenever the old narrow tokeniser produced a live key the
wider one produces the same key. Sixteen name-surface pairs start resolving where they
rendered a fallback or nothing before:

- `layout_dashboard` and `building_2` (and every other snake_case or space-separated
spelling) now resolve on the shared resolver, `ui:icon`, `ListView`'s empty state,
`TabBar` and `ViewSwitcher` — they previously resolved only on the action preview and
the related list.
- `home` / `Home` now resolves on `RelatedList`, `ListView` and `TabBar`, which carried
no rename map. `Home` is not a live record key, so this could only ever be a widening.

**What does NOT change: what each surface draws when a name does not resolve.** The seam
answers `name -> component`, returning `null`, and decides nothing else (maintainer
ruling 2026-09-03 on objectui#5935). Every call site keeps its own fallback, visibly, at
the call site: `ui:icon` keeps its `SquareDashed` placeholder and its warning
(objectui#5631, untouched), `RelatedList` and `ListView` keep their `Inbox` glyph,
`ActionPreview` keeps its three-character name chip, and the shared resolver, `TabBar`
and `ViewSwitcher` keep `null`. A two-valued `onUnresolvable` parameter was ruled on and
then dropped once the tree was measured to have four such behaviours rather than two: a
lookup function is the wrong place to publish a presentation decision.

`resolveIcon` is newly exported from `@object-ui/components`, which is the only surface
this adds. `scripts/check-lucide-icon-record-names.mjs` is simplified in the same change:
its census goes from seven sites to one, and its normalisation stops being a
widest-common approximation of three disagreeing resolvers — so the under-reporting that
gate disclosed at objectui#5932 is closed rather than merely bounded.
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,8 +40,8 @@ import {
Sparkles,
Square,
Workflow,
icons as lucideIcons,
} from 'lucide-react';
import { resolveIcon } from '@object-ui/components';
import type { ActionParam } from '@object-ui/types';
import { paramDegradesWithoutTarget, resolveParamWidgetType } from '../../../utils/paramToField.js';
import type { MetadataPreviewProps } from '../preview-registry.js';
Expand DownExpand Up@@ -321,15 +321,23 @@ function FauxButton({
* the author still sees that an icon binding is in place.
*/
function IconHint({ name }: { name: string }) {
const pascal = name
.split(/[-_\s]+/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join('');
const resolved = pascal === 'Home' ? 'House' : pascal;
const Glyph = (lucideIcons as Record<string, React.ComponentType<{ className?: string }>>)[resolved];
// objectui#5935: the normalisation moved to the ONE seam. This site's own
// copy was the WIDEST of the three tokenisers in the tree, so the seam adopts
// its behaviour rather than replacing it — nothing this preview resolved
// before stops resolving now.
//
// ⛔ The seam does not decide the fallback. The name chip below stays here,
// unchanged: an author looking at an action preview needs to see that an icon
// binding is in place even when the glyph does not resolve (maintainer
// ruling 2026-09-03, objectui#5935, option C).
const Glyph = resolveIcon(name);

if (Glyph) {
// The same annotation the other five seam call sites carry: `resolveIcon`
// returns a STABLE component out of lucide's static record, it does not
// create one during render. The rule cannot see that through a call, where
// it could through the record index this line replaced.
// eslint-disable-next-line react-hooks/static-components
return <Glyph className="h-4 w-4" aria-hidden />;
}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#5935 — `ActionPreview` takes the shared seam and KEEPS its name chip.
*
* ## Why this file's rows read differently from the other four surfaces'
*
* This site's own normalisation was `split(/[-_\s]+/)` plus the `Home -> House`
* rename — i.e. it was ALREADY the rule the pre-dispatch enumeration went on to
* measure as the zero-regression one (comment 5522254814). The seam adopted
* this site's width rather than the `split('-')` the other five used.
*
* ⇒ There is NO behavioural row here that could be red before the change, and
* this file does not pretend otherwise. Its discriminating row is STRUCTURAL:
* the seam is SPIED, and before the consolidation this renderer never called it
* — the spy recorded zero calls. That row pins the only thing that actually
* moved at this site: which function the glyph came out of.
*
* Every other row is green in both worlds by construction, which is the point:
* the acceptance criterion for this card is that nothing observable changed on
* the fallback behaviours, and the fourth of the tree's four is this chip.
*
* ## Why the module is SPIED rather than stubbed
*
* `importOriginal` keeps the REAL resolver running against the REAL lucide
* record, so the behaviour rows still test resolution rather than a fixture. A
* stub returning a fixed component would have deleted the half that matters —
* that an unresolvable name yields the chip and not a wrong glyph.
*/

import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';

vi.mock('@object-ui/components', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/components')>();
return { ...actual, resolveIcon: vi.fn(actual.resolveIcon) };
});

import { resolveIcon } from '@object-ui/components';
import { ActionPreview } from '../ActionPreview';

const seam = vi.mocked(resolveIcon);

beforeEach(() => seam.mockClear());
afterEach(cleanup);

function renderPreview(icon: string) {
return render(
<ActionPreview
type="action"
name="new_task"
draft={{ name: 'new_task', label: 'New Task', type: 'script', target: 'true', icon }}
/>,
);
}

describe('ActionPreview icon binding (objectui#5935)', () => {
it('CONTROL — the preview renders its action, whatever the icon does', () => {
// Without this, every "the chip is shown" row below could pass against a
// preview that fell into an error boundary and drew nothing at all.
renderPreview('definitely-not-a-lucide-icon');
expect(screen.getAllByText('New Task').length).toBeGreaterThan(0);
});

describe('routing — the only row here that discriminates', () => {
it('resolves the authored name through the SHARED seam', () => {
// RED before the consolidation: this file carried its own tokeniser and
// rename ternary and never called this function.
renderPreview('file-text');
expect(seam).toHaveBeenCalledWith('file-text');
});

it('draws the glyph the seam returned, not one of its own', () => {
// ⚠️ `toHaveBeenCalled` FIRST. Reading `mock.results[0]?.value` straight
// away is a BLIND instrument — with zero calls it is `undefined`, and
// `expect(undefined).not.toBeNull()` passes.
const { container } = renderPreview('file-text');
expect(seam).toHaveBeenCalled();
expect(seam.mock.results[0].value).not.toBeNull();
expect(container.querySelector('svg.lucide-file-text')).not.toBeNull();
});
});

describe('the name chip — GREEN IN BOTH WORLDS, pinning that nothing moved', () => {
it('falls back to the 3-character chip when the name does not resolve', () => {
// ⭐ The fourth of the tree's four unresolvable behaviours, and the one
// that killed the 2026-08-31 `onUnresolvable: "placeholder" | "null"`
// domain: it is neither. The chip stays HERE (maintainer ruling
// 2026-09-03, comment 5523286738, option C) so an author can still see
// that an icon binding is in place.
const { container } = renderPreview('definitely-not-a-lucide-icon');
expect(container.querySelector('svg.lucide-definitely-not-a-lucide-icon')).toBeNull();
// Positive: the chip is the first three characters, uppercased by CSS but
// authored verbatim in the DOM.
expect(screen.getAllByText('def').length).toBeGreaterThan(0);
});

it('shows no chip when the name DOES resolve', () => {
// The control that makes the row above a reading: the chip is not simply
// always present.
renderPreview('file-text');
expect(screen.queryByText('fil')).toBeNull();
});

it('keeps resolving the spellings this site already accepted', () => {
// Green in both worlds, and load-bearing: the shared tokeniser had to
// ADOPT this site's width. A narrowing to `split('-')` would land here
// first, and it is the regression the enumeration measured at 4,748
// name-surface pairs.
expect(renderPreview('file_text').container.querySelector('svg.lucide-file-text')).not.toBeNull();
cleanup();
expect(renderPreview('file text').container.querySelector('svg.lucide-file-text')).not.toBeNull();
cleanup();
expect(renderPreview('home').container.querySelector('svg.lucide-house')).not.toBeNull();
});
});
});
16 changes: 16 additions & 0 deletions packages/components/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,6 +59,22 @@ export { getLazyIcon, isLucideIconName, LazyIcon, toKebabIconName } from './lib/
// already depends on this package, so the direction costs nothing new.
export { hasDeclaredVisibilityGate } from './renderers/action/visibility-gate';

// THE icon-name seam (objectui#5935) — `name -> LucideIcon | null`, one
// tokeniser and one rename map for the whole repo.
//
// Exported for the same reason `hasDeclaredVisibilityGate` above is: the family
// has members outside this package. Seven modules used to hand-roll this lookup
// with THREE different tokenisers and the `Home -> House` rename on only four of
// them, so the same authored name rendered on one surface and not another —
// `app-shell`'s ActionPreview, `plugin-detail`'s RelatedList, `plugin-list`'s
// ListView and TabBar, and `plugin-view`'s ViewSwitcher now import this one.
// All five already depend on this package, so the direction costs nothing new.
//
// ⛔ Nothing about the FALLBACK is exported, because there is none to export:
// the seam returns `null` and each surface keeps its own visible fallback
// (maintainer ruling 2026-09-03, objectui#5935, option C).
export { resolveIcon } from './renderers/action/resolve-icon';

// Export placeholder registration
export { registerPlaceholders } from './renderers/placeholders';

Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
/**
* 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.
*/

/**
* objectui#5935 — the ONE icon-name seam.
*
* Seven modules used to resolve authored icon names, with THREE tokenisers
* (`split('-')` on five, `split(/[-_\s]/)` on one, `split(/[-_\s]+/)` on one)
* and the `Home -> House` rename on only four, so the same authored name
* rendered on one surface and not another. This file pins what the surviving
* one does.
*
* ## What is being proved, and what could not be proved by rendering
*
* The per-surface suites prove that each call site still draws ITS OWN fallback
* (`null`, the objectui#5631 placeholder, `Inbox`, a name chip). They cannot
* prove the seam's own algebra, because a surface only ever shows "resolved" or
* "did not" — so the tokeniser rows below are here, where the answer is the
* component itself.
*
* ## The adopted rule is MEASURED, not chosen
*
* `split(/[-_\s]+/)` with `Home -> House` universal, from the pre-dispatch
* enumeration the 2026-08-31 ruling required (comment 5522254814). Its
* regression set is EMPTY three ways over: against the authored population,
* against the every-name-x-every-surface cross-product, and against a
* bound-free differential over 8,298 spellings derived from all 1,767 live
* record keys. `split('-')` regresses 4,748 pairs in that last reading, which
* is why it is not adoptable and why the rows below assert the WIDER rule
* rather than the more common one.
*/

import { describe, it, expect } from 'vitest';
import { icons } from 'lucide-react';
import { resolveIcon, describeIconLookup } from '../resolve-icon';

describe('the icon-name seam resolves (objectui#5935)', () => {
/**
* ⭐ Non-vacuity for every "resolves" row below. A `resolveIcon` that returned
* some component for EVERY input would pass them all; a `resolveIcon` that
* returned `null` for every input would pass every fallback row in every
* per-surface suite. Both directions are excluded here, in the same run.
*/
it('DISCRIMINATES — a live name resolves and a dead one does not', () => {
expect(resolveIcon('file-text')).not.toBeNull();
expect(resolveIcon('not-a-real-icon')).toBeNull();
});

it('accepts all four authored spellings of one glyph', () => {
const canonical = icons.ArrowRight;
expect(canonical).toBeDefined();
// kebab — what the docs and most fixtures author.
expect(resolveIcon('arrow-right')).toBe(canonical);
// snake — resolved on TWO of the seven surfaces before this card and on
// five of them not at all. This row is the consolidation.
expect(resolveIcon('arrow_right')).toBe(canonical);
// space-separated — same story.
expect(resolveIcon('arrow right')).toBe(canonical);
// already-Pascal — authored in real fixtures, must not be mangled.
expect(resolveIcon('ArrowRight')).toBe(canonical);
});

it('collapses repeated and mixed separators', () => {
// `+` in the tokeniser. The equivalent spelling without it produced empty
// tokens, which capitalise to nothing and join to nothing — measured
// identical over 51,449 hostile spellings, and pinned here so the two
// spellings are not "fixed" apart later.
expect(resolveIcon('arrow--right')).toBe(icons.ArrowRight);
expect(resolveIcon('arrow-_ right')).toBe(icons.ArrowRight);
});

it('applies the `Home` -> `House` rename, which is the ONLY rename', () => {
// lucide dropped `Home` from its runtime record and kept `House`. The map
// exists so a name that used to resolve still does — it is not a general
// alias table, and nothing else belongs in it.
expect(icons).not.toHaveProperty('Home');
expect(resolveIcon('home')).toBe(icons.House);
expect(resolveIcon('Home')).toBe(icons.House);
expect(describeIconLookup('home')).toEqual({ pascal: 'Home', key: 'House' });
// The control: an UNMAPPED name passes through both halves unchanged, so
// the row above is about the map and not about `describeIconLookup` always
// answering `House`.
expect(describeIconLookup('file-text')).toEqual({ pascal: 'FileText', key: 'FileText' });
});

it('returns null — never a fallback glyph — for absent and unresolvable names', () => {
// ⭐ The contract the 2026-09-03 maintainer ruling (option C) fixed: the
// seam does `name -> component`, and NOTHING about what a surface draws
// when there is no component. Each call site keeps its own fallback, so
// this function must never acquire one, and must never acquire a parameter
// for choosing one either.
expect(resolveIcon(undefined)).toBeNull();
expect(resolveIcon('')).toBeNull();
expect(resolveIcon('definitely-not-a-lucide-icon')).toBeNull();
// A RETIRED spelling: `Edit` still imports and still renders, but its key
// is gone from the runtime record. Rules out a resolver that reached for
// the named exports instead — a third, more forgiving vocabulary.
expect(resolveIcon('edit')).toBeNull();
expect(resolveIcon('square-pen')).toBe(icons.SquarePen);
});

it('takes the seam FUNCTION, not a re-derived string, as the answer', () => {
// `describeIconLookup` exists only so `renderers/basic/icon.tsx` can name
// both halves in its objectui#5631 warning without a second copy of the
// tokeniser. Pinned as CONSISTENT with `resolveIcon` so the diagnostic can
// never describe a lookup that did not happen.
for (const authored of ['home', 'file-text', 'arrow_right', 'not-a-real-icon']) {
const { key } = describeIconLookup(authored);
const expected = Object.prototype.hasOwnProperty.call(icons, key)
? (icons as Record<string, unknown>)[key]
: null;
expect(resolveIcon(authored)).toBe(expected);
}
});

it('is what the widening promised: the OLD resolving sets are strict subsets', () => {
// Why no name could regress, made concrete. The old narrow tokeniser is
// re-implemented HERE, in the test, so the claim is checked rather than
// asserted — every name it resolved must still resolve, and the two names
// the enumeration named as newly-resolving must now do so.
const narrow = (name: string) => {
const pascal = name.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
const mapped = pascal === 'Home' ? 'House' : pascal;
return Object.prototype.hasOwnProperty.call(icons, mapped)
? (icons as Record<string, unknown>)[mapped]
: null;
};
let carried = 0;
for (const key of Object.keys(icons)) {
// The kebab spelling of every live glyph — what the narrow tokeniser
// could resolve at all.
const kebab = key.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
const before = narrow(kebab);
if (before === null) continue;
carried += 1;
expect(resolveIcon(kebab), `${kebab} stopped resolving`).toBe(before);
}
// Non-vacuity: a loop that skipped everything would pass silently.
expect(carried).toBeGreaterThan(1000);
// And the widening the enumeration measured, in both of its named cases.
expect(narrow('building_2')).toBeNull();
expect(resolveIcon('building_2')).toBe(icons.Building2);
expect(narrow('layout_dashboard')).toBeNull();
expect(resolveIcon('layout_dashboard')).toBe(icons.LayoutDashboard);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/5935-one-icon-resolver-seam.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
'@object-ui/components': minor
'@object-ui/app-shell': minor
'@object-ui/plugin-detail': minor
'@object-ui/plugin-list': minor
'@object-ui/plugin-view': minor
---

Consolidate the seven lucide icon-name resolvers into one seam (objectui#5935).

Seven modules resolved authored icon names into lucide's runtime `icons` record, each
with its own copy of the logic: **three different tokenisers** (`split('-')` on five of
them, `split(/[-_\s]/)` on one, `split(/[-_\s]+/)` on one) and the `Home` -> `House`
rename on only **four** of the seven. The same authored name therefore rendered on one
surface and not another — the sidebar-vs-action-bar disagreement objectui#5633 opened
with. There is now one resolver, `resolveIcon`, exported from `@object-ui/components`,
and the other six call it.

**The tokeniser is `split(/[-_\s]+/)` with `Home` -> `House` applied universally, and it
was measured rather than chosen.** Its regression set is empty three independent ways:
against the authored population, against a maximally-pessimistic every-authored-name x
every-surface cross-product, and against a bound-free differential over 8,298 spellings
derived from all 1,767 live record keys — each with a discrimination control that fired
in the same run. `split('-')` was **not** adoptable: it regresses 4,748 name-surface
pairs in that last reading, stripping two surfaces of every snake_case and
space-separated spelling they resolve today.

**What changes for you — all of it widening, none of it removal.** No name that resolved
before stops resolving: no key of lucide's record contains `_`, whitespace or `-`
(measured: 0 of 1,767), so whenever the old narrow tokeniser produced a live key the
wider one produces the same key. Sixteen name-surface pairs start resolving where they
rendered a fallback or nothing before:

- `layout_dashboard` and `building_2` (and every other snake_case or space-separated
spelling) now resolve on the shared resolver, `ui:icon`, `ListView`'s empty state,
`TabBar` and `ViewSwitcher` — they previously resolved only on the action preview and
the related list.
- `home` / `Home` now resolves on `RelatedList`, `ListView` and `TabBar`, which carried
no rename map. `Home` is not a live record key, so this could only ever be a widening.

**What does NOT change: what each surface draws when a name does not resolve.** The seam
answers `name -> component`, returning `null`, and decides nothing else (maintainer
ruling 2026-09-03 on objectui#5935). Every call site keeps its own fallback, visibly, at
the call site: `ui:icon` keeps its `SquareDashed` placeholder and its warning
(objectui#5631, untouched), `RelatedList` and `ListView` keep their `Inbox` glyph,
`ActionPreview` keeps its three-character name chip, and the shared resolver, `TabBar`
and `ViewSwitcher` keep `null`. A two-valued `onUnresolvable` parameter was ruled on and
then dropped once the tree was measured to have four such behaviours rather than two: a
lookup function is the wrong place to publish a presentation decision.

`resolveIcon` is newly exported from `@object-ui/components`, which is the only surface
this adds. `scripts/check-lucide-icon-record-names.mjs` is simplified in the same change:
its census goes from seven sites to one, and its normalisation stops being a
widest-common approximation of three disagreeing resolvers — so the under-reporting that
gate disclosed at objectui#5932 is closed rather than merely bounded.
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,8 +40,8 @@ import {
Sparkles,
Square,
Workflow,
icons as lucideIcons,
} from 'lucide-react';
import { resolveIcon } from '@object-ui/components';
import type { ActionParam } from '@object-ui/types';
import { paramDegradesWithoutTarget, resolveParamWidgetType } from '../../../utils/paramToField.js';
import type { MetadataPreviewProps } from '../preview-registry.js';
Expand DownExpand Up@@ -321,15 +321,23 @@ function FauxButton({
* the author still sees that an icon binding is in place.
*/
function IconHint({ name }: { name: string }) {
const pascal = name
.split(/[-_\s]+/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join('');
const resolved = pascal === 'Home' ? 'House' : pascal;
const Glyph = (lucideIcons as Record<string, React.ComponentType<{ className?: string }>>)[resolved];
// objectui#5935: the normalisation moved to the ONE seam. This site's own
// copy was the WIDEST of the three tokenisers in the tree, so the seam adopts
// its behaviour rather than replacing it — nothing this preview resolved
// before stops resolving now.
//
// ⛔ The seam does not decide the fallback. The name chip below stays here,
// unchanged: an author looking at an action preview needs to see that an icon
// binding is in place even when the glyph does not resolve (maintainer
// ruling 2026-09-03, objectui#5935, option C).
const Glyph = resolveIcon(name);

if (Glyph) {
// The same annotation the other five seam call sites carry: `resolveIcon`
// returns a STABLE component out of lucide's static record, it does not
// create one during render. The rule cannot see that through a call, where
// it could through the record index this line replaced.
// eslint-disable-next-line react-hooks/static-components
return <Glyph className="h-4 w-4" aria-hidden />;
}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#5935 — `ActionPreview` takes the shared seam and KEEPS its name chip.
*
* ## Why this file's rows read differently from the other four surfaces'
*
* This site's own normalisation was `split(/[-_\s]+/)` plus the `Home -> House`
* rename — i.e. it was ALREADY the rule the pre-dispatch enumeration went on to
* measure as the zero-regression one (comment 5522254814). The seam adopted
* this site's width rather than the `split('-')` the other five used.
*
* ⇒ There is NO behavioural row here that could be red before the change, and
* this file does not pretend otherwise. Its discriminating row is STRUCTURAL:
* the seam is SPIED, and before the consolidation this renderer never called it
* — the spy recorded zero calls. That row pins the only thing that actually
* moved at this site: which function the glyph came out of.
*
* Every other row is green in both worlds by construction, which is the point:
* the acceptance criterion for this card is that nothing observable changed on
* the fallback behaviours, and the fourth of the tree's four is this chip.
*
* ## Why the module is SPIED rather than stubbed
*
* `importOriginal` keeps the REAL resolver running against the REAL lucide
* record, so the behaviour rows still test resolution rather than a fixture. A
* stub returning a fixed component would have deleted the half that matters —
* that an unresolvable name yields the chip and not a wrong glyph.
*/

import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';

vi.mock('@object-ui/components', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/components')>();
return { ...actual, resolveIcon: vi.fn(actual.resolveIcon) };
});

import { resolveIcon } from '@object-ui/components';
import { ActionPreview } from '../ActionPreview';

const seam = vi.mocked(resolveIcon);

beforeEach(() => seam.mockClear());
afterEach(cleanup);

function renderPreview(icon: string) {
return render(
<ActionPreview
type="action"
name="new_task"
draft={{ name: 'new_task', label: 'New Task', type: 'script', target: 'true', icon }}
/>,
);
}

describe('ActionPreview icon binding (objectui#5935)', () => {
it('CONTROL — the preview renders its action, whatever the icon does', () => {
// Without this, every "the chip is shown" row below could pass against a
// preview that fell into an error boundary and drew nothing at all.
renderPreview('definitely-not-a-lucide-icon');
expect(screen.getAllByText('New Task').length).toBeGreaterThan(0);
});

describe('routing — the only row here that discriminates', () => {
it('resolves the authored name through the SHARED seam', () => {
// RED before the consolidation: this file carried its own tokeniser and
// rename ternary and never called this function.
renderPreview('file-text');
expect(seam).toHaveBeenCalledWith('file-text');
});

it('draws the glyph the seam returned, not one of its own', () => {
// ⚠️ `toHaveBeenCalled` FIRST. Reading `mock.results[0]?.value` straight
// away is a BLIND instrument — with zero calls it is `undefined`, and
// `expect(undefined).not.toBeNull()` passes.
const { container } = renderPreview('file-text');
expect(seam).toHaveBeenCalled();
expect(seam.mock.results[0].value).not.toBeNull();
expect(container.querySelector('svg.lucide-file-text')).not.toBeNull();
});
});

describe('the name chip — GREEN IN BOTH WORLDS, pinning that nothing moved', () => {
it('falls back to the 3-character chip when the name does not resolve', () => {
// ⭐ The fourth of the tree's four unresolvable behaviours, and the one
// that killed the 2026-08-31 `onUnresolvable: "placeholder" | "null"`
// domain: it is neither. The chip stays HERE (maintainer ruling
// 2026-09-03, comment 5523286738, option C) so an author can still see
// that an icon binding is in place.
const { container } = renderPreview('definitely-not-a-lucide-icon');
expect(container.querySelector('svg.lucide-definitely-not-a-lucide-icon')).toBeNull();
// Positive: the chip is the first three characters, uppercased by CSS but
// authored verbatim in the DOM.
expect(screen.getAllByText('def').length).toBeGreaterThan(0);
});

it('shows no chip when the name DOES resolve', () => {
// The control that makes the row above a reading: the chip is not simply
// always present.
renderPreview('file-text');
expect(screen.queryByText('fil')).toBeNull();
});

it('keeps resolving the spellings this site already accepted', () => {
// Green in both worlds, and load-bearing: the shared tokeniser had to
// ADOPT this site's width. A narrowing to `split('-')` would land here
// first, and it is the regression the enumeration measured at 4,748
// name-surface pairs.
expect(renderPreview('file_text').container.querySelector('svg.lucide-file-text')).not.toBeNull();
cleanup();
expect(renderPreview('file text').container.querySelector('svg.lucide-file-text')).not.toBeNull();
cleanup();
expect(renderPreview('home').container.querySelector('svg.lucide-house')).not.toBeNull();
});
});
});
16 changes: 16 additions & 0 deletions packages/components/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,6 +59,22 @@ export { getLazyIcon, isLucideIconName, LazyIcon, toKebabIconName } from './lib/
// already depends on this package, so the direction costs nothing new.
export { hasDeclaredVisibilityGate } from './renderers/action/visibility-gate';

// THE icon-name seam (objectui#5935) — `name -> LucideIcon | null`, one
// tokeniser and one rename map for the whole repo.
//
// Exported for the same reason `hasDeclaredVisibilityGate` above is: the family
// has members outside this package. Seven modules used to hand-roll this lookup
// with THREE different tokenisers and the `Home -> House` rename on only four of
// them, so the same authored name rendered on one surface and not another —
// `app-shell`'s ActionPreview, `plugin-detail`'s RelatedList, `plugin-list`'s
// ListView and TabBar, and `plugin-view`'s ViewSwitcher now import this one.
// All five already depend on this package, so the direction costs nothing new.
//
// ⛔ Nothing about the FALLBACK is exported, because there is none to export:
// the seam returns `null` and each surface keeps its own visible fallback
// (maintainer ruling 2026-09-03, objectui#5935, option C).
export { resolveIcon } from './renderers/action/resolve-icon';

// Export placeholder registration
export { registerPlaceholders } from './renderers/placeholders';

Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
/**
* 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.
*/

/**
* objectui#5935 — the ONE icon-name seam.
*
* Seven modules used to resolve authored icon names, with THREE tokenisers
* (`split('-')` on five, `split(/[-_\s]/)` on one, `split(/[-_\s]+/)` on one)
* and the `Home -> House` rename on only four, so the same authored name
* rendered on one surface and not another. This file pins what the surviving
* one does.
*
* ## What is being proved, and what could not be proved by rendering
*
* The per-surface suites prove that each call site still draws ITS OWN fallback
* (`null`, the objectui#5631 placeholder, `Inbox`, a name chip). They cannot
* prove the seam's own algebra, because a surface only ever shows "resolved" or
* "did not" — so the tokeniser rows below are here, where the answer is the
* component itself.
*
* ## The adopted rule is MEASURED, not chosen
*
* `split(/[-_\s]+/)` with `Home -> House` universal, from the pre-dispatch
* enumeration the 2026-08-31 ruling required (comment 5522254814). Its
* regression set is EMPTY three ways over: against the authored population,
* against the every-name-x-every-surface cross-product, and against a
* bound-free differential over 8,298 spellings derived from all 1,767 live
* record keys. `split('-')` regresses 4,748 pairs in that last reading, which
* is why it is not adoptable and why the rows below assert the WIDER rule
* rather than the more common one.
*/

import { describe, it, expect } from 'vitest';
import { icons } from 'lucide-react';
import { resolveIcon, describeIconLookup } from '../resolve-icon';

describe('the icon-name seam resolves (objectui#5935)', () => {
/**
* ⭐ Non-vacuity for every "resolves" row below. A `resolveIcon` that returned
* some component for EVERY input would pass them all; a `resolveIcon` that
* returned `null` for every input would pass every fallback row in every
* per-surface suite. Both directions are excluded here, in the same run.
*/
it('DISCRIMINATES — a live name resolves and a dead one does not', () => {
expect(resolveIcon('file-text')).not.toBeNull();
expect(resolveIcon('not-a-real-icon')).toBeNull();
});

it('accepts all four authored spellings of one glyph', () => {
const canonical = icons.ArrowRight;
expect(canonical).toBeDefined();
// kebab — what the docs and most fixtures author.
expect(resolveIcon('arrow-right')).toBe(canonical);
// snake — resolved on TWO of the seven surfaces before this card and on
// five of them not at all. This row is the consolidation.
expect(resolveIcon('arrow_right')).toBe(canonical);
// space-separated — same story.
expect(resolveIcon('arrow right')).toBe(canonical);
// already-Pascal — authored in real fixtures, must not be mangled.
expect(resolveIcon('ArrowRight')).toBe(canonical);
});

it('collapses repeated and mixed separators', () => {
// `+` in the tokeniser. The equivalent spelling without it produced empty
// tokens, which capitalise to nothing and join to nothing — measured
// identical over 51,449 hostile spellings, and pinned here so the two
// spellings are not "fixed" apart later.
expect(resolveIcon('arrow--right')).toBe(icons.ArrowRight);
expect(resolveIcon('arrow-_ right')).toBe(icons.ArrowRight);
});

it('applies the `Home` -> `House` rename, which is the ONLY rename', () => {
// lucide dropped `Home` from its runtime record and kept `House`. The map
// exists so a name that used to resolve still does — it is not a general
// alias table, and nothing else belongs in it.
expect(icons).not.toHaveProperty('Home');
expect(resolveIcon('home')).toBe(icons.House);
expect(resolveIcon('Home')).toBe(icons.House);
expect(describeIconLookup('home')).toEqual({ pascal: 'Home', key: 'House' });
// The control: an UNMAPPED name passes through both halves unchanged, so
// the row above is about the map and not about `describeIconLookup` always
// answering `House`.
expect(describeIconLookup('file-text')).toEqual({ pascal: 'FileText', key: 'FileText' });
});

it('returns null — never a fallback glyph — for absent and unresolvable names', () => {
// ⭐ The contract the 2026-09-03 maintainer ruling (option C) fixed: the
// seam does `name -> component`, and NOTHING about what a surface draws
// when there is no component. Each call site keeps its own fallback, so
// this function must never acquire one, and must never acquire a parameter
// for choosing one either.
expect(resolveIcon(undefined)).toBeNull();
expect(resolveIcon('')).toBeNull();
expect(resolveIcon('definitely-not-a-lucide-icon')).toBeNull();
// A RETIRED spelling: `Edit` still imports and still renders, but its key
// is gone from the runtime record. Rules out a resolver that reached for
// the named exports instead — a third, more forgiving vocabulary.
expect(resolveIcon('edit')).toBeNull();
expect(resolveIcon('square-pen')).toBe(icons.SquarePen);
});

it('takes the seam FUNCTION, not a re-derived string, as the answer', () => {
// `describeIconLookup` exists only so `renderers/basic/icon.tsx` can name
// both halves in its objectui#5631 warning without a second copy of the
// tokeniser. Pinned as CONSISTENT with `resolveIcon` so the diagnostic can
// never describe a lookup that did not happen.
for (const authored of ['home', 'file-text', 'arrow_right', 'not-a-real-icon']) {
const { key } = describeIconLookup(authored);
const expected = Object.prototype.hasOwnProperty.call(icons, key)
? (icons as Record<string, unknown>)[key]
: null;
expect(resolveIcon(authored)).toBe(expected);
}
});

it('is what the widening promised: the OLD resolving sets are strict subsets', () => {
// Why no name could regress, made concrete. The old narrow tokeniser is
// re-implemented HERE, in the test, so the claim is checked rather than
// asserted — every name it resolved must still resolve, and the two names
// the enumeration named as newly-resolving must now do so.
const narrow = (name: string) => {
const pascal = name.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
const mapped = pascal === 'Home' ? 'House' : pascal;
return Object.prototype.hasOwnProperty.call(icons, mapped)
? (icons as Record<string, unknown>)[mapped]
: null;
};
let carried = 0;
for (const key of Object.keys(icons)) {
// The kebab spelling of every live glyph — what the narrow tokeniser
// could resolve at all.
const kebab = key.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
const before = narrow(kebab);
if (before === null) continue;
carried += 1;
expect(resolveIcon(kebab), `${kebab} stopped resolving`).toBe(before);
}
// Non-vacuity: a loop that skipped everything would pass silently.
expect(carried).toBeGreaterThan(1000);
// And the widening the enumeration measured, in both of its named cases.
expect(narrow('building_2')).toBeNull();
expect(resolveIcon('building_2')).toBe(icons.Building2);
expect(narrow('layout_dashboard')).toBeNull();
expect(resolveIcon('layout_dashboard')).toBe(icons.LayoutDashboard);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/5935-one-icon-resolver-seam.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
'@object-ui/components': minor
'@object-ui/app-shell': minor
'@object-ui/plugin-detail': minor
'@object-ui/plugin-list': minor
'@object-ui/plugin-view': minor
---

Consolidate the seven lucide icon-name resolvers into one seam (objectui#5935).

Seven modules resolved authored icon names into lucide's runtime `icons` record, each
with its own copy of the logic: **three different tokenisers** (`split('-')` on five of
them, `split(/[-_\s]/)` on one, `split(/[-_\s]+/)` on one) and the `Home` -> `House`
rename on only **four** of the seven. The same authored name therefore rendered on one
surface and not another — the sidebar-vs-action-bar disagreement objectui#5633 opened
with. There is now one resolver, `resolveIcon`, exported from `@object-ui/components`,
and the other six call it.

**The tokeniser is `split(/[-_\s]+/)` with `Home` -> `House` applied universally, and it
was measured rather than chosen.** Its regression set is empty three independent ways:
against the authored population, against a maximally-pessimistic every-authored-name x
every-surface cross-product, and against a bound-free differential over 8,298 spellings
derived from all 1,767 live record keys — each with a discrimination control that fired
in the same run. `split('-')` was **not** adoptable: it regresses 4,748 name-surface
pairs in that last reading, stripping two surfaces of every snake_case and
space-separated spelling they resolve today.

**What changes for you — all of it widening, none of it removal.** No name that resolved
before stops resolving: no key of lucide's record contains `_`, whitespace or `-`
(measured: 0 of 1,767), so whenever the old narrow tokeniser produced a live key the
wider one produces the same key. Sixteen name-surface pairs start resolving where they
rendered a fallback or nothing before:

- `layout_dashboard` and `building_2` (and every other snake_case or space-separated
spelling) now resolve on the shared resolver, `ui:icon`, `ListView`'s empty state,
`TabBar` and `ViewSwitcher` — they previously resolved only on the action preview and
the related list.
- `home` / `Home` now resolves on `RelatedList`, `ListView` and `TabBar`, which carried
no rename map. `Home` is not a live record key, so this could only ever be a widening.

**What does NOT change: what each surface draws when a name does not resolve.** The seam
answers `name -> component`, returning `null`, and decides nothing else (maintainer
ruling 2026-09-03 on objectui#5935). Every call site keeps its own fallback, visibly, at
the call site: `ui:icon` keeps its `SquareDashed` placeholder and its warning
(objectui#5631, untouched), `RelatedList` and `ListView` keep their `Inbox` glyph,
`ActionPreview` keeps its three-character name chip, and the shared resolver, `TabBar`
and `ViewSwitcher` keep `null`. A two-valued `onUnresolvable` parameter was ruled on and
then dropped once the tree was measured to have four such behaviours rather than two: a
lookup function is the wrong place to publish a presentation decision.

`resolveIcon` is newly exported from `@object-ui/components`, which is the only surface
this adds. `scripts/check-lucide-icon-record-names.mjs` is simplified in the same change:
its census goes from seven sites to one, and its normalisation stops being a
widest-common approximation of three disagreeing resolvers — so the under-reporting that
gate disclosed at objectui#5932 is closed rather than merely bounded.
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,8 +40,8 @@ import {
Sparkles,
Square,
Workflow,
icons as lucideIcons,
} from 'lucide-react';
import { resolveIcon } from '@object-ui/components';
import type { ActionParam } from '@object-ui/types';
import { paramDegradesWithoutTarget, resolveParamWidgetType } from '../../../utils/paramToField.js';
import type { MetadataPreviewProps } from '../preview-registry.js';
Expand DownExpand Up@@ -321,15 +321,23 @@ function FauxButton({
* the author still sees that an icon binding is in place.
*/
function IconHint({ name }: { name: string }) {
const pascal = name
.split(/[-_\s]+/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join('');
const resolved = pascal === 'Home' ? 'House' : pascal;
const Glyph = (lucideIcons as Record<string, React.ComponentType<{ className?: string }>>)[resolved];
// objectui#5935: the normalisation moved to the ONE seam. This site's own
// copy was the WIDEST of the three tokenisers in the tree, so the seam adopts
// its behaviour rather than replacing it — nothing this preview resolved
// before stops resolving now.
//
// ⛔ The seam does not decide the fallback. The name chip below stays here,
// unchanged: an author looking at an action preview needs to see that an icon
// binding is in place even when the glyph does not resolve (maintainer
// ruling 2026-09-03, objectui#5935, option C).
const Glyph = resolveIcon(name);

if (Glyph) {
// The same annotation the other five seam call sites carry: `resolveIcon`
// returns a STABLE component out of lucide's static record, it does not
// create one during render. The rule cannot see that through a call, where
// it could through the record index this line replaced.
// eslint-disable-next-line react-hooks/static-components
return <Glyph className="h-4 w-4" aria-hidden />;
}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#5935 — `ActionPreview` takes the shared seam and KEEPS its name chip.
*
* ## Why this file's rows read differently from the other four surfaces'
*
* This site's own normalisation was `split(/[-_\s]+/)` plus the `Home -> House`
* rename — i.e. it was ALREADY the rule the pre-dispatch enumeration went on to
* measure as the zero-regression one (comment 5522254814). The seam adopted
* this site's width rather than the `split('-')` the other five used.
*
* ⇒ There is NO behavioural row here that could be red before the change, and
* this file does not pretend otherwise. Its discriminating row is STRUCTURAL:
* the seam is SPIED, and before the consolidation this renderer never called it
* — the spy recorded zero calls. That row pins the only thing that actually
* moved at this site: which function the glyph came out of.
*
* Every other row is green in both worlds by construction, which is the point:
* the acceptance criterion for this card is that nothing observable changed on
* the fallback behaviours, and the fourth of the tree's four is this chip.
*
* ## Why the module is SPIED rather than stubbed
*
* `importOriginal` keeps the REAL resolver running against the REAL lucide
* record, so the behaviour rows still test resolution rather than a fixture. A
* stub returning a fixed component would have deleted the half that matters —
* that an unresolvable name yields the chip and not a wrong glyph.
*/

import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';

vi.mock('@object-ui/components', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/components')>();
return { ...actual, resolveIcon: vi.fn(actual.resolveIcon) };
});

import { resolveIcon } from '@object-ui/components';
import { ActionPreview } from '../ActionPreview';

const seam = vi.mocked(resolveIcon);

beforeEach(() => seam.mockClear());
afterEach(cleanup);

function renderPreview(icon: string) {
return render(
<ActionPreview
type="action"
name="new_task"
draft={{ name: 'new_task', label: 'New Task', type: 'script', target: 'true', icon }}
/>,
);
}

describe('ActionPreview icon binding (objectui#5935)', () => {
it('CONTROL — the preview renders its action, whatever the icon does', () => {
// Without this, every "the chip is shown" row below could pass against a
// preview that fell into an error boundary and drew nothing at all.
renderPreview('definitely-not-a-lucide-icon');
expect(screen.getAllByText('New Task').length).toBeGreaterThan(0);
});

describe('routing — the only row here that discriminates', () => {
it('resolves the authored name through the SHARED seam', () => {
// RED before the consolidation: this file carried its own tokeniser and
// rename ternary and never called this function.
renderPreview('file-text');
expect(seam).toHaveBeenCalledWith('file-text');
});

it('draws the glyph the seam returned, not one of its own', () => {
// ⚠️ `toHaveBeenCalled` FIRST. Reading `mock.results[0]?.value` straight
// away is a BLIND instrument — with zero calls it is `undefined`, and
// `expect(undefined).not.toBeNull()` passes.
const { container } = renderPreview('file-text');
expect(seam).toHaveBeenCalled();
expect(seam.mock.results[0].value).not.toBeNull();
expect(container.querySelector('svg.lucide-file-text')).not.toBeNull();
});
});

describe('the name chip — GREEN IN BOTH WORLDS, pinning that nothing moved', () => {
it('falls back to the 3-character chip when the name does not resolve', () => {
// ⭐ The fourth of the tree's four unresolvable behaviours, and the one
// that killed the 2026-08-31 `onUnresolvable: "placeholder" | "null"`
// domain: it is neither. The chip stays HERE (maintainer ruling
// 2026-09-03, comment 5523286738, option C) so an author can still see
// that an icon binding is in place.
const { container } = renderPreview('definitely-not-a-lucide-icon');
expect(container.querySelector('svg.lucide-definitely-not-a-lucide-icon')).toBeNull();
// Positive: the chip is the first three characters, uppercased by CSS but
// authored verbatim in the DOM.
expect(screen.getAllByText('def').length).toBeGreaterThan(0);
});

it('shows no chip when the name DOES resolve', () => {
// The control that makes the row above a reading: the chip is not simply
// always present.
renderPreview('file-text');
expect(screen.queryByText('fil')).toBeNull();
});

it('keeps resolving the spellings this site already accepted', () => {
// Green in both worlds, and load-bearing: the shared tokeniser had to
// ADOPT this site's width. A narrowing to `split('-')` would land here
// first, and it is the regression the enumeration measured at 4,748
// name-surface pairs.
expect(renderPreview('file_text').container.querySelector('svg.lucide-file-text')).not.toBeNull();
cleanup();
expect(renderPreview('file text').container.querySelector('svg.lucide-file-text')).not.toBeNull();
cleanup();
expect(renderPreview('home').container.querySelector('svg.lucide-house')).not.toBeNull();
});
});
});
16 changes: 16 additions & 0 deletions packages/components/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,6 +59,22 @@ export { getLazyIcon, isLucideIconName, LazyIcon, toKebabIconName } from './lib/
// already depends on this package, so the direction costs nothing new.
export { hasDeclaredVisibilityGate } from './renderers/action/visibility-gate';

// THE icon-name seam (objectui#5935) — `name -> LucideIcon | null`, one
// tokeniser and one rename map for the whole repo.
//
// Exported for the same reason `hasDeclaredVisibilityGate` above is: the family
// has members outside this package. Seven modules used to hand-roll this lookup
// with THREE different tokenisers and the `Home -> House` rename on only four of
// them, so the same authored name rendered on one surface and not another —
// `app-shell`'s ActionPreview, `plugin-detail`'s RelatedList, `plugin-list`'s
// ListView and TabBar, and `plugin-view`'s ViewSwitcher now import this one.
// All five already depend on this package, so the direction costs nothing new.
//
// ⛔ Nothing about the FALLBACK is exported, because there is none to export:
// the seam returns `null` and each surface keeps its own visible fallback
// (maintainer ruling 2026-09-03, objectui#5935, option C).
export { resolveIcon } from './renderers/action/resolve-icon';

// Export placeholder registration
export { registerPlaceholders } from './renderers/placeholders';

Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
/**
* 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.
*/

/**
* objectui#5935 — the ONE icon-name seam.
*
* Seven modules used to resolve authored icon names, with THREE tokenisers
* (`split('-')` on five, `split(/[-_\s]/)` on one, `split(/[-_\s]+/)` on one)
* and the `Home -> House` rename on only four, so the same authored name
* rendered on one surface and not another. This file pins what the surviving
* one does.
*
* ## What is being proved, and what could not be proved by rendering
*
* The per-surface suites prove that each call site still draws ITS OWN fallback
* (`null`, the objectui#5631 placeholder, `Inbox`, a name chip). They cannot
* prove the seam's own algebra, because a surface only ever shows "resolved" or
* "did not" — so the tokeniser rows below are here, where the answer is the
* component itself.
*
* ## The adopted rule is MEASURED, not chosen
*
* `split(/[-_\s]+/)` with `Home -> House` universal, from the pre-dispatch
* enumeration the 2026-08-31 ruling required (comment 5522254814). Its
* regression set is EMPTY three ways over: against the authored population,
* against the every-name-x-every-surface cross-product, and against a
* bound-free differential over 8,298 spellings derived from all 1,767 live
* record keys. `split('-')` regresses 4,748 pairs in that last reading, which
* is why it is not adoptable and why the rows below assert the WIDER rule
* rather than the more common one.
*/

import { describe, it, expect } from 'vitest';
import { icons } from 'lucide-react';
import { resolveIcon, describeIconLookup } from '../resolve-icon';

describe('the icon-name seam resolves (objectui#5935)', () => {
/**
* ⭐ Non-vacuity for every "resolves" row below. A `resolveIcon` that returned
* some component for EVERY input would pass them all; a `resolveIcon` that
* returned `null` for every input would pass every fallback row in every
* per-surface suite. Both directions are excluded here, in the same run.
*/
it('DISCRIMINATES — a live name resolves and a dead one does not', () => {
expect(resolveIcon('file-text')).not.toBeNull();
expect(resolveIcon('not-a-real-icon')).toBeNull();
});

it('accepts all four authored spellings of one glyph', () => {
const canonical = icons.ArrowRight;
expect(canonical).toBeDefined();
// kebab — what the docs and most fixtures author.
expect(resolveIcon('arrow-right')).toBe(canonical);
// snake — resolved on TWO of the seven surfaces before this card and on
// five of them not at all. This row is the consolidation.
expect(resolveIcon('arrow_right')).toBe(canonical);
// space-separated — same story.
expect(resolveIcon('arrow right')).toBe(canonical);
// already-Pascal — authored in real fixtures, must not be mangled.
expect(resolveIcon('ArrowRight')).toBe(canonical);
});

it('collapses repeated and mixed separators', () => {
// `+` in the tokeniser. The equivalent spelling without it produced empty
// tokens, which capitalise to nothing and join to nothing — measured
// identical over 51,449 hostile spellings, and pinned here so the two
// spellings are not "fixed" apart later.
expect(resolveIcon('arrow--right')).toBe(icons.ArrowRight);
expect(resolveIcon('arrow-_ right')).toBe(icons.ArrowRight);
});

it('applies the `Home` -> `House` rename, which is the ONLY rename', () => {
// lucide dropped `Home` from its runtime record and kept `House`. The map
// exists so a name that used to resolve still does — it is not a general
// alias table, and nothing else belongs in it.
expect(icons).not.toHaveProperty('Home');
expect(resolveIcon('home')).toBe(icons.House);
expect(resolveIcon('Home')).toBe(icons.House);
expect(describeIconLookup('home')).toEqual({ pascal: 'Home', key: 'House' });
// The control: an UNMAPPED name passes through both halves unchanged, so
// the row above is about the map and not about `describeIconLookup` always
// answering `House`.
expect(describeIconLookup('file-text')).toEqual({ pascal: 'FileText', key: 'FileText' });
});

it('returns null — never a fallback glyph — for absent and unresolvable names', () => {
// ⭐ The contract the 2026-09-03 maintainer ruling (option C) fixed: the
// seam does `name -> component`, and NOTHING about what a surface draws
// when there is no component. Each call site keeps its own fallback, so
// this function must never acquire one, and must never acquire a parameter
// for choosing one either.
expect(resolveIcon(undefined)).toBeNull();
expect(resolveIcon('')).toBeNull();
expect(resolveIcon('definitely-not-a-lucide-icon')).toBeNull();
// A RETIRED spelling: `Edit` still imports and still renders, but its key
// is gone from the runtime record. Rules out a resolver that reached for
// the named exports instead — a third, more forgiving vocabulary.
expect(resolveIcon('edit')).toBeNull();
expect(resolveIcon('square-pen')).toBe(icons.SquarePen);
});

it('takes the seam FUNCTION, not a re-derived string, as the answer', () => {
// `describeIconLookup` exists only so `renderers/basic/icon.tsx` can name
// both halves in its objectui#5631 warning without a second copy of the
// tokeniser. Pinned as CONSISTENT with `resolveIcon` so the diagnostic can
// never describe a lookup that did not happen.
for (const authored of ['home', 'file-text', 'arrow_right', 'not-a-real-icon']) {
const { key } = describeIconLookup(authored);
const expected = Object.prototype.hasOwnProperty.call(icons, key)
? (icons as Record<string, unknown>)[key]
: null;
expect(resolveIcon(authored)).toBe(expected);
}
});

it('is what the widening promised: the OLD resolving sets are strict subsets', () => {
// Why no name could regress, made concrete. The old narrow tokeniser is
// re-implemented HERE, in the test, so the claim is checked rather than
// asserted — every name it resolved must still resolve, and the two names
// the enumeration named as newly-resolving must now do so.
const narrow = (name: string) => {
const pascal = name.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
const mapped = pascal === 'Home' ? 'House' : pascal;
return Object.prototype.hasOwnProperty.call(icons, mapped)
? (icons as Record<string, unknown>)[mapped]
: null;
};
let carried = 0;
for (const key of Object.keys(icons)) {
// The kebab spelling of every live glyph — what the narrow tokeniser
// could resolve at all.
const kebab = key.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
const before = narrow(kebab);
if (before === null) continue;
carried += 1;
expect(resolveIcon(kebab), `${kebab} stopped resolving`).toBe(before);
}
// Non-vacuity: a loop that skipped everything would pass silently.
expect(carried).toBeGreaterThan(1000);
// And the widening the enumeration measured, in both of its named cases.
expect(narrow('building_2')).toBeNull();
expect(resolveIcon('building_2')).toBe(icons.Building2);
expect(narrow('layout_dashboard')).toBeNull();
expect(resolveIcon('layout_dashboard')).toBe(icons.LayoutDashboard);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/5935-one-icon-resolver-seam.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
'@object-ui/components': minor
'@object-ui/app-shell': minor
'@object-ui/plugin-detail': minor
'@object-ui/plugin-list': minor
'@object-ui/plugin-view': minor
---

Consolidate the seven lucide icon-name resolvers into one seam (objectui#5935).

Seven modules resolved authored icon names into lucide's runtime `icons` record, each
with its own copy of the logic: **three different tokenisers** (`split('-')` on five of
them, `split(/[-_\s]/)` on one, `split(/[-_\s]+/)` on one) and the `Home` -> `House`
rename on only **four** of the seven. The same authored name therefore rendered on one
surface and not another — the sidebar-vs-action-bar disagreement objectui#5633 opened
with. There is now one resolver, `resolveIcon`, exported from `@object-ui/components`,
and the other six call it.

**The tokeniser is `split(/[-_\s]+/)` with `Home` -> `House` applied universally, and it
was measured rather than chosen.** Its regression set is empty three independent ways:
against the authored population, against a maximally-pessimistic every-authored-name x
every-surface cross-product, and against a bound-free differential over 8,298 spellings
derived from all 1,767 live record keys — each with a discrimination control that fired
in the same run. `split('-')` was **not** adoptable: it regresses 4,748 name-surface
pairs in that last reading, stripping two surfaces of every snake_case and
space-separated spelling they resolve today.

**What changes for you — all of it widening, none of it removal.** No name that resolved
before stops resolving: no key of lucide's record contains `_`, whitespace or `-`
(measured: 0 of 1,767), so whenever the old narrow tokeniser produced a live key the
wider one produces the same key. Sixteen name-surface pairs start resolving where they
rendered a fallback or nothing before:

- `layout_dashboard` and `building_2` (and every other snake_case or space-separated
spelling) now resolve on the shared resolver, `ui:icon`, `ListView`'s empty state,
`TabBar` and `ViewSwitcher` — they previously resolved only on the action preview and
the related list.
- `home` / `Home` now resolves on `RelatedList`, `ListView` and `TabBar`, which carried
no rename map. `Home` is not a live record key, so this could only ever be a widening.

**What does NOT change: what each surface draws when a name does not resolve.** The seam
answers `name -> component`, returning `null`, and decides nothing else (maintainer
ruling 2026-09-03 on objectui#5935). Every call site keeps its own fallback, visibly, at
the call site: `ui:icon` keeps its `SquareDashed` placeholder and its warning
(objectui#5631, untouched), `RelatedList` and `ListView` keep their `Inbox` glyph,
`ActionPreview` keeps its three-character name chip, and the shared resolver, `TabBar`
and `ViewSwitcher` keep `null`. A two-valued `onUnresolvable` parameter was ruled on and
then dropped once the tree was measured to have four such behaviours rather than two: a
lookup function is the wrong place to publish a presentation decision.

`resolveIcon` is newly exported from `@object-ui/components`, which is the only surface
this adds. `scripts/check-lucide-icon-record-names.mjs` is simplified in the same change:
its census goes from seven sites to one, and its normalisation stops being a
widest-common approximation of three disagreeing resolvers — so the under-reporting that
gate disclosed at objectui#5932 is closed rather than merely bounded.
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,8 +40,8 @@ import {
Sparkles,
Square,
Workflow,
icons as lucideIcons,
} from 'lucide-react';
import { resolveIcon } from '@object-ui/components';
import type { ActionParam } from '@object-ui/types';
import { paramDegradesWithoutTarget, resolveParamWidgetType } from '../../../utils/paramToField.js';
import type { MetadataPreviewProps } from '../preview-registry.js';
Expand DownExpand Up@@ -321,15 +321,23 @@ function FauxButton({
* the author still sees that an icon binding is in place.
*/
function IconHint({ name }: { name: string }) {
const pascal = name
.split(/[-_\s]+/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join('');
const resolved = pascal === 'Home' ? 'House' : pascal;
const Glyph = (lucideIcons as Record<string, React.ComponentType<{ className?: string }>>)[resolved];
// objectui#5935: the normalisation moved to the ONE seam. This site's own
// copy was the WIDEST of the three tokenisers in the tree, so the seam adopts
// its behaviour rather than replacing it — nothing this preview resolved
// before stops resolving now.
//
// ⛔ The seam does not decide the fallback. The name chip below stays here,
// unchanged: an author looking at an action preview needs to see that an icon
// binding is in place even when the glyph does not resolve (maintainer
// ruling 2026-09-03, objectui#5935, option C).
const Glyph = resolveIcon(name);

if (Glyph) {
// The same annotation the other five seam call sites carry: `resolveIcon`
// returns a STABLE component out of lucide's static record, it does not
// create one during render. The rule cannot see that through a call, where
// it could through the record index this line replaced.
// eslint-disable-next-line react-hooks/static-components
return <Glyph className="h-4 w-4" aria-hidden />;
}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#5935 — `ActionPreview` takes the shared seam and KEEPS its name chip.
*
* ## Why this file's rows read differently from the other four surfaces'
*
* This site's own normalisation was `split(/[-_\s]+/)` plus the `Home -> House`
* rename — i.e. it was ALREADY the rule the pre-dispatch enumeration went on to
* measure as the zero-regression one (comment 5522254814). The seam adopted
* this site's width rather than the `split('-')` the other five used.
*
* ⇒ There is NO behavioural row here that could be red before the change, and
* this file does not pretend otherwise. Its discriminating row is STRUCTURAL:
* the seam is SPIED, and before the consolidation this renderer never called it
* — the spy recorded zero calls. That row pins the only thing that actually
* moved at this site: which function the glyph came out of.
*
* Every other row is green in both worlds by construction, which is the point:
* the acceptance criterion for this card is that nothing observable changed on
* the fallback behaviours, and the fourth of the tree's four is this chip.
*
* ## Why the module is SPIED rather than stubbed
*
* `importOriginal` keeps the REAL resolver running against the REAL lucide
* record, so the behaviour rows still test resolution rather than a fixture. A
* stub returning a fixed component would have deleted the half that matters —
* that an unresolvable name yields the chip and not a wrong glyph.
*/

import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';

vi.mock('@object-ui/components', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/components')>();
return { ...actual, resolveIcon: vi.fn(actual.resolveIcon) };
});

import { resolveIcon } from '@object-ui/components';
import { ActionPreview } from '../ActionPreview';

const seam = vi.mocked(resolveIcon);

beforeEach(() => seam.mockClear());
afterEach(cleanup);

function renderPreview(icon: string) {
return render(
<ActionPreview
type="action"
name="new_task"
draft={{ name: 'new_task', label: 'New Task', type: 'script', target: 'true', icon }}
/>,
);
}

describe('ActionPreview icon binding (objectui#5935)', () => {
it('CONTROL — the preview renders its action, whatever the icon does', () => {
// Without this, every "the chip is shown" row below could pass against a
// preview that fell into an error boundary and drew nothing at all.
renderPreview('definitely-not-a-lucide-icon');
expect(screen.getAllByText('New Task').length).toBeGreaterThan(0);
});

describe('routing — the only row here that discriminates', () => {
it('resolves the authored name through the SHARED seam', () => {
// RED before the consolidation: this file carried its own tokeniser and
// rename ternary and never called this function.
renderPreview('file-text');
expect(seam).toHaveBeenCalledWith('file-text');
});

it('draws the glyph the seam returned, not one of its own', () => {
// ⚠️ `toHaveBeenCalled` FIRST. Reading `mock.results[0]?.value` straight
// away is a BLIND instrument — with zero calls it is `undefined`, and
// `expect(undefined).not.toBeNull()` passes.
const { container } = renderPreview('file-text');
expect(seam).toHaveBeenCalled();
expect(seam.mock.results[0].value).not.toBeNull();
expect(container.querySelector('svg.lucide-file-text')).not.toBeNull();
});
});

describe('the name chip — GREEN IN BOTH WORLDS, pinning that nothing moved', () => {
it('falls back to the 3-character chip when the name does not resolve', () => {
// ⭐ The fourth of the tree's four unresolvable behaviours, and the one
// that killed the 2026-08-31 `onUnresolvable: "placeholder" | "null"`
// domain: it is neither. The chip stays HERE (maintainer ruling
// 2026-09-03, comment 5523286738, option C) so an author can still see
// that an icon binding is in place.
const { container } = renderPreview('definitely-not-a-lucide-icon');
expect(container.querySelector('svg.lucide-definitely-not-a-lucide-icon')).toBeNull();
// Positive: the chip is the first three characters, uppercased by CSS but
// authored verbatim in the DOM.
expect(screen.getAllByText('def').length).toBeGreaterThan(0);
});

it('shows no chip when the name DOES resolve', () => {
// The control that makes the row above a reading: the chip is not simply
// always present.
renderPreview('file-text');
expect(screen.queryByText('fil')).toBeNull();
});

it('keeps resolving the spellings this site already accepted', () => {
// Green in both worlds, and load-bearing: the shared tokeniser had to
// ADOPT this site's width. A narrowing to `split('-')` would land here
// first, and it is the regression the enumeration measured at 4,748
// name-surface pairs.
expect(renderPreview('file_text').container.querySelector('svg.lucide-file-text')).not.toBeNull();
cleanup();
expect(renderPreview('file text').container.querySelector('svg.lucide-file-text')).not.toBeNull();
cleanup();
expect(renderPreview('home').container.querySelector('svg.lucide-house')).not.toBeNull();
});
});
});
16 changes: 16 additions & 0 deletions packages/components/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,6 +59,22 @@ export { getLazyIcon, isLucideIconName, LazyIcon, toKebabIconName } from './lib/
// already depends on this package, so the direction costs nothing new.
export { hasDeclaredVisibilityGate } from './renderers/action/visibility-gate';

// THE icon-name seam (objectui#5935) — `name -> LucideIcon | null`, one
// tokeniser and one rename map for the whole repo.
//
// Exported for the same reason `hasDeclaredVisibilityGate` above is: the family
// has members outside this package. Seven modules used to hand-roll this lookup
// with THREE different tokenisers and the `Home -> House` rename on only four of
// them, so the same authored name rendered on one surface and not another —
// `app-shell`'s ActionPreview, `plugin-detail`'s RelatedList, `plugin-list`'s
// ListView and TabBar, and `plugin-view`'s ViewSwitcher now import this one.
// All five already depend on this package, so the direction costs nothing new.
//
// ⛔ Nothing about the FALLBACK is exported, because there is none to export:
// the seam returns `null` and each surface keeps its own visible fallback
// (maintainer ruling 2026-09-03, objectui#5935, option C).
export { resolveIcon } from './renderers/action/resolve-icon';

// Export placeholder registration
export { registerPlaceholders } from './renderers/placeholders';

Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
/**
* 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.
*/

/**
* objectui#5935 — the ONE icon-name seam.
*
* Seven modules used to resolve authored icon names, with THREE tokenisers
* (`split('-')` on five, `split(/[-_\s]/)` on one, `split(/[-_\s]+/)` on one)
* and the `Home -> House` rename on only four, so the same authored name
* rendered on one surface and not another. This file pins what the surviving
* one does.
*
* ## What is being proved, and what could not be proved by rendering
*
* The per-surface suites prove that each call site still draws ITS OWN fallback
* (`null`, the objectui#5631 placeholder, `Inbox`, a name chip). They cannot
* prove the seam's own algebra, because a surface only ever shows "resolved" or
* "did not" — so the tokeniser rows below are here, where the answer is the
* component itself.
*
* ## The adopted rule is MEASURED, not chosen
*
* `split(/[-_\s]+/)` with `Home -> House` universal, from the pre-dispatch
* enumeration the 2026-08-31 ruling required (comment 5522254814). Its
* regression set is EMPTY three ways over: against the authored population,
* against the every-name-x-every-surface cross-product, and against a
* bound-free differential over 8,298 spellings derived from all 1,767 live
* record keys. `split('-')` regresses 4,748 pairs in that last reading, which
* is why it is not adoptable and why the rows below assert the WIDER rule
* rather than the more common one.
*/

import { describe, it, expect } from 'vitest';
import { icons } from 'lucide-react';
import { resolveIcon, describeIconLookup } from '../resolve-icon';

describe('the icon-name seam resolves (objectui#5935)', () => {
/**
* ⭐ Non-vacuity for every "resolves" row below. A `resolveIcon` that returned
* some component for EVERY input would pass them all; a `resolveIcon` that
* returned `null` for every input would pass every fallback row in every
* per-surface suite. Both directions are excluded here, in the same run.
*/
it('DISCRIMINATES — a live name resolves and a dead one does not', () => {
expect(resolveIcon('file-text')).not.toBeNull();
expect(resolveIcon('not-a-real-icon')).toBeNull();
});

it('accepts all four authored spellings of one glyph', () => {
const canonical = icons.ArrowRight;
expect(canonical).toBeDefined();
// kebab — what the docs and most fixtures author.
expect(resolveIcon('arrow-right')).toBe(canonical);
// snake — resolved on TWO of the seven surfaces before this card and on
// five of them not at all. This row is the consolidation.
expect(resolveIcon('arrow_right')).toBe(canonical);
// space-separated — same story.
expect(resolveIcon('arrow right')).toBe(canonical);
// already-Pascal — authored in real fixtures, must not be mangled.
expect(resolveIcon('ArrowRight')).toBe(canonical);
});

it('collapses repeated and mixed separators', () => {
// `+` in the tokeniser. The equivalent spelling without it produced empty
// tokens, which capitalise to nothing and join to nothing — measured
// identical over 51,449 hostile spellings, and pinned here so the two
// spellings are not "fixed" apart later.
expect(resolveIcon('arrow--right')).toBe(icons.ArrowRight);
expect(resolveIcon('arrow-_ right')).toBe(icons.ArrowRight);
});

it('applies the `Home` -> `House` rename, which is the ONLY rename', () => {
// lucide dropped `Home` from its runtime record and kept `House`. The map
// exists so a name that used to resolve still does — it is not a general
// alias table, and nothing else belongs in it.
expect(icons).not.toHaveProperty('Home');
expect(resolveIcon('home')).toBe(icons.House);
expect(resolveIcon('Home')).toBe(icons.House);
expect(describeIconLookup('home')).toEqual({ pascal: 'Home', key: 'House' });
// The control: an UNMAPPED name passes through both halves unchanged, so
// the row above is about the map and not about `describeIconLookup` always
// answering `House`.
expect(describeIconLookup('file-text')).toEqual({ pascal: 'FileText', key: 'FileText' });
});

it('returns null — never a fallback glyph — for absent and unresolvable names', () => {
// ⭐ The contract the 2026-09-03 maintainer ruling (option C) fixed: the
// seam does `name -> component`, and NOTHING about what a surface draws
// when there is no component. Each call site keeps its own fallback, so
// this function must never acquire one, and must never acquire a parameter
// for choosing one either.
expect(resolveIcon(undefined)).toBeNull();
expect(resolveIcon('')).toBeNull();
expect(resolveIcon('definitely-not-a-lucide-icon')).toBeNull();
// A RETIRED spelling: `Edit` still imports and still renders, but its key
// is gone from the runtime record. Rules out a resolver that reached for
// the named exports instead — a third, more forgiving vocabulary.
expect(resolveIcon('edit')).toBeNull();
expect(resolveIcon('square-pen')).toBe(icons.SquarePen);
});

it('takes the seam FUNCTION, not a re-derived string, as the answer', () => {
// `describeIconLookup` exists only so `renderers/basic/icon.tsx` can name
// both halves in its objectui#5631 warning without a second copy of the
// tokeniser. Pinned as CONSISTENT with `resolveIcon` so the diagnostic can
// never describe a lookup that did not happen.
for (const authored of ['home', 'file-text', 'arrow_right', 'not-a-real-icon']) {
const { key } = describeIconLookup(authored);
const expected = Object.prototype.hasOwnProperty.call(icons, key)
? (icons as Record<string, unknown>)[key]
: null;
expect(resolveIcon(authored)).toBe(expected);
}
});

it('is what the widening promised: the OLD resolving sets are strict subsets', () => {
// Why no name could regress, made concrete. The old narrow tokeniser is
// re-implemented HERE, in the test, so the claim is checked rather than
// asserted — every name it resolved must still resolve, and the two names
// the enumeration named as newly-resolving must now do so.
const narrow = (name: string) => {
const pascal = name.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
const mapped = pascal === 'Home' ? 'House' : pascal;
return Object.prototype.hasOwnProperty.call(icons, mapped)
? (icons as Record<string, unknown>)[mapped]
: null;
};
let carried = 0;
for (const key of Object.keys(icons)) {
// The kebab spelling of every live glyph — what the narrow tokeniser
// could resolve at all.
const kebab = key.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
const before = narrow(kebab);
if (before === null) continue;
carried += 1;
expect(resolveIcon(kebab), `${kebab} stopped resolving`).toBe(before);
}
// Non-vacuity: a loop that skipped everything would pass silently.
expect(carried).toBeGreaterThan(1000);
// And the widening the enumeration measured, in both of its named cases.
expect(narrow('building_2')).toBeNull();
expect(resolveIcon('building_2')).toBe(icons.Building2);
expect(narrow('layout_dashboard')).toBeNull();
expect(resolveIcon('layout_dashboard')).toBe(icons.LayoutDashboard);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/5935-one-icon-resolver-seam.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
'@object-ui/components': minor
'@object-ui/app-shell': minor
'@object-ui/plugin-detail': minor
'@object-ui/plugin-list': minor
'@object-ui/plugin-view': minor
---

Consolidate the seven lucide icon-name resolvers into one seam (objectui#5935).

Seven modules resolved authored icon names into lucide's runtime `icons` record, each
with its own copy of the logic: **three different tokenisers** (`split('-')` on five of
them, `split(/[-_\s]/)` on one, `split(/[-_\s]+/)` on one) and the `Home` -> `House`
rename on only **four** of the seven. The same authored name therefore rendered on one
surface and not another — the sidebar-vs-action-bar disagreement objectui#5633 opened
with. There is now one resolver, `resolveIcon`, exported from `@object-ui/components`,
and the other six call it.

**The tokeniser is `split(/[-_\s]+/)` with `Home` -> `House` applied universally, and it
was measured rather than chosen.** Its regression set is empty three independent ways:
against the authored population, against a maximally-pessimistic every-authored-name x
every-surface cross-product, and against a bound-free differential over 8,298 spellings
derived from all 1,767 live record keys — each with a discrimination control that fired
in the same run. `split('-')` was **not** adoptable: it regresses 4,748 name-surface
pairs in that last reading, stripping two surfaces of every snake_case and
space-separated spelling they resolve today.

**What changes for you — all of it widening, none of it removal.** No name that resolved
before stops resolving: no key of lucide's record contains `_`, whitespace or `-`
(measured: 0 of 1,767), so whenever the old narrow tokeniser produced a live key the
wider one produces the same key. Sixteen name-surface pairs start resolving where they
rendered a fallback or nothing before:

- `layout_dashboard` and `building_2` (and every other snake_case or space-separated
spelling) now resolve on the shared resolver, `ui:icon`, `ListView`'s empty state,
`TabBar` and `ViewSwitcher` — they previously resolved only on the action preview and
the related list.
- `home` / `Home` now resolves on `RelatedList`, `ListView` and `TabBar`, which carried
no rename map. `Home` is not a live record key, so this could only ever be a widening.

**What does NOT change: what each surface draws when a name does not resolve.** The seam
answers `name -> component`, returning `null`, and decides nothing else (maintainer
ruling 2026-09-03 on objectui#5935). Every call site keeps its own fallback, visibly, at
the call site: `ui:icon` keeps its `SquareDashed` placeholder and its warning
(objectui#5631, untouched), `RelatedList` and `ListView` keep their `Inbox` glyph,
`ActionPreview` keeps its three-character name chip, and the shared resolver, `TabBar`
and `ViewSwitcher` keep `null`. A two-valued `onUnresolvable` parameter was ruled on and
then dropped once the tree was measured to have four such behaviours rather than two: a
lookup function is the wrong place to publish a presentation decision.

`resolveIcon` is newly exported from `@object-ui/components`, which is the only surface
this adds. `scripts/check-lucide-icon-record-names.mjs` is simplified in the same change:
its census goes from seven sites to one, and its normalisation stops being a
widest-common approximation of three disagreeing resolvers — so the under-reporting that
gate disclosed at objectui#5932 is closed rather than merely bounded.
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,8 +40,8 @@ import {
Sparkles,
Square,
Workflow,
icons as lucideIcons,
} from 'lucide-react';
import { resolveIcon } from '@object-ui/components';
import type { ActionParam } from '@object-ui/types';
import { paramDegradesWithoutTarget, resolveParamWidgetType } from '../../../utils/paramToField.js';
import type { MetadataPreviewProps } from '../preview-registry.js';
Expand DownExpand Up@@ -321,15 +321,23 @@ function FauxButton({
* the author still sees that an icon binding is in place.
*/
function IconHint({ name }: { name: string }) {
const pascal = name
.split(/[-_\s]+/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join('');
const resolved = pascal === 'Home' ? 'House' : pascal;
const Glyph = (lucideIcons as Record<string, React.ComponentType<{ className?: string }>>)[resolved];
// objectui#5935: the normalisation moved to the ONE seam. This site's own
// copy was the WIDEST of the three tokenisers in the tree, so the seam adopts
// its behaviour rather than replacing it — nothing this preview resolved
// before stops resolving now.
//
// ⛔ The seam does not decide the fallback. The name chip below stays here,
// unchanged: an author looking at an action preview needs to see that an icon
// binding is in place even when the glyph does not resolve (maintainer
// ruling 2026-09-03, objectui#5935, option C).
const Glyph = resolveIcon(name);

if (Glyph) {
// The same annotation the other five seam call sites carry: `resolveIcon`
// returns a STABLE component out of lucide's static record, it does not
// create one during render. The rule cannot see that through a call, where
// it could through the record index this line replaced.
// eslint-disable-next-line react-hooks/static-components
return <Glyph className="h-4 w-4" aria-hidden />;
}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#5935 — `ActionPreview` takes the shared seam and KEEPS its name chip.
*
* ## Why this file's rows read differently from the other four surfaces'
*
* This site's own normalisation was `split(/[-_\s]+/)` plus the `Home -> House`
* rename — i.e. it was ALREADY the rule the pre-dispatch enumeration went on to
* measure as the zero-regression one (comment 5522254814). The seam adopted
* this site's width rather than the `split('-')` the other five used.
*
* ⇒ There is NO behavioural row here that could be red before the change, and
* this file does not pretend otherwise. Its discriminating row is STRUCTURAL:
* the seam is SPIED, and before the consolidation this renderer never called it
* — the spy recorded zero calls. That row pins the only thing that actually
* moved at this site: which function the glyph came out of.
*
* Every other row is green in both worlds by construction, which is the point:
* the acceptance criterion for this card is that nothing observable changed on
* the fallback behaviours, and the fourth of the tree's four is this chip.
*
* ## Why the module is SPIED rather than stubbed
*
* `importOriginal` keeps the REAL resolver running against the REAL lucide
* record, so the behaviour rows still test resolution rather than a fixture. A
* stub returning a fixed component would have deleted the half that matters —
* that an unresolvable name yields the chip and not a wrong glyph.
*/

import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';

vi.mock('@object-ui/components', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/components')>();
return { ...actual, resolveIcon: vi.fn(actual.resolveIcon) };
});

import { resolveIcon } from '@object-ui/components';
import { ActionPreview } from '../ActionPreview';

const seam = vi.mocked(resolveIcon);

beforeEach(() => seam.mockClear());
afterEach(cleanup);

function renderPreview(icon: string) {
return render(
<ActionPreview
type="action"
name="new_task"
draft={{ name: 'new_task', label: 'New Task', type: 'script', target: 'true', icon }}
/>,
);
}

describe('ActionPreview icon binding (objectui#5935)', () => {
it('CONTROL — the preview renders its action, whatever the icon does', () => {
// Without this, every "the chip is shown" row below could pass against a
// preview that fell into an error boundary and drew nothing at all.
renderPreview('definitely-not-a-lucide-icon');
expect(screen.getAllByText('New Task').length).toBeGreaterThan(0);
});

describe('routing — the only row here that discriminates', () => {
it('resolves the authored name through the SHARED seam', () => {
// RED before the consolidation: this file carried its own tokeniser and
// rename ternary and never called this function.
renderPreview('file-text');
expect(seam).toHaveBeenCalledWith('file-text');
});

it('draws the glyph the seam returned, not one of its own', () => {
// ⚠️ `toHaveBeenCalled` FIRST. Reading `mock.results[0]?.value` straight
// away is a BLIND instrument — with zero calls it is `undefined`, and
// `expect(undefined).not.toBeNull()` passes.
const { container } = renderPreview('file-text');
expect(seam).toHaveBeenCalled();
expect(seam.mock.results[0].value).not.toBeNull();
expect(container.querySelector('svg.lucide-file-text')).not.toBeNull();
});
});

describe('the name chip — GREEN IN BOTH WORLDS, pinning that nothing moved', () => {
it('falls back to the 3-character chip when the name does not resolve', () => {
// ⭐ The fourth of the tree's four unresolvable behaviours, and the one
// that killed the 2026-08-31 `onUnresolvable: "placeholder" | "null"`
// domain: it is neither. The chip stays HERE (maintainer ruling
// 2026-09-03, comment 5523286738, option C) so an author can still see
// that an icon binding is in place.
const { container } = renderPreview('definitely-not-a-lucide-icon');
expect(container.querySelector('svg.lucide-definitely-not-a-lucide-icon')).toBeNull();
// Positive: the chip is the first three characters, uppercased by CSS but
// authored verbatim in the DOM.
expect(screen.getAllByText('def').length).toBeGreaterThan(0);
});

it('shows no chip when the name DOES resolve', () => {
// The control that makes the row above a reading: the chip is not simply
// always present.
renderPreview('file-text');
expect(screen.queryByText('fil')).toBeNull();
});

it('keeps resolving the spellings this site already accepted', () => {
// Green in both worlds, and load-bearing: the shared tokeniser had to
// ADOPT this site's width. A narrowing to `split('-')` would land here
// first, and it is the regression the enumeration measured at 4,748
// name-surface pairs.
expect(renderPreview('file_text').container.querySelector('svg.lucide-file-text')).not.toBeNull();
cleanup();
expect(renderPreview('file text').container.querySelector('svg.lucide-file-text')).not.toBeNull();
cleanup();
expect(renderPreview('home').container.querySelector('svg.lucide-house')).not.toBeNull();
});
});
});
16 changes: 16 additions & 0 deletions packages/components/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,6 +59,22 @@ export { getLazyIcon, isLucideIconName, LazyIcon, toKebabIconName } from './lib/
// already depends on this package, so the direction costs nothing new.
export { hasDeclaredVisibilityGate } from './renderers/action/visibility-gate';

// THE icon-name seam (objectui#5935) — `name -> LucideIcon | null`, one
// tokeniser and one rename map for the whole repo.
//
// Exported for the same reason `hasDeclaredVisibilityGate` above is: the family
// has members outside this package. Seven modules used to hand-roll this lookup
// with THREE different tokenisers and the `Home -> House` rename on only four of
// them, so the same authored name rendered on one surface and not another —
// `app-shell`'s ActionPreview, `plugin-detail`'s RelatedList, `plugin-list`'s
// ListView and TabBar, and `plugin-view`'s ViewSwitcher now import this one.
// All five already depend on this package, so the direction costs nothing new.
//
// ⛔ Nothing about the FALLBACK is exported, because there is none to export:
// the seam returns `null` and each surface keeps its own visible fallback
// (maintainer ruling 2026-09-03, objectui#5935, option C).
export { resolveIcon } from './renderers/action/resolve-icon';

// Export placeholder registration
export { registerPlaceholders } from './renderers/placeholders';

Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
/**
* 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.
*/

/**
* objectui#5935 — the ONE icon-name seam.
*
* Seven modules used to resolve authored icon names, with THREE tokenisers
* (`split('-')` on five, `split(/[-_\s]/)` on one, `split(/[-_\s]+/)` on one)
* and the `Home -> House` rename on only four, so the same authored name
* rendered on one surface and not another. This file pins what the surviving
* one does.
*
* ## What is being proved, and what could not be proved by rendering
*
* The per-surface suites prove that each call site still draws ITS OWN fallback
* (`null`, the objectui#5631 placeholder, `Inbox`, a name chip). They cannot
* prove the seam's own algebra, because a surface only ever shows "resolved" or
* "did not" — so the tokeniser rows below are here, where the answer is the
* component itself.
*
* ## The adopted rule is MEASURED, not chosen
*
* `split(/[-_\s]+/)` with `Home -> House` universal, from the pre-dispatch
* enumeration the 2026-08-31 ruling required (comment 5522254814). Its
* regression set is EMPTY three ways over: against the authored population,
* against the every-name-x-every-surface cross-product, and against a
* bound-free differential over 8,298 spellings derived from all 1,767 live
* record keys. `split('-')` regresses 4,748 pairs in that last reading, which
* is why it is not adoptable and why the rows below assert the WIDER rule
* rather than the more common one.
*/

import { describe, it, expect } from 'vitest';
import { icons } from 'lucide-react';
import { resolveIcon, describeIconLookup } from '../resolve-icon';

describe('the icon-name seam resolves (objectui#5935)', () => {
/**
* ⭐ Non-vacuity for every "resolves" row below. A `resolveIcon` that returned
* some component for EVERY input would pass them all; a `resolveIcon` that
* returned `null` for every input would pass every fallback row in every
* per-surface suite. Both directions are excluded here, in the same run.
*/
it('DISCRIMINATES — a live name resolves and a dead one does not', () => {
expect(resolveIcon('file-text')).not.toBeNull();
expect(resolveIcon('not-a-real-icon')).toBeNull();
});

it('accepts all four authored spellings of one glyph', () => {
const canonical = icons.ArrowRight;
expect(canonical).toBeDefined();
// kebab — what the docs and most fixtures author.
expect(resolveIcon('arrow-right')).toBe(canonical);
// snake — resolved on TWO of the seven surfaces before this card and on
// five of them not at all. This row is the consolidation.
expect(resolveIcon('arrow_right')).toBe(canonical);
// space-separated — same story.
expect(resolveIcon('arrow right')).toBe(canonical);
// already-Pascal — authored in real fixtures, must not be mangled.
expect(resolveIcon('ArrowRight')).toBe(canonical);
});

it('collapses repeated and mixed separators', () => {
// `+` in the tokeniser. The equivalent spelling without it produced empty
// tokens, which capitalise to nothing and join to nothing — measured
// identical over 51,449 hostile spellings, and pinned here so the two
// spellings are not "fixed" apart later.
expect(resolveIcon('arrow--right')).toBe(icons.ArrowRight);
expect(resolveIcon('arrow-_ right')).toBe(icons.ArrowRight);
});

it('applies the `Home` -> `House` rename, which is the ONLY rename', () => {
// lucide dropped `Home` from its runtime record and kept `House`. The map
// exists so a name that used to resolve still does — it is not a general
// alias table, and nothing else belongs in it.
expect(icons).not.toHaveProperty('Home');
expect(resolveIcon('home')).toBe(icons.House);
expect(resolveIcon('Home')).toBe(icons.House);
expect(describeIconLookup('home')).toEqual({ pascal: 'Home', key: 'House' });
// The control: an UNMAPPED name passes through both halves unchanged, so
// the row above is about the map and not about `describeIconLookup` always
// answering `House`.
expect(describeIconLookup('file-text')).toEqual({ pascal: 'FileText', key: 'FileText' });
});

it('returns null — never a fallback glyph — for absent and unresolvable names', () => {
// ⭐ The contract the 2026-09-03 maintainer ruling (option C) fixed: the
// seam does `name -> component`, and NOTHING about what a surface draws
// when there is no component. Each call site keeps its own fallback, so
// this function must never acquire one, and must never acquire a parameter
// for choosing one either.
expect(resolveIcon(undefined)).toBeNull();
expect(resolveIcon('')).toBeNull();
expect(resolveIcon('definitely-not-a-lucide-icon')).toBeNull();
// A RETIRED spelling: `Edit` still imports and still renders, but its key
// is gone from the runtime record. Rules out a resolver that reached for
// the named exports instead — a third, more forgiving vocabulary.
expect(resolveIcon('edit')).toBeNull();
expect(resolveIcon('square-pen')).toBe(icons.SquarePen);
});

it('takes the seam FUNCTION, not a re-derived string, as the answer', () => {
// `describeIconLookup` exists only so `renderers/basic/icon.tsx` can name
// both halves in its objectui#5631 warning without a second copy of the
// tokeniser. Pinned as CONSISTENT with `resolveIcon` so the diagnostic can
// never describe a lookup that did not happen.
for (const authored of ['home', 'file-text', 'arrow_right', 'not-a-real-icon']) {
const { key } = describeIconLookup(authored);
const expected = Object.prototype.hasOwnProperty.call(icons, key)
? (icons as Record<string, unknown>)[key]
: null;
expect(resolveIcon(authored)).toBe(expected);
}
});

it('is what the widening promised: the OLD resolving sets are strict subsets', () => {
// Why no name could regress, made concrete. The old narrow tokeniser is
// re-implemented HERE, in the test, so the claim is checked rather than
// asserted — every name it resolved must still resolve, and the two names
// the enumeration named as newly-resolving must now do so.
const narrow = (name: string) => {
const pascal = name.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
const mapped = pascal === 'Home' ? 'House' : pascal;
return Object.prototype.hasOwnProperty.call(icons, mapped)
? (icons as Record<string, unknown>)[mapped]
: null;
};
let carried = 0;
for (const key of Object.keys(icons)) {
// The kebab spelling of every live glyph — what the narrow tokeniser
// could resolve at all.
const kebab = key.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
const before = narrow(kebab);
if (before === null) continue;
carried += 1;
expect(resolveIcon(kebab), `${kebab} stopped resolving`).toBe(before);
}
// Non-vacuity: a loop that skipped everything would pass silently.
expect(carried).toBeGreaterThan(1000);
// And the widening the enumeration measured, in both of its named cases.
expect(narrow('building_2')).toBeNull();
expect(resolveIcon('building_2')).toBe(icons.Building2);
expect(narrow('layout_dashboard')).toBeNull();
expect(resolveIcon('layout_dashboard')).toBe(icons.LayoutDashboard);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/5935-one-icon-resolver-seam.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
'@object-ui/components': minor
'@object-ui/app-shell': minor
'@object-ui/plugin-detail': minor
'@object-ui/plugin-list': minor
'@object-ui/plugin-view': minor
---

Consolidate the seven lucide icon-name resolvers into one seam (objectui#5935).

Seven modules resolved authored icon names into lucide's runtime `icons` record, each
with its own copy of the logic: **three different tokenisers** (`split('-')` on five of
them, `split(/[-_\s]/)` on one, `split(/[-_\s]+/)` on one) and the `Home` -> `House`
rename on only **four** of the seven. The same authored name therefore rendered on one
surface and not another — the sidebar-vs-action-bar disagreement objectui#5633 opened
with. There is now one resolver, `resolveIcon`, exported from `@object-ui/components`,
and the other six call it.

**The tokeniser is `split(/[-_\s]+/)` with `Home` -> `House` applied universally, and it
was measured rather than chosen.** Its regression set is empty three independent ways:
against the authored population, against a maximally-pessimistic every-authored-name x
every-surface cross-product, and against a bound-free differential over 8,298 spellings
derived from all 1,767 live record keys — each with a discrimination control that fired
in the same run. `split('-')` was **not** adoptable: it regresses 4,748 name-surface
pairs in that last reading, stripping two surfaces of every snake_case and
space-separated spelling they resolve today.

**What changes for you — all of it widening, none of it removal.** No name that resolved
before stops resolving: no key of lucide's record contains `_`, whitespace or `-`
(measured: 0 of 1,767), so whenever the old narrow tokeniser produced a live key the
wider one produces the same key. Sixteen name-surface pairs start resolving where they
rendered a fallback or nothing before:

- `layout_dashboard` and `building_2` (and every other snake_case or space-separated
spelling) now resolve on the shared resolver, `ui:icon`, `ListView`'s empty state,
`TabBar` and `ViewSwitcher` — they previously resolved only on the action preview and
the related list.
- `home` / `Home` now resolves on `RelatedList`, `ListView` and `TabBar`, which carried
no rename map. `Home` is not a live record key, so this could only ever be a widening.

**What does NOT change: what each surface draws when a name does not resolve.** The seam
answers `name -> component`, returning `null`, and decides nothing else (maintainer
ruling 2026-09-03 on objectui#5935). Every call site keeps its own fallback, visibly, at
the call site: `ui:icon` keeps its `SquareDashed` placeholder and its warning
(objectui#5631, untouched), `RelatedList` and `ListView` keep their `Inbox` glyph,
`ActionPreview` keeps its three-character name chip, and the shared resolver, `TabBar`
and `ViewSwitcher` keep `null`. A two-valued `onUnresolvable` parameter was ruled on and
then dropped once the tree was measured to have four such behaviours rather than two: a
lookup function is the wrong place to publish a presentation decision.

`resolveIcon` is newly exported from `@object-ui/components`, which is the only surface
this adds. `scripts/check-lucide-icon-record-names.mjs` is simplified in the same change:
its census goes from seven sites to one, and its normalisation stops being a
widest-common approximation of three disagreeing resolvers — so the under-reporting that
gate disclosed at objectui#5932 is closed rather than merely bounded.
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,8 +40,8 @@ import {
Sparkles,
Square,
Workflow,
icons as lucideIcons,
} from 'lucide-react';
import { resolveIcon } from '@object-ui/components';
import type { ActionParam } from '@object-ui/types';
import { paramDegradesWithoutTarget, resolveParamWidgetType } from '../../../utils/paramToField.js';
import type { MetadataPreviewProps } from '../preview-registry.js';
Expand DownExpand Up@@ -321,15 +321,23 @@ function FauxButton({
* the author still sees that an icon binding is in place.
*/
function IconHint({ name }: { name: string }) {
const pascal = name
.split(/[-_\s]+/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join('');
const resolved = pascal === 'Home' ? 'House' : pascal;
const Glyph = (lucideIcons as Record<string, React.ComponentType<{ className?: string }>>)[resolved];
// objectui#5935: the normalisation moved to the ONE seam. This site's own
// copy was the WIDEST of the three tokenisers in the tree, so the seam adopts
// its behaviour rather than replacing it — nothing this preview resolved
// before stops resolving now.
//
// ⛔ The seam does not decide the fallback. The name chip below stays here,
// unchanged: an author looking at an action preview needs to see that an icon
// binding is in place even when the glyph does not resolve (maintainer
// ruling 2026-09-03, objectui#5935, option C).
const Glyph = resolveIcon(name);

if (Glyph) {
// The same annotation the other five seam call sites carry: `resolveIcon`
// returns a STABLE component out of lucide's static record, it does not
// create one during render. The rule cannot see that through a call, where
// it could through the record index this line replaced.
// eslint-disable-next-line react-hooks/static-components
return <Glyph className="h-4 w-4" aria-hidden />;
}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#5935 — `ActionPreview` takes the shared seam and KEEPS its name chip.
*
* ## Why this file's rows read differently from the other four surfaces'
*
* This site's own normalisation was `split(/[-_\s]+/)` plus the `Home -> House`
* rename — i.e. it was ALREADY the rule the pre-dispatch enumeration went on to
* measure as the zero-regression one (comment 5522254814). The seam adopted
* this site's width rather than the `split('-')` the other five used.
*
* ⇒ There is NO behavioural row here that could be red before the change, and
* this file does not pretend otherwise. Its discriminating row is STRUCTURAL:
* the seam is SPIED, and before the consolidation this renderer never called it
* — the spy recorded zero calls. That row pins the only thing that actually
* moved at this site: which function the glyph came out of.
*
* Every other row is green in both worlds by construction, which is the point:
* the acceptance criterion for this card is that nothing observable changed on
* the fallback behaviours, and the fourth of the tree's four is this chip.
*
* ## Why the module is SPIED rather than stubbed
*
* `importOriginal` keeps the REAL resolver running against the REAL lucide
* record, so the behaviour rows still test resolution rather than a fixture. A
* stub returning a fixed component would have deleted the half that matters —
* that an unresolvable name yields the chip and not a wrong glyph.
*/

import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';

vi.mock('@object-ui/components', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/components')>();
return { ...actual, resolveIcon: vi.fn(actual.resolveIcon) };
});

import { resolveIcon } from '@object-ui/components';
import { ActionPreview } from '../ActionPreview';

const seam = vi.mocked(resolveIcon);

beforeEach(() => seam.mockClear());
afterEach(cleanup);

function renderPreview(icon: string) {
return render(
<ActionPreview
type="action"
name="new_task"
draft={{ name: 'new_task', label: 'New Task', type: 'script', target: 'true', icon }}
/>,
);
}

describe('ActionPreview icon binding (objectui#5935)', () => {
it('CONTROL — the preview renders its action, whatever the icon does', () => {
// Without this, every "the chip is shown" row below could pass against a
// preview that fell into an error boundary and drew nothing at all.
renderPreview('definitely-not-a-lucide-icon');
expect(screen.getAllByText('New Task').length).toBeGreaterThan(0);
});

describe('routing — the only row here that discriminates', () => {
it('resolves the authored name through the SHARED seam', () => {
// RED before the consolidation: this file carried its own tokeniser and
// rename ternary and never called this function.
renderPreview('file-text');
expect(seam).toHaveBeenCalledWith('file-text');
});

it('draws the glyph the seam returned, not one of its own', () => {
// ⚠️ `toHaveBeenCalled` FIRST. Reading `mock.results[0]?.value` straight
// away is a BLIND instrument — with zero calls it is `undefined`, and
// `expect(undefined).not.toBeNull()` passes.
const { container } = renderPreview('file-text');
expect(seam).toHaveBeenCalled();
expect(seam.mock.results[0].value).not.toBeNull();
expect(container.querySelector('svg.lucide-file-text')).not.toBeNull();
});
});

describe('the name chip — GREEN IN BOTH WORLDS, pinning that nothing moved', () => {
it('falls back to the 3-character chip when the name does not resolve', () => {
// ⭐ The fourth of the tree's four unresolvable behaviours, and the one
// that killed the 2026-08-31 `onUnresolvable: "placeholder" | "null"`
// domain: it is neither. The chip stays HERE (maintainer ruling
// 2026-09-03, comment 5523286738, option C) so an author can still see
// that an icon binding is in place.
const { container } = renderPreview('definitely-not-a-lucide-icon');
expect(container.querySelector('svg.lucide-definitely-not-a-lucide-icon')).toBeNull();
// Positive: the chip is the first three characters, uppercased by CSS but
// authored verbatim in the DOM.
expect(screen.getAllByText('def').length).toBeGreaterThan(0);
});

it('shows no chip when the name DOES resolve', () => {
// The control that makes the row above a reading: the chip is not simply
// always present.
renderPreview('file-text');
expect(screen.queryByText('fil')).toBeNull();
});

it('keeps resolving the spellings this site already accepted', () => {
// Green in both worlds, and load-bearing: the shared tokeniser had to
// ADOPT this site's width. A narrowing to `split('-')` would land here
// first, and it is the regression the enumeration measured at 4,748
// name-surface pairs.
expect(renderPreview('file_text').container.querySelector('svg.lucide-file-text')).not.toBeNull();
cleanup();
expect(renderPreview('file text').container.querySelector('svg.lucide-file-text')).not.toBeNull();
cleanup();
expect(renderPreview('home').container.querySelector('svg.lucide-house')).not.toBeNull();
});
});
});
16 changes: 16 additions & 0 deletions packages/components/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,6 +59,22 @@ export { getLazyIcon, isLucideIconName, LazyIcon, toKebabIconName } from './lib/
// already depends on this package, so the direction costs nothing new.
export { hasDeclaredVisibilityGate } from './renderers/action/visibility-gate';

// THE icon-name seam (objectui#5935) — `name -> LucideIcon | null`, one
// tokeniser and one rename map for the whole repo.
//
// Exported for the same reason `hasDeclaredVisibilityGate` above is: the family
// has members outside this package. Seven modules used to hand-roll this lookup
// with THREE different tokenisers and the `Home -> House` rename on only four of
// them, so the same authored name rendered on one surface and not another —
// `app-shell`'s ActionPreview, `plugin-detail`'s RelatedList, `plugin-list`'s
// ListView and TabBar, and `plugin-view`'s ViewSwitcher now import this one.
// All five already depend on this package, so the direction costs nothing new.
//
// ⛔ Nothing about the FALLBACK is exported, because there is none to export:
// the seam returns `null` and each surface keeps its own visible fallback
// (maintainer ruling 2026-09-03, objectui#5935, option C).
export { resolveIcon } from './renderers/action/resolve-icon';

// Export placeholder registration
export { registerPlaceholders } from './renderers/placeholders';

Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
/**
* 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.
*/

/**
* objectui#5935 — the ONE icon-name seam.
*
* Seven modules used to resolve authored icon names, with THREE tokenisers
* (`split('-')` on five, `split(/[-_\s]/)` on one, `split(/[-_\s]+/)` on one)
* and the `Home -> House` rename on only four, so the same authored name
* rendered on one surface and not another. This file pins what the surviving
* one does.
*
* ## What is being proved, and what could not be proved by rendering
*
* The per-surface suites prove that each call site still draws ITS OWN fallback
* (`null`, the objectui#5631 placeholder, `Inbox`, a name chip). They cannot
* prove the seam's own algebra, because a surface only ever shows "resolved" or
* "did not" — so the tokeniser rows below are here, where the answer is the
* component itself.
*
* ## The adopted rule is MEASURED, not chosen
*
* `split(/[-_\s]+/)` with `Home -> House` universal, from the pre-dispatch
* enumeration the 2026-08-31 ruling required (comment 5522254814). Its
* regression set is EMPTY three ways over: against the authored population,
* against the every-name-x-every-surface cross-product, and against a
* bound-free differential over 8,298 spellings derived from all 1,767 live
* record keys. `split('-')` regresses 4,748 pairs in that last reading, which
* is why it is not adoptable and why the rows below assert the WIDER rule
* rather than the more common one.
*/

import { describe, it, expect } from 'vitest';
import { icons } from 'lucide-react';
import { resolveIcon, describeIconLookup } from '../resolve-icon';

describe('the icon-name seam resolves (objectui#5935)', () => {
/**
* ⭐ Non-vacuity for every "resolves" row below. A `resolveIcon` that returned
* some component for EVERY input would pass them all; a `resolveIcon` that
* returned `null` for every input would pass every fallback row in every
* per-surface suite. Both directions are excluded here, in the same run.
*/
it('DISCRIMINATES — a live name resolves and a dead one does not', () => {
expect(resolveIcon('file-text')).not.toBeNull();
expect(resolveIcon('not-a-real-icon')).toBeNull();
});

it('accepts all four authored spellings of one glyph', () => {
const canonical = icons.ArrowRight;
expect(canonical).toBeDefined();
// kebab — what the docs and most fixtures author.
expect(resolveIcon('arrow-right')).toBe(canonical);
// snake — resolved on TWO of the seven surfaces before this card and on
// five of them not at all. This row is the consolidation.
expect(resolveIcon('arrow_right')).toBe(canonical);
// space-separated — same story.
expect(resolveIcon('arrow right')).toBe(canonical);
// already-Pascal — authored in real fixtures, must not be mangled.
expect(resolveIcon('ArrowRight')).toBe(canonical);
});

it('collapses repeated and mixed separators', () => {
// `+` in the tokeniser. The equivalent spelling without it produced empty
// tokens, which capitalise to nothing and join to nothing — measured
// identical over 51,449 hostile spellings, and pinned here so the two
// spellings are not "fixed" apart later.
expect(resolveIcon('arrow--right')).toBe(icons.ArrowRight);
expect(resolveIcon('arrow-_ right')).toBe(icons.ArrowRight);
});

it('applies the `Home` -> `House` rename, which is the ONLY rename', () => {
// lucide dropped `Home` from its runtime record and kept `House`. The map
// exists so a name that used to resolve still does — it is not a general
// alias table, and nothing else belongs in it.
expect(icons).not.toHaveProperty('Home');
expect(resolveIcon('home')).toBe(icons.House);
expect(resolveIcon('Home')).toBe(icons.House);
expect(describeIconLookup('home')).toEqual({ pascal: 'Home', key: 'House' });
// The control: an UNMAPPED name passes through both halves unchanged, so
// the row above is about the map and not about `describeIconLookup` always
// answering `House`.
expect(describeIconLookup('file-text')).toEqual({ pascal: 'FileText', key: 'FileText' });
});

it('returns null — never a fallback glyph — for absent and unresolvable names', () => {
// ⭐ The contract the 2026-09-03 maintainer ruling (option C) fixed: the
// seam does `name -> component`, and NOTHING about what a surface draws
// when there is no component. Each call site keeps its own fallback, so
// this function must never acquire one, and must never acquire a parameter
// for choosing one either.
expect(resolveIcon(undefined)).toBeNull();
expect(resolveIcon('')).toBeNull();
expect(resolveIcon('definitely-not-a-lucide-icon')).toBeNull();
// A RETIRED spelling: `Edit` still imports and still renders, but its key
// is gone from the runtime record. Rules out a resolver that reached for
// the named exports instead — a third, more forgiving vocabulary.
expect(resolveIcon('edit')).toBeNull();
expect(resolveIcon('square-pen')).toBe(icons.SquarePen);
});

it('takes the seam FUNCTION, not a re-derived string, as the answer', () => {
// `describeIconLookup` exists only so `renderers/basic/icon.tsx` can name
// both halves in its objectui#5631 warning without a second copy of the
// tokeniser. Pinned as CONSISTENT with `resolveIcon` so the diagnostic can
// never describe a lookup that did not happen.
for (const authored of ['home', 'file-text', 'arrow_right', 'not-a-real-icon']) {
const { key } = describeIconLookup(authored);
const expected = Object.prototype.hasOwnProperty.call(icons, key)
? (icons as Record<string, unknown>)[key]
: null;
expect(resolveIcon(authored)).toBe(expected);
}
});

it('is what the widening promised: the OLD resolving sets are strict subsets', () => {
// Why no name could regress, made concrete. The old narrow tokeniser is
// re-implemented HERE, in the test, so the claim is checked rather than
// asserted — every name it resolved must still resolve, and the two names
// the enumeration named as newly-resolving must now do so.
const narrow = (name: string) => {
const pascal = name.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
const mapped = pascal === 'Home' ? 'House' : pascal;
return Object.prototype.hasOwnProperty.call(icons, mapped)
? (icons as Record<string, unknown>)[mapped]
: null;
};
let carried = 0;
for (const key of Object.keys(icons)) {
// The kebab spelling of every live glyph — what the narrow tokeniser
// could resolve at all.
const kebab = key.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
const before = narrow(kebab);
if (before === null) continue;
carried += 1;
expect(resolveIcon(kebab), `${kebab} stopped resolving`).toBe(before);
}
// Non-vacuity: a loop that skipped everything would pass silently.
expect(carried).toBeGreaterThan(1000);
// And the widening the enumeration measured, in both of its named cases.
expect(narrow('building_2')).toBeNull();
expect(resolveIcon('building_2')).toBe(icons.Building2);
expect(narrow('layout_dashboard')).toBeNull();
expect(resolveIcon('layout_dashboard')).toBe(icons.LayoutDashboard);
});
});
Loading
Loading