Skip to content

feat(components): honour the declared breadcrumb-family keys — separator, maxItems, and header-bar crumb icons - #6721

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-6646-breadcrumb-family-declared-keys
Aug 29, 2026
Merged

feat(components): honour the declared breadcrumb-family keys — separator, maxItems, and header-bar crumb icons#6721
os-sales merged 1 commit into
mainfrom
claude/issue-6646-breadcrumb-family-declared-keys

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes #6646
Fixes #6645

Family branch: two cards, one branch, because both route through the shared resolveIcon and the same BreadcrumbItem declaration. Verified at e1d51d4da.

What landed

#6646ui:breadcrumb now reads separator and maxItems

BreadcrumbSchema has declared both since it shipped (packages/types/src/navigation.ts, mirrored in zod/navigation.zod.ts), and separator is additionally advertised on the component's own docs page. The renderer contained zero occurrences of either name.

  • separator is read, with the declared default honoured. ??, not ||: an authored empty string means "no visible separator" and must not be promoted to the default.
  • maxItems bounds the count of RENDERED crumbs. Over-long trails keep the first crumb and the last maxItems - 1, with shadcn's BreadcrumbEllipsis between, so the current page is never the crumb that gets dropped; at maxItems: 1 there is no room for both ends and the current page is what stays. A value that cannot mean a count (absent, non-finite, below 1) is declined rather than coerced.

The default-alignment direction, stated explicitly because it is the one judgement call here. separator carries @default '/' in the declaration while the renderer fell through to shadcn's ChevronRight — declared default and actual render disagreed, and honouring only the authored value would have left the docs lying about the unauthored one. Two ways to align:

  • align the render to the declaration (schema.separator ?? '/') — chosen; or
  • amend the published @default to describe the chevron — rejected: separator is typed string, so no honest @default can name a component, which means that route deletes a documented default. That is a published-contract change, which the triage ruling routes to a maintainer, and the arm dispatched here is "implement the declaration".

Consequence, and it is user-visible: every existing ui:breadcrumb that authors no separator now renders / where it rendered a chevron. That is what moved the changeset from patch (the sibling repair, PR #6644) to minor.

#6645ui:header-bar now resolves crumbs[].icon

HeaderBarSchema.crumbs is typed BreadcrumbItem[], the same declaration BreadcrumbSchema.items uses, and the zod mirror does not merely declare icon — it describes it (.describe('Breadcrumb icon')), so any authoring surface reading zod describe can already offer the key. The renderer contained zero occurrences of the substring icon.

  • Resolved through the shared resolveIcon, never a local normaliser (objectui#5993's lesson). Pinned from the outside by a row asserting that home draws lucide's House glyph — that rename lives only in the shared resolver's map, so a local toPascalCase copy is red.
  • Resolved once per crumb, above BreadcrumbLabel, so all three of that helper's arms — the siblings quick-switch dropdown, the last crumb's page arm, and every earlier link arm — carry the glyph by construction. The dropdown arm is the shape this card could not inherit from PR fix(components): resolve ui:breadcrumb and ui:command child item icons through the record resolver #6644, because ui:breadcrumb has no such arm; it gets its own rows.
  • The asymmetry is asserted directly. One crumb object is rendered through both consumers and the resolved glyph compared — in the positive direction and on a retired spelling, because a header-bar that quietly used LazyIcon would satisfy the first and diverge on exactly the names that matter.

Anti-vacuity: red before, green after

Both suites were written and run against the merge-base renderers first. Full run recorded: 19 failed | 16 passed (35), the 16 being the harness controls and the rows that are green in both worlds by design.

card key / behaviour rows RED on 98188c284 after
#6646 separator — declared @default '/' honoured 2 (renders the declared @default, draws NO chevron glyph) green
#6646 separator — authored value read 4 (single-char, multi-char, distinguishable-from-a-typo, empty string) green
#6646 maxItems — collapse 6 (at-most-N, first + last N-1, ellipsis marker, maxItems: 1, separators through the collapse, icons through the collapse) green
#6645 crumbs[].icon — link arm 1 green
#6645 crumbs[].icon — page arm 1 green
#6645 crumbs[].icon — siblings-dropdown arm 1 green
#6645 shared resolver (home to lucide-house), glyph-not-word 2 green
#6645 the two consumers agree 1 green
#6645 catalog fixture names all draw 1 green

The separator default has its own assertion pair rather than one row: a repair that appended / while still drawing the chevron would pass "renders the declared default" and still show an author something the declaration never promised.

Two negative rows were green in both worlds and are labelled as such rather than counted: "both consumers agree on a RETIRED spelling" (a uniform zero before the fix — the card's own framing) and "the dropdown's own chevron survives".

One instrument bug found and fixed mid-run, disclosed rather than quietly corrected: the first post-fix run was 3 failed, all three counting the elision's own list item as a crumb. The renderer was right; the test helper's "not a separator" filter was too loose. It now excludes both decorative kinds by the one attribute shadcn marks them with.

An existing test's control had to move, and why

breadcrumb-item-icon.test.tsx (PR #6644) used the renderer's own separator chevron as the positive control on its svg.lucide-* selector. Aligning the default separator removes that chevron, so the control would have gone red for a reason with nothing to do with icons. It now renders the shadcn primitive directly — still a positive control on the selector, and now green in both worlds by construction instead of by a renderer default that was free to change. Its prose says so.

The duplicate BreadcrumbSchema — verified, documented, NOT deleted

The triage asked the taker to handle this rather than treat it as trivia, with the fence "do not delete the un-exported one; verify first". Both halves done:

consumer resolves
renderers/data-display/breadcrumb.tsx navigation.ts:205 — the one index.ts:272 re-exports, and the only one carrying maxItems
packages/types/src/registry.ts:87,176 navigation.ts (imported from './navigation.js') ✅
zod/navigation.zod.ts:97 — the only zod breadcrumb in the package restates navigation.ts
data-display.ts:1623 DataDisplaySchema union data-display.ts:1578, the narrow one — so it is still referenced and was not touched, exactly as fenced
packages/types/__tests__/zod-mirror-parity.test.ts:136,606,607 data-display.tsthe wrong one

The last row is a real find and is now #6720, filed as a sub-issue of #6349 (the 46-name collision card that already lists this row). The parity file's job is "the mirror accepts everything the TS declaration declares", and for both Breadcrumb pairs it compares the navigation.zod mirror against the data-display declaration — so maxItems, icon, onClick and siblings are mirror-only keys that fall out of the comparison entirely and the pair reads clean about a declaration no consumer resolves.

Not repaired here on purpose. Re-pointing those two import specifiers runs the comparison against four more declared keys, and whatever that reveals may need new entries in KnownDrift / UnmirroredDeclared / RuntimeOnlyDeclared — writing new debt into governed ratchet ledgers is a ruling, not an execution step. renderers/data-display/breadcrumb.tsx gained a docblock naming which declaration it consumes, so the next reader does not have to re-derive it.

One thing to look at: the icon-record gate gains a header-bar census entry

Flagged prominently because #6645's triage carries a fence near it. The fence is "⛔ do not FIX this by teaching scripts/check-lucide-icon-record-names.mjs to guess", with the stated ground that the gate "correctly does not census header-bar (its crumb names reach no resolver)". That ground expires with this PR — the crumb names now reach resolveIcon — and the gate's own header says a row's verdict "is a fact about a renderer, so it expires when that renderer is repaired".

So this is not guessing and not the fix: the fix is the renderer. It is the same lawful move context-menu made in objectui#6278 (whose entry says in so many words that it could not have been added before its repair landed), and the same move PR #6644 made for breadcrumb. Without it the three icon names the new fixture authors would be unjudged, which is the hazard the gate exists for. It is explicitly NOT objectui#5992's blind spot, which is the gate censusing containers nobody read off a renderer.

The header's part-2 table was re-measured rather than adjusted by arithmetic: an independent walk over examples/schema-catalog/ reproduces the seven existing rows identically (tree-view 30, command 9, button-group 8, context-menu 4, timeline 4, breadcrumb 3, dropdown-menu 3 = 61) and finds header-bar 3, for 64 across eight containers.

Reversing it is one hunk (the entry plus its header paragraph) if a maintainer reads the fence more strictly; nothing else in this PR depends on it.

Fixtures and docs, so neither key gets re-filed as "declared but unenforced"

  • components-data-display-breadcrumb/custom-separator and .../collapsed-trail author the two keys; components-navigation-header-bar/crumbs-with-icons authors three crumb icons.
  • catalog-meta.json carries curated titles/descriptions for all three; index.ts regenerated by the script that owns it, additions only (regenerate:check clean).
  • The breadcrumb page gains a Separator section, a Collapsing section, and the maxItems row its interface block never had.
  • The header-bar page gains a Crumb Icons section, and its interface block is corrected to the real declared name (BreadcrumbItem, not Breadcrumb) with icon and siblings added.

packages/types is untouched by this PR: every key here was already declared, and the only thing missing was renderers that read them.

Verification

All heavy work ran under the shared verify lock; verdicts quoted from what each command printed, not from a shell status.

  • pnpm exec vitest run packages/components/src/__tests__/{breadcrumb-separator-max-items,header-bar-crumb-icon,breadcrumb-item-icon}.test.tsx on the merge-base: Tests 19 failed | 16 passed (35).
  • Final union at e1d51d4da (working tree clean at commit, so the tested tree is HEAD's tree), VERDICT command-exit 0 over an && chain of: pnpm --filter @object-ui/components type-check, pnpm check:icon-record-names, and pnpm exec vitest run over the gate's own suite, all of examples/schema-catalog/test/, the three component suites and snapshot-criticalTest Files 19 passed (19) · Tests 1952 passed (1952).
  • Icon gate: OK lucide icon names: 182 authored/declared names reaching 7 record-reading resolvers are live icons keys.
  • Earlier in the same chain, also VERDICT command-exit 0: dependency-closure build, pnpm --filter @object-ui/example-schema-catalog type-check, pnpm type-check:scripts, regenerate:check ("index.ts is up to date (431 entries)"), check:control-bytes ("scanned 5577 tracked text file(s)"), check:doc-types ("Every documented component type is registered"), check:doc-fences.
  • The new suites are genuinely in the type-check program: packages/components/tsconfig.test.json includes src/**/*.test.tsx, and both new files sit in src/__tests__/.

Two declared narrowings, so they are not read as coverage:

  1. pnpm lint was run over the changed files, not the repo. eslint --no-inline-config --format json over the 7 lintable changed paths: 7 files, 0 errors, 11 warnings, every warning on a pre-existing file being a construct that is verbatim present at 98188c284 (the unused renderChildren import, [key: string]: any, React.ComponentType(any) in the sibling suite, ExampleMeta in the generated index). The new suites' warnings are the same already-accepted construct. The narrowing is sound because eslint.config.js enables no type-aware linting (no parserOptions.project, no projectService), so this diff cannot move the verdict on any file it does not touch. One real ERROR was found and fixed this way: react-hooks/static-components on a first-draft CrumbIcon helper, which is why the resolution is inline in the map callback — the same shape breadcrumb.tsx uses.
  2. pnpm check:doc-snippets was NOT run. It exits 2 with PRECONDITION NOT MET until 21 package closures are built, which is not a red and not a green — it is NOT MEASURED. It compiles only ts/tsx/typescript fences; every fence this PR touches is plaintext, and check:doc-fences passing over all 223 documents is the independent confirmation that the classification is right. CI runs it regardless.

Out of scope, filed not fixed


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 49 chunks) 3233.1 KB 3266.6 KB
Main entry chunk (gzip) 157.3 KB 350 KB
Entry file index-BMNWQLmn.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 11.89KB 4.50KB
app-shell (runtime-config.js) 20.61KB 7.35KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 511.59KB 116.29KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 173.10KB 47.96KB
fields (index.js) 239.20KB 60.12KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.89KB 12.91KB
plugin-charts (index.js) 64.66KB 18.32KB
plugin-chatbot (index.js) 190.33KB 45.10KB
plugin-dashboard (index.js) 133.44KB 34.48KB
plugin-designer (index.js) 212.87KB 43.19KB
plugin-detail (index.js) 245.29KB 62.39KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 132.01KB 32.23KB
plugin-gantt (index.js) 165.20KB 40.37KB
plugin-grid (index.js) 201.51KB 54.54KB
plugin-kanban (index.js) 53.11KB 14.62KB
plugin-list (index.js) 113.01KB 27.57KB
plugin-map (index.js) 20.17KB 6.66KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 26.44KB 7.59KB
plugin-tree (index.js) 9.00KB 3.08KB
plugin-view (index.js) 85.87KB 21.12KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 67.73KB 22.54KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 2.44KB 1.21KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.72KB 2.24KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant