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

vercelBot commented Jul 31, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredJul 31, 2026 1:26am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)27.9 KB350 KB
Entry fileindex-VtxhLoWf.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.26KB2.99KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.12KB3.41KB
auth (LoginForm.js)17.86KB5.29KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.43KB2.09KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)18.38KB4.49KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)3.65KB1.42KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.25KB0.53KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)472.44KB103.27KB
core (index.js)2.16KB0.78KB
create-plugin (index.js)9.28KB2.98KB
data-objectstack (index.js)136.10KB34.56KB
fields (index.js)222.07KB54.35KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.46KB0.96KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)5.37KB1.72KB
i18n (useObjectLabel.js)25.17KB5.80KB
i18n (useSafeTranslation.js)3.26KB1.44KB
layout (index.js)38.45KB10.67KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.05KB1.53KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.76KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (retry.js)3.48KB1.61KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)44.90KB12.35KB
plugin-charts (index.js)60.52KB17.11KB
plugin-chatbot (index.js)180.09KB42.72KB
plugin-dashboard (index.js)111.59KB28.74KB
plugin-designer (index.js)210.51KB42.50KB
plugin-detail (index.js)221.81KB54.28KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)110.71KB26.67KB
plugin-gantt (index.js)162.26KB39.53KB
plugin-grid (index.js)182.21KB48.24KB
plugin-kanban (index.js)47.82KB13.18KB
plugin-list (index.js)103.85KB24.80KB
plugin-map (index.js)16.80KB5.24KB
plugin-markdown (index.js)13.65KB4.67KB
plugin-report (index.js)40.32KB10.53KB
plugin-timeline (index.js)25.75KB7.32KB
plugin-tree (index.js)8.36KB2.81KB
plugin-view (index.js)85.79KB20.99KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)19.28KB6.38KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.02KB0.55KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)3.47KB1.54KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (index.js)2.07KB0.99KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)0.20KB0.18KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)1.08KB0.64KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.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 mainJul 31, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/notification-config-contract branch July 31, 2026 01:32
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Aug 3, 2026
…h-prefixed root files (objectstack-ai#3195)
A tracked 68KB PNG (1280x577) named literally `--full-page` sat in the
repository root: a screenshot CLI flag that landed in the output-filename slot
and was committed alongside objectstack-ai#3085. Every `git worktree add` since materialised
it into a fresh tree.
The root-anchored `/*.png` guard could not catch it — the file has no extension
to match on, because the flag *is* the name. `.gitignore` now also ignores
`/--*`, matching the accident by shape instead of content type: nothing
legitimate lives at the repository root under a flag name. Nested paths are
unaffected.
AGENTS.md Housekeeping is updated to state the guard accurately, and to note
that removing such a file needs `--` to stop argument parsing
(`git rm -- './--full-page'`).
Fixesobjectstack-ai#3193
Claude-Session: https://claude.ai/code/session_01PRJtkgUAaVG11FsJQbvZWA
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationpackage: componentspackage: reacttests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@os-zhuang