Skip to content

fix(spec-parity): the fifteen Tier-2 spec values render instead of validating into nothing (#2942) - #3008

Merged
os-zhuang merged 1 commit into
mainfrom
claude/tier2-spec-silent-absent
Jul 30, 2026
Merged

fix(spec-parity): the fifteen Tier-2 spec values render instead of validating into nothing (#2942)#3008
os-zhuang merged 1 commit into
mainfrom
claude/tier2-spec-silent-absent

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Closes #2942 (Tier 2 of the #2901 audit — spec values that validate and then render nothing; audit doc). Follows #2993 (Tier 1).

All 15 rows re-verified against current main before touching anything (post-#2989/#2940 the filter-operator row had narrowed to 4 tokens; everything else was live). Two rows turned out worse than filed: the timeline renderer read its own timeScale key so even day/week/month spec configs were ignored, and the report-aggregation vocabulary is the types-local distinct, not the issue's unique.

The fifteen fixes

Row Before Now
element: 'toggle' deleted the filter bar default: return null — the spec promises stored toggle configs keep rendering, the renderer shipped the one artifact that says nothing The existing-but-unreachable ToggleFilters is wired. The split is deliberate: authoring stays untypeable (ADR-0053's correct-by-construction removal, pinned by phase2-schemas.test.ts), rendering honors stored metadata (spec ADR-0047 §3.4a)
7 chart types drew an empty plot gauge/solid-gauge/metric/kpi/bullet/table/pivot fell through || BarChart into a shell whose series marks all returned null — indistinguishable from an empty dataset, reachable via ChartRenderer's schema.chartType ?? spec.chartType bypass Single-value families render the measure as a number (the spec's own framing), table/pivot name the owning component, out-of-spec values are named — never guessed at
6 of 9 animation presets did nothing PRESET_CLASSES keyed in hyphens vs the spec's underscores; rotate/flip absent Re-keyed to the spec vocabulary (rotate/flip classes copied from usePageTransition, the sibling that always had all nine); hyphen dialect + scale-fade stay accepted for stored configs
3 of 6 easings emitted invalid CSS EASING_MAP[easing] || easinganimationTimingFunction: 'ease_in_out', silently dropped by the browser All six spec spellings map to real CSS; out-of-vocabulary strings are dropped unless they look like raw CSS (cubic-bezier(…))
mode: 'auto' locked the light theme Both ThemeProviders branched on the pre-spec system only; classList.add('auto') matches no Tailwind variant auto follows the OS preference in both providers; system kept for stored values
Export PDF downloaded nothing Every authored format was offered; picking PDF closed the popover silently (xlsx too, without the server path) Fixed concurrently by #2999 (drop undeliverable formats + dev-time warning) — this branch rebased onto it and defers; the parallel implementation it carried was removed in the rebase
Timeline scale blanked the axis Renderer read its own timeScale key (spec scale ignored for ALL six values); hour/quarter/year generated zero headers Spec key read first (legacy kept); every scale produces calendar-snapped gantt buckets. The vertical/horizontal variants are event feeds with no time axis — documented, not a gap
All 6 toast positions discarded <SonnerToaster /> bare, inputs: [] Position (spec underscores + ToasterSchema hyphens) and limit reach sonner; registry inputs declared
All 5 notification types collapsed to toast displayType stored, never read; the union claimed spec alignment while carrying modal and missing alert/inline Union + positions now match the spec enums (legacy spellings kept, modal presents as alert); displayType is materialized so the presenting delegate can branch. Distinct banner/inline presenters remain the documented follow-up — this PR closes the contract half
navigation.size ignored off app-shell The hook only read the deprecated width Explicit buckets resolve to the same viewport-clamped widths as plugin-view's overlayWidthFor; width still wins; auto stays host-derived (needs field counts only schema-aware hosts have)
date-range/text filters were dead controls Chip rendered; popover said the literal "No options" A from/to date pair (emits >=/<= bounds) and a commit-on-Enter contains search
4 gesture types became tap useSpecGesture never read config.type; branched on sub-object presence The declared type drives recognition; useGesture gains real double-tap (two taps within 350 ms — it used to fire per tap) and two-touch pinch/rotate deltas
Report distinct → blank cell default: return '' Distinct count over the raw field values; extracted as computeReportAggregation with a Record<ReportAggregationType, …> guard so a new union member fails type-check until computed
8 field types got a plain text input inline json/composite/record/repeater/tree/video/audio/autonumber were in neither EDIT_WIDGETS nor the exclusion set — the guard iterated FORM_FIELD_TYPES, which alias-only spec spellings never enter Inline resolution reuses the form's alias table: json → code editor, tree → lookup picker, the rest resolve to their documented exclusions — and ObjectGrid's editability gate consults the same contract, so a composite cell is read-only instead of a value-corrupting text box. New guard iterates the spec FieldType (49 members, all decided)
4 filter operators unreachable from the UI $startsWith/$endsWith/$null/$exists accepted by FieldOperatorsSchema, offered nowhere (post-#2940/#2989 remainder) Six new builder operators (startsWith/endsWith/isNull/isNotNull/exists/notExists) authorable, translated both ways, and the operator guard now DERIVES the spec set from FieldOperatorsSchema.shape and asserts every token is emittable

Guards

Ten parity/coverage suites (~100 new tests), all per the #2897 template — each pair fails the moment the spec or the renderer moves alone. Prerequisite devDeps: fields, mobile, plugin-charts, providers gain @objectstack/spec (lockfile checked — no second zod peer variant materialized; the components trap from #2993 did not recur).

Verification

  • Combined suite over the 13 touched packages: 3464 tests, all green (the one mid-run failure was ADR-0053's toggle-rejection test, resolved by keeping the authoring contract narrow and widening only the render branch).
  • pnpm build 43/43; tsc --noEmit clean on all 13; eslint 0 errors on every touched file.

Refs #2901. Remaining tiers: #2943 (loud failures), #2944/#2945 (forks & vocabularies).

🤖 Generated with Claude Code

@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 9:24am

Request Review

… into nothing (#2942)

Every row below is the #2897 shape — validates at authoring time, renders
nothing (or a dead control), no test fails and no warning fires:

- UserFilters `element: 'toggle'`: `default: return null` deleted the ENTIRE
  filter bar for stored toggle configs. The existing-but-unreachable
  ToggleFilters branch is wired; authoring stays untypeable (ADR-0053) while
  stored metadata keeps rendering (spec ADR-0047 §3.4a).
- UserFilters `date-range` / `text`: dead "No options" popovers become a
  from/to date pair (emits >=/<= bounds) and a contains search input.
- useAnimation: preset/easing maps re-keyed to the spec's underscore
  vocabulary (+ rotate/flip via usePageTransition's classes); hyphen dialect
  and `scale-fade` stay accepted for stored configs. The
  `EASING_MAP[easing] || easing` fallthrough no longer emits invalid CSS.
- NotificationContext: displayType materialized (spec default toast, legacy
  `modal` presents as alert) and the unions now match NotificationTypeSchema
  / NotificationPositionSchema instead of claiming to.
- useNavigationOverlay: the spec `size` buckets resolve to viewport-clamped
  widths off app-shell too (explicit `width` still wins; `auto` stays
  host-derived).
- Both ThemeProviders: `mode: 'auto'` follows the OS instead of adding a
  dead `auto` class that locked the light theme; `system` kept as the
  pre-spec spelling.
- AdvancedChart: the single-value families (gauge/solid-gauge/metric/kpi/
  bullet) render the measure as a number, table/pivot name their owning
  component, unknown types are named — never the bar SHELL with null series
  marks that was indistinguishable from an empty dataset (reachable via
  ChartRenderer's `schema.chartType ?? spec.chartType` bypass).
- Timeline: the spec `scale` key is read at last (legacy `timeScale` kept);
  hour/quarter/year generate real gantt header buckets instead of a blank
  axis.
- Toaster: position (all six spec values) and `limit` reach sonner instead
  of being discarded by a bare `<SonnerToaster />`.
- useSpecGesture: the DECLARED `config.type` drives recognition —
  pan/drag/rotate/double_tap no longer collapse to tap; useGesture gains
  real double-tap (two taps, not one) and two-touch pinch/rotate deltas.
- ReportViewer: `aggregation: 'distinct'` computes a distinct count instead
  of a blank summary cell.
- FieldEditWidget: inline resolution goes through the form's alias table, so
  `json` gets the code editor, `tree` the lookup picker, and composite/
  record/repeater/video/audio/autonumber resolve to their documented
  exclusions; ObjectGrid's editability gate consults the same contract, so
  a `composite` cell is read-only instead of a value-corrupting text box.
- FilterBuilder: $startsWith/$endsWith/$null/$exists become authorable
  (startsWith/endsWith/isNull/isNotNull/exists/notExists) and round-trip
  through condToMongo/kvToCondition — every FieldOperatorsSchema token is
  now reachable from the UI.

The export-menu row (PDF silently downloading nothing) was fixed
concurrently by #2999, which this branch rebases onto and defers to.

Each fix lands with a spec-parity guard per the #2897 template; `fields`,
`mobile`, `plugin-charts` and `providers` gain the `@objectstack/spec`
devDependency that makes those guards possible (no second zod peer variant
materialized — verified in the lockfile).

Refs #2942, #2901

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

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

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.20KB 2.97KB
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.25KB 1.01KB
auth (org-roles.js) 6.72KB 2.85KB
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) 456.06KB 99.57KB
core (index.js) 2.16KB 0.78KB
create-plugin (index.js) 9.28KB 2.98KB
data-objectstack (index.js) 134.67KB 34.24KB
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) 6.84KB 2.42KB
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 (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) 58.71KB 16.56KB
plugin-chatbot (index.js) 180.09KB 42.72KB
plugin-dashboard (index.js) 110.24KB 28.47KB
plugin-designer (index.js) 210.56KB 42.56KB
plugin-detail (index.js) 216.52KB 53.02KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 104.48KB 25.29KB
plugin-gantt (index.js) 162.26KB 39.53KB
plugin-grid (index.js) 180.86KB 47.45KB
plugin-kanban (index.js) 47.82KB 13.18KB
plugin-list (index.js) 102.39KB 24.18KB
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.88KB 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) 0.77KB 0.41KB
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.00KB 0.96KB
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.04KB 1.93KB
types (system-fields.js) 2.39KB 1.17KB
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 07580d6 into main Jul 30, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/tier2-spec-silent-absent branch July 30, 2026 09:30
os-zhuang added a commit that referenced this pull request Jul 30, 2026
…3014) (#3071)

`NotificationProvider` handed every notification to the host's `onToast`
delegate regardless of `displayType`, so all five spec types presented as a
toast — an author picking `banner` or `inline` got a transient overlay. #3008
made the value reach the delegate; nothing branched on it.

Each type now routes to its own surface:

  toast    -> the host's `onToast` delegate (unchanged)
  snackbar -> <NotificationSnackbar />  bottom-anchored, one at a time, 1 action
  banner   -> <NotificationBanners />   page-width strip, in the content flow
  alert    -> <NotificationAlerts />    blocking acknowledgement, FIFO queue
  inline   -> <NotificationInline />    in place, at the raising surface

Banner/inline placement is the host's — they are not overlays — so the context
exposes the items (`useNotificationsByPresentation`) and the surfaces subscribe.
`alert` renders through the AlertDialog primitive, NOT the action system's
ModalHandler: that handler resolves a page and reports an ActionResult, while a
notification alert has no schema, target or result.

Auto-dismiss follows the presentation: toast/snackbar stay transient,
banner/alert/inline are persistent unless `duration` is set explicitly (a
persistent banner used to evaporate on the shared 5s toast timer).

`NOTIFICATION_PRESENTATIONS` is `Record<NotificationPresentation, …>`, so a new
spec enum member fails type-check until its presentation is decided; the parity
test asserts the table covers `NotificationTypeSchema` exactly and that no two
types share a surface. Raising a surface-rendered type with nothing mounted to
present it warns in dev instead of vanishing.

Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tier 2 (#2901): spec values that validate and then render nothing

1 participant