Skip to content

fix(notifications): the spec icon is read instead of stored and ignored (#3014 follow-up) - #3076

Merged
os-zhuang merged 1 commit into
mainfrom
claude/notification-icon-override
Jul 30, 2026
Merged

fix(notifications): the spec icon is read instead of stored and ignored (#3014 follow-up)#3076
os-zhuang merged 1 commit into
mainfrom
claude/notification-icon-override

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Follow-up to #3071 / #3075. Closes the gap I flagged when wiring the console: NotificationSchema.icon — the spec's "Icon name override" — reached NotificationItem and stopped there. Every surface drew the severity icon, so an author writing icon: 'rocket' got the success checkmark. Same shape as the displayType collapse #3071 fixed: a value that validates, is carried, and renders nothing.

The fix

All five presentations resolve icon through one rule (notificationIcon): a declared Lucide name — kebab-case or PascalCase — replaces the severity icon; anything else falls back to it. That includes the console's sonner toast (presentNotificationToast, now .tsx so it can build the icon element), so the override behaves identically on a toast, a banner, a snackbar, an alert and an inline message.

The fallback is the part worth reviewing. getLazyIcon degrades an unknown name to a Database glyph — the right default for a data-shaped schema slot, and the wrong one here: on an error notification it swaps a meaningful icon for a meaningless one. So the name is checked first, via a new isLucideIconName export on lib/lazy-icon, and a typo costs the author their override and nothing more.

The two react-hooks/static-components disables follow the repo's existing convention for this rule (MetricCard, MetricWidget, NavigationRenderer): getLazyIcon is module-cached per name, so the component identity is stable across renders and the rule is a false positive here. Only the two direct component-body call sites trip it; the banner/inline ones sit inside .map callbacks and are not flagged, so they carry no disable rather than an unused one.

Verification

In the running console — a toast declaring icon: 'rocket' renders the rocket instead of the green success checkmark, and, side by side, a snackbar declaring icon: 'not-a-real-icon' renders the plain info icon rather than a stray database glyph. Both fallback directions proven in one frame; no console errors.

Suites: packages/components + packages/app-shell2478 tests, 301 files, all green (9 new: 4 pinning the resolver contract, 1 DOM smoke on a surface, 4 on the toast mapping). tsc --noEmit clean on both; eslint 0 errors on every touched file.

Docs: the notifications guide gains an icon section, plus a line in the @object-ui/components README.

Refs #3014, #2942.

🤖 Generated with Claude Code

…ored (#3014 follow-up)

`NotificationSchema.icon` — "Icon name override" — reached `NotificationItem`
and stopped there. Every surface drew the severity icon, so an author writing
`icon: 'rocket'` got the success checkmark. Same shape as the `displayType`
collapse #3071 fixed: a value that validates, is carried, and renders nothing.

All five presentations now resolve it through one rule (`notificationIcon`): a
declared Lucide name — kebab-case or PascalCase — replaces the severity icon;
anything else falls back to it. That includes the console's sonner toast
(`presentNotificationToast`, now .tsx so it can build the icon element), so the
override behaves identically on all five.

The fallback is the interesting part. `getLazyIcon` degrades an unknown name to
a `Database` glyph — right for a data-shaped schema slot, wrong here, where it
would swap a meaningful icon for a meaningless one on an error notification. So
the name is checked first via a new `isLucideIconName` export, and a typo costs
the author their override and nothing more.

The two `react-hooks/static-components` disables follow the existing repo
convention for this rule (MetricCard / MetricWidget / NavigationRenderer): the
factory is module-cached per name, so the component identity is stable and the
rule is a false positive here.

Verified in the running console: a toast declaring `icon: 'rocket'` renders the
rocket instead of the success checkmark, while a snackbar declaring
`icon: 'not-a-real-icon'` renders the info icon — not a Database glyph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Jul 30, 2026 4:39pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation package: components tests labels Jul 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 27.9 KB 350 KB
Entry file index-D64AIAuC.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.26KB 2.99KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.12KB 3.41KB
auth (LoginForm.js) 17.86KB 5.29KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.43KB 2.09KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
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) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 18.38KB 4.49KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 3.65KB 1.42KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.25KB 0.53KB
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) 471.25KB 102.82KB
core (index.js) 2.16KB 0.78KB
create-plugin (index.js) 9.28KB 2.98KB
data-objectstack (index.js) 135.63KB 34.47KB
fields (index.js) 222.07KB 54.35KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.46KB 0.96KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 5.37KB 1.72KB
i18n (useObjectLabel.js) 25.17KB 5.80KB
i18n (useSafeTranslation.js) 3.26KB 1.44KB
layout (index.js) 38.45KB 10.67KB
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.74KB
mobile (index.js) 1.50KB 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.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.05KB 1.53KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.76KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (retry.js) 3.48KB 1.61KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 44.90KB 12.35KB
plugin-charts (index.js) 60.52KB 17.11KB
plugin-chatbot (index.js) 180.09KB 42.72KB
plugin-dashboard (index.js) 111.59KB 28.74KB
plugin-designer (index.js) 210.51KB 42.50KB
plugin-detail (index.js) 221.81KB 54.28KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 110.71KB 26.67KB
plugin-gantt (index.js) 162.26KB 39.53KB
plugin-grid (index.js) 182.21KB 48.24KB
plugin-kanban (index.js) 47.82KB 13.18KB
plugin-list (index.js) 104.07KB 24.88KB
plugin-map (index.js) 16.80KB 5.24KB
plugin-markdown (index.js) 13.65KB 4.67KB
plugin-report (index.js) 40.32KB 10.53KB
plugin-timeline (index.js) 25.75KB 7.32KB
plugin-tree (index.js) 8.36KB 2.81KB
plugin-view (index.js) 85.95KB 21.02KB
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.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 19.28KB 6.38KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.02KB 0.55KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 3.47KB 1.54KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
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) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 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 (index.js) 2.07KB 0.99KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 0.20KB 0.18KB
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 (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 1.08KB 0.64KB
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

@os-zhuang
os-zhuang merged commit df613fa into main Jul 30, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/notification-icon-override branch July 30, 2026 16:46
os-zhuang added a commit that referenced this pull request Jul 31, 2026
…instead of forked or ignored (#3014 follow-up) (#3085)

The last of the notification contract. After displayType (#3071) and icon
(#3076), four gaps of the same family were left:

  - the config was 3/4 inert: only `defaultDuration` was ever read, while
    `maxVisible` / `stacking` were carried and ignored and NotificationBanners
    capped at a hard-coded 3 of its own;
  - its field names forked from `NotificationConfigSchema` (`position` vs
    `defaultPosition`, a renderer-local `stacking` boolean, no `pauseOnHover`);
  - a notification could not declare a `position` at all — the #3008 parity
    guard asserted the position VOCABULARY while nothing positioned anything
    by it;
  - `NotificationActionButton.variant` was the shadcn Button vocabulary
    (`default | destructive | outline`) under a spec-shaped name, forking
    `NotificationActionSchema.variant` (`primary | secondary | link`).

Positioning resolves as `notification.position ?? config.defaultPosition ??
nothing`, and "nothing" is a real answer: declared → the surface pins itself
there and `presentNotificationToast` passes it per-toast so the contract beats
the container; undeclared → the surface keeps its own anchor, or defers to the
host's toast chrome. That asymmetry is the decision — the sonner container also
serves toasts that are NOT spec notifications (the action runtime's own
`toast.*` calls), so it stays the fallback authority for placement, never a
competing one. Hence `defaultPosition` has no fabricated default: "the host
didn't say" has to be representable.

`maxVisible` / `stackDirection` now drive every stacking surface through one
shared `visibleNotificationStack`; `pauseOnHover` holds a transient timer and
resumes it with the time it had left, which needed the provider to track live
timers instead of fire-and-forget setTimeouts. Legacy spellings still resolve:
`position` folds into `defaultPosition`, `stacking: false` reads as
`maxVisible: 1`.

`onToast` gains the resolved config as a second argument (one-arg handlers are
unaffected), and the spec-parity guard gained the action-variant vocabulary —
the one notification enum it did not cover.

Verified in the running console, in one frame: an undeclared toast stays where
the sonner container puts it (bottom-right), a toast declaring `top_left` moves
there, and a snackbar declaring `top_right` leaves its bottom anchor.

Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation package: components tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant