Skip to content

The SDUI authoring preview stops teaching Tailwind in page source; the two plumbing previews declare the exception - #5662

Merged
os-sales merged 3 commits into
mainfrom
claude/issue-5470-sdui-preview-tailwind
Aug 22, 2026
Merged

The SDUI authoring preview stops teaching Tailwind in page source; the two plumbing previews declare the exception#5662
os-sales merged 3 commits into
mainfrom
claude/issue-5470-sdui-preview-tailwind

Conversation

@os-sales

@os-sales os-sales commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Part of #5470

Triage settled the remedy fork on the card itself (2026-08-21): (a) for
sdui-tiers-preview.tsx, (b) for the other two. This implements that split. The one
judgement triage left open — whether the rewrite costs visual fidelity the manual dogfood
loop uses — is answered below with a measured before/after, not an opinion: it costs
none, and it gains dark mode.
Part of, not Fixes, because the card's headline number
is wrong (see The counts) and the maintainer may want the corrected figure recorded
before it closes.

Note for readers: GitHub's body sanitizer strips bare angle-bracket fragments even inside
backticks (it ate seven of them on the first revision of this description), so every JSX
tag below is written inside a fenced block or without its brackets.

The defect

A page's source is runtime metadata. The console's Tailwind is compiled at build
time by scanning the console's own srcapps/console/src/index.css line 12:

@source '../src/**/*.{ts,tsx}';

— with no safelist. So a utility class authored in real page metadata produces no CSS and
no error anywhere. That is the ADR-0065 "works only by coincidence" failure, recorded as
ADR-0080's 2026-06-30 amendment, and os validate reports it as
page-source-className-tailwind.

The three harnesses are the one place in the repo where the rule is violated and still
looks right
, because each harness file is itself inside that scanned src — their own
headers said as much. Lift one of their source strings into a real page and every class
evaporates silently.

The counts — the card's 79 is a whole-file grep -c

Re-measured on the current tip. The three files are byte-identical to the card's
measurement commit (blob hashes at 9bd753682 and at branch point 7e811687a match, and
no commit touched them in between), so the difference is method, not drift:

file card className= inside page source rule's own count
sdui-jsx-preview.tsx 40 48 48
sdui-tiers-preview.tsx 25 26 26
sdui-workbench-preview.tsx 14 21 21
total 79 95 95

The card's numbers reproduce exactly as:

grep -c 'className=' apps/console/src/sdui-jsx-preview.tsx        # 40
grep -c 'className=' apps/console/src/sdui-tiers-preview.tsx      # 25
grep -c 'className=' apps/console/src/sdui-workbench-preview.tsx  # 14

— which counts matching lines, not occurrences, and reads the whole file, so it
includes the harnesses' own console JSX (legitimate Tailwind, outside any page source). The
middle column counts occurrences strictly inside the page-source template literals; the
right column is what validatePageSourceStyling itself reports, and it agrees. The card
undercounts the violation.

What changed

(a) sdui-tiers-preview.tsx — rewritten to each tier's real primitive. It is the file
that labels itself "Browser preview for the two AI-authoring tiers", so it is the one
making an authoring claim. Its two sources now carry zero className:

  • kind:'html' — layout from the blocks' own structured props, everything else a JSON
    style object:

    <flex direction="col" align="stretch" gap={6}>
      <hr style={{"border":"0","borderTop":"1px solid hsl(var(--border))"}} />
      <ul style={{"paddingLeft":"var(--space-6)","listStyleType":"disc"}}></ul>
    </flex>

    Quoted keys and quoted values are load-bearing: the html tier materializes a braced
    attribute with JSON.parse and keeps anything else as a deferred { $expr }, so
    JS-object syntax parses without complaint and renders nothing. A test covers exactly
    that.

  • kind:'react' — inline style objects, real JS so shared style objects are just consts.

  • Colours on both tiers as hsl(var(--token)).

(b) sdui-jsx-preview.tsx and sdui-workbench-preview.tsx — kept, and declared. They
are renderer-plumbing previews (does the tier compile and mount the registered blocks; do
the real ListView / ObjectForm blocks interact inside an executed react page). Each
header now carries an ADR-0080 EXCEPTION note naming the build-scan coincidence, saying
the source is not a pattern to copy, and pointing at the authoring example.

A gate that can see the exception. __tests__/sdui-preview-page-source-styling.test.ts
runs the shipped rulevalidatePageSourceStyling from @objectstack/lint, the same
finding an author gets from os validate — not a local re-implementation, because a copy
of a rule cannot disagree with itself. It pins: zero findings for the authoring harness;
expected findings with the header note for the other two; and no third path, over an
import.meta.glob('../*-preview.tsx') enumeration so a new harness is judged without
anyone remembering to list it.

Verification

The gate is measured capable of failing. Three mutation legs, each confirmed on disk
(anchor counts before/after — an editor's exit code proves nothing) and each restored under
a trap, restoration verified by blob hash:

leg mutation on disk result
1 re-add className="p-4" to the rewritten html source target 1→0, injected 0→1 2 failed — "authors no Tailwind", "declared shapes"
2 delete the ADR-0080 EXCEPTION line from sdui-jsx-preview.tsx anchor lines 1→0 2 failed — "declares the exception", "declared shapes"
3 JS-object style instead of JSON in the html source json-form 1→0, js-form 0→1 1 failed — "style objects materialize"

Leg 3's message is the trap stated by the tool itself:

→ expected { Object ($expr) } to not have property "$expr"

Each leg was re-measured against the final tree after the test's read path changed. The
in-test control specimen is committed too: the rule must fire on a dirty specimen and stay
silent on a clean one, so a green run means clean, not rule inert.

Rendered in a real browser — Chromium (Playwright) against vite dev serving
sdui-tiers-preview.html, not merely asserted:

  • structured props produce real CSS: the flex column reports display: flex,
    flex-direction: column, gap: 24px
  • JSON style materializes: 12 styled nodes; the list → list-style-type: disc; padding-left: 24px; the blockquote → token background, 4px left border, 16px padding
  • react tier interaction still works: click → Clicked 1 times; sort by name → first row
    Acme (was Umbrella by amount); 5 rows, Total: $465,000
  • zero page errors on both harnesses; the untouched jsx preview still renders its 6 cards
  • theme follow-through, which is the fidelity gain: every colour flips under .dark
    h1 rgb(2,8,23)rgb(248,250,252), body rgb(246,246,249)rgb(14,14,16),
    blockquote rgb(237,237,242)rgb(39,39,43). The old sources hard-coded slate/indigo
    and did not move.

One real regression, caught only by the browser and fixed. The flex block's align
defaults to start (items-start), so the horizontal rule — no intrinsic width —
collapsed. Playwright reported:

123 × locator resolved to hidden <hr data-obj-type="hr"/>

…while its computed border was correct all along, so nothing static could have caught it.
align="stretch" is the canonical structured prop for it; re-measured, the rule, the
blockquote and the image all report 688px in a 688px column, matching the pre-change page.

Fidelity, measured against the base. The pre-change file was checked out at
7e811687a (26 classNames — the pristine count re-asserted), re-rendered, and compared.
Same structure, same content, same richness; every difference is the palette moving from
fixed slate/indigo to theme tokens. Two things that look like losses are not from this
change and are identical in both renders: the placehold.co banner fails to load (the
sandbox proxy blocks outbound; ERR_TUNNEL_CONNECTION_FAILED, one occurrence in each
version), and the space between a text run and an adjacent inline element is dropped —
A <strong>x</strong> page renders as Axpage — filed as #5661, a sdui-parser defect
with its own fork. The other console error is a /favicon.ico 404.

Gates

Run at the final commit 70fc8b9a6, clean tree, each exit code captured before any
pipe and each verdict quoted from the gate's own output:

check-changeset-presence    EXIT=0  ✅ 4 source file(s) … declares 1 changeset(s) … EMPTY frontmatter … complete answer to this gate
check-changeset-no-major    EXIT=0  ✅ No changeset declares a `major` bump.
check-changeset-fixed       EXIT=0  ✅ All workspace packages are in the changeset fixed group.
check-control-bytes         EXIT=0  ✅ scanned 4705 tracked text file(s); skipped 85 binary
check-phantom-dependencies  EXIT=0  ✅ Every in-scope import is declared by the package that publishes it.
check-lint-coverage         EXIT=0  ✅ 46/46 packages linted, 0 with outstanding errors
check-type-check-coverage   EXIT=0  ✅ 45/46 via `type-check`, 0 errors outstanding
check-package-self-import   EXIT=0  ✅ No package names itself inside its own src/.
turbo type-check --filter=@object-ui/console  EXIT=0  (36 tasks successful; "tsc --noEmit && tsc -b tsconfig.node.json --force" echoed)
vitest --project @object-ui/console           EXIT=0  Test Files 69 passed (69) · Tests 749 passed (749)

ESLint — a declared narrowing, not a skipped run. eslint apps/console was run rather
than the repo-wide eslint .: 168 files enumerated by ESLint itself, count read from
--format json, 0 errors (200 warnings, all pre-existing
@typescript-eslint/no-explicit-any on the harnesses' as any schema casts — same count
and kind as before). The narrowing is sound because the repo's eslint.config.js declares
no parserOptions.project and no projectService, i.e. no type-aware linting, so no
rule's verdict on a file can depend on another file's contents — and every file this branch
touches is under apps/console (plus a changeset .md and the lockfile, neither of which
files: ['**/*.{ts,tsx}'] matches). CI runs the full farm regardless.

Notes

  • The changeset has an empty frontmatter: this publishes nothing. apps/console's vite
    config declares no build.rollupOptions.input — resolved config, measured: input unset
    → vite's default single index.html entry — so the three sdui-*-preview.html entries
    and the modules behind them are dev-server-only and never enter dist/.
    @object-ui/console's files omits src, and its exports map has exactly one entry
    (../plugin.js, built from tsconfig.plugin.json including plugin.ts alone,
    which imports nothing from src/). Re-verified this round; the shape has not moved.
  • @objectstack/lint is added to apps/console's devDependencies (^17.0.0, matching
    @object-ui/app-shell) because the test imports the real rule. It is not in the workspace
    root's devDependencies, so check-phantom-dependencies would have called it a phantom.
  • Out of the fence for this round and untouched: packages/components/src/renderers/** and
    the packages/types declaration sites. Noted while reading, not fixed:
    packages/components/src/renderers/layout/page.tsx:541 still carries the retracted
    "HTML+Tailwind" framing in a comment — that is The ADR-0080 page-source styling retraction reached 3 of 11 prose sites — 8 more still teach "JSX/HTML + Tailwind", in two spellings #5461's grep cannot see #5469's surface, not this one's.

Generated by Claude Code

claude added 3 commits August 22, 2026 01:39
…age source

`apps/console/src`'s three ADR-0080 browser preview harnesses authored Tailwind
classNames inside real page `source` strings. Page `source` is RUNTIME metadata:
the console's Tailwind is compiled at BUILD time by scanning the console's own
`src` (`@source '../src/**'` in `index.css`) with no safelist, so a utility class
authored in real page metadata produces no CSS and no error anywhere — the
ADR-0065 "works only by coincidence" failure, recorded as ADR-0080's 2026-06-30
amendment. These harnesses are the one place in the repo where the rule is
violated AND STILL LOOKS RIGHT, because each harness file is itself inside the
scanned `src`.

Split by what each harness claims to be:

- `sdui-tiers-preview.tsx` labels itself "Browser preview for the two
  AI-authoring tiers", so it is the one making an authoring claim. Its two
  sources are rewritten to each tier's real primitive — the html tier with
  `<flex>`'s structured props plus JSON `style` objects, the react tier with
  inline `style` objects, colours as `hsl(var(--token))`. It now demonstrates
  what authors are told to write, exercises the token path in a browser, and
  follows light/dark instead of pinning a fixed slate/indigo palette.
- `sdui-jsx-preview.tsx` and `sdui-workbench-preview.tsx` are renderer-plumbing
  previews. They keep their Tailwind and declare the exception in their headers,
  naming the build-scan coincidence and pointing at the authoring example.

`__tests__/sdui-preview-page-source-styling.test.ts` pins both halves against the
shipped rule itself (`validatePageSourceStyling` /
`page-source-className-tailwind` from `@objectstack/lint`, the same finding
`os validate` gives an author) rather than a local re-implementation: zero
findings for the authoring harness, expected findings WITH the header note for
the other two, and no third path for a new harness.

Part of #5470

Co-authored-by: Claude <noreply@anthropic.com>
…de:fs

The console app's tsconfig is browser-only (`lib: ES2020, DOM`, `types` without
`node`), so a `node:fs` import passes under Vitest and fails `tsc` in the
console's own build — the trap `insecure-origin-crypto.placement.test.ts`
already records. `import.meta.glob('../*-preview.tsx', { query: '?raw' })` reads
the same bytes and is a better enumeration besides: Vite expands it against the
real directory at transform time, so a NEW preview harness is judged without
anyone remembering to list it.

Co-authored-by: Claude <noreply@anthropic.com>
…zero-width

`<flex>`'s `align` defaults to `start` (`items-start`), which makes a child with
no intrinsic width collapse. Measured in a real browser: the rewritten html
source's `<hr>` resolved to `hidden` — Playwright reported
`123 x locator resolved to hidden <hr data-obj-type="hr"/>` — while its computed
border was correct all along, so nothing static could have caught it.

`align="stretch"` is the canonical structured prop for it and keeps the fix on
this tier's own primitive. Re-measured: `alignItems: stretch`, and the `<hr>`,
`<blockquote>` and `<img>` all report 688px inside a 688px column, matching what
the pre-change page rendered.

Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 52 chunks) 3911.9 KB 3990.2 KB
Main entry chunk (gzip) 151.6 KB 350 KB
Entry file index-C6kaIJXF.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 (index.js) 10.04KB 3.72KB
app-shell (runtime-config.js) 11.22KB 3.78KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 29.34KB 7.05KB
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) 6.35KB 2.43KB
auth (index.js) 2.77KB 1.22KB
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.02KB 0.89KB
auth (useIsWorkspaceAdmin.js) 3.04KB 1.45KB
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) 506.21KB 113.58KB
core (index.js) 4.51KB 1.80KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 160.38KB 44.54KB
fields (index.js) 238.85KB 60.13KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 23.13KB 7.63KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
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.35KB 3.31KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.42KB 1.42KB
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.81KB 0.83KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.65KB 18.32KB
plugin-chatbot (index.js) 181.41KB 43.22KB
plugin-dashboard (index.js) 128.33KB 32.93KB
plugin-designer (index.js) 212.30KB 42.80KB
plugin-detail (index.js) 242.16KB 60.90KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 125.07KB 30.43KB
plugin-gantt (index.js) 164.10KB 39.87KB
plugin-grid (index.js) 200.79KB 54.26KB
plugin-kanban (index.js) 52.93KB 14.60KB
plugin-list (index.js) 111.74KB 27.18KB
plugin-map (index.js) 20.06KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.49KB 11.93KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.54KB 20.69KB
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) 3.77KB 1.33KB
react (SchemaRenderer.js) 43.66KB 14.77KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.33KB 0.69KB
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 (index.js) 4.77KB 2.16KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 10.76KB 3.17KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 6.92KB 2.40KB
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 (dashboard-filter-alias.js) 6.23KB 2.74KB
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 (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.08KB 1.53KB
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 (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
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

Development

Successfully merging this pull request may close these issues.

2 participants