Skip to content

fix(notifications): the config, position and action variant are read instead of forked or ignored (#3014 follow-up) - #3085

Merged
os-zhuang merged 1 commit into
mainfrom
claude/notification-config-contract
Jul 31, 2026
Merged

fix(notifications): the config, position and action variant are read instead of forked or ignored (#3014 follow-up)#3085
os-zhuang merged 1 commit into
mainfrom
claude/notification-config-contract

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

The last of the notification contract. After displayType (#3071) and icon (#3076), four gaps of the same family were left — values that validate, are carried, and change nothing.

What was wrong

  • The config was 3/4 inert. Only defaultDuration was ever read. maxVisible and stacking were carried and ignored, while NotificationBanners capped at a hard-coded 3 of its own.
  • Its field names forked from NotificationConfigSchemaposition vs defaultPosition, a renderer-local stacking boolean with no spec counterpart, and no pauseOnHover at all.
  • A notification could not declare a position. The fix(spec-parity): the fifteen Tier-2 spec values render instead of validating into nothing (#2942) #3008 parity guard asserted the position vocabulary matched the spec while nothing positioned anything by it — a guard passing over an unused value.
  • NotificationActionButton.variant was the shadcn Button vocabulary (default | destructive | outline) under a spec-shaped name, forking NotificationActionSchema.variant (primary | secondary | link).

The decision this PR settles: who owns position

notification.position ?? config.defaultPosition ?? nothing — and "nothing" is a real answer, not a missing one:

  • declared → the surface pins itself there, always. presentNotificationToast passes it per-toast, so the contract beats the container;
  • undeclared → the surface keeps its own anchor (a snackbar's bottom edge), or defers to the host's toast chrome.

That asymmetry is the point. The sonner container also serves toasts that are not spec notifications — the console action runtime's own toast.* calls — so it stays the fallback authority for placement, never a competing one. A declared position that a component prop could silently override is the same "validates, then does nothing" shape this whole area is about.

Consequence: defaultPosition gets no fabricated default. "The host didn't say" has to be representable, otherwise an invented top_right silently fights the host's own chrome. This also means no visible change in the console — nothing declares a position there today, so toasts stay bottom-right and snackbars stay bottom-anchored.

The rest

  • maxVisible / stackDirection drive every stacking surface through one shared visibleNotificationStack — the cap keeps the newest, the stack grows in the declared direction (down = newest below, the spec default). NotificationBanners' invented max prop is gone.
  • pauseOnHover holds a transient notification's timer and resumes it with the time it had left — which required the provider to track live timers rather than fire-and-forget setTimeouts. That is why the spec key had nothing to attach to before.
  • Legacy spellings still resolve rather than being fossilized: position folds into defaultPosition, and stacking: false reads as maxVisible: 1 ("show only the newest").
  • onToast receives the resolved config as a second argument so the delegate can apply the parts of the contract only it can. Existing one-argument handlers are unaffected.
  • The spec-parity guard gained the action-variant vocabulary — the one notification enum it did not cover. Note the access path: variant carries .default('primary'), so the enum sits one wrapper down (shape.variant.def.innerType); reading .options off the field returns nothing, which would have made the guard pass by finding no spec values at all. assertParity's empty-check is what catches that.

Verification

In the running console, in one frame: an undeclared toast stays where the sonner container puts it (bottom-right), a toast declaring position: 'top_left' moves there, and a snackbar declaring position: 'top_right' leaves its bottom anchor — "declared wins, undeclared defers", proven end to end. No console errors.

Suites: packages/react + packages/components + packages/app-shell2872 tests, 332 files, all green (25 new). tsc --noEmit clean on all three; eslint 0 errors on every touched file.

Docs: the notifications guide gains position, actions and a full config table, plus the @object-ui/react README.

Refs #3014, #2942, #2944.

🤖 Generated with Claude Code

…instead of forked or ignored (#3014 follow-up)

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: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 31, 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 31, 2026 1:26am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation package: react package: components tests labels Jul 31, 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-VtxhLoWf.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) 472.44KB 103.27KB
core (index.js) 2.16KB 0.78KB
create-plugin (index.js) 9.28KB 2.98KB
data-objectstack (index.js) 136.10KB 34.56KB
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) 103.85KB 24.80KB
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.79KB 20.99KB
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 07de839 into main Jul 31, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/notification-config-contract branch July 31, 2026 01:32
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 package: react tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant