Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/7115-root-readme-doc-gate-surface.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
---

Docs and gates only: the root `README.md` — the repository's landing page and the
most-read authored file in it — was outside the scan surface of every doc gate,
and had been teaching `stat-card` four times in its flagship "dashboard in JSON"
example. Nothing registers `stat-card`, so a reader who copied the headline
snippet got four OBJUI-001 "Unknown component type" panels.

The file now joins the scan surface of `check-doc-component-types` and
`check-doc-snippet-types`, and the four widgets are retargeted onto `statistic`,
which is registered and declares a `value` carriage row, so the example keeps its
expressions. Per the maintainer's 2026-09-01 ruling on the (A)/(B) fork —
option (B), no new carriage rows — four documentation sites that authored `${…}`
in keys with no carriage row now teach what actually renders instead.

No package source changed, so this ships nothing: `check-changeset-presence`
independently reports "no changeset is owed" for this diff. The declaration
states the release intent rather than bumping anything.
46 changes: 22 additions & 24 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,9 +134,10 @@ function UserForm() {

// Object UI: 20 lines
const schema = {
type: "crud",
api: "/api/users",
columns: [...]
type: "object-form",
objectName: "user",
mode: "create",
fields: ["name", "email", "role"]
}
```

Expand DownExpand Up@@ -217,9 +218,9 @@ const schema = {
type: "grid",
columns: 3,
items: [
{ type: "card", title: "Total Users", value: "${stats.users}" },
{ type: "card", title: "Revenue", value: "${stats.revenue}" },
{ type: "card", title: "Orders", value: "${stats.orders}" }
{ type: "statistic", label: "Total Users", value: "${stats.users}" },
{ type: "statistic", label: "Revenue", value: "${stats.revenue}" },
{ type: "statistic", label: "Orders", value: "${stats.orders}" }
]
}
}
Expand DownExpand Up@@ -253,30 +254,27 @@ export default App
]},
{ "name": "message", "type": "textarea", "label": "Message", "required": true }
],
"actions": [{ "type": "submit", "label": "Send Message" }]
"submitLabel": "Send Message"
}
```

#### 📊 Data Grid

```json
{
"type": "crud",
"api": "/api/users",
"type": "object-grid",
"objectName": "user",
"title": "Users",
"columns": [
{ "name": "name", "label": "Name", "sortable": true },
{ "name": "email", "label": "Email" },
{ "name": "role", "label": "Role", "type": "select", "options": ["Admin", "User", "Viewer"] },
{ "name": "status", "label": "Status", "type": "badge" },
{ "name": "created_at", "label": "Joined", "type": "date" }
],
"filters": [
{ "name": "role", "type": "select", "label": "Filter by Role" },
{ "name": "status", "type": "select", "label": "Filter by Status" }
{ "field": "name", "label": "Name", "sortable": true },
{ "field": "email", "label": "Email" },
{ "field": "role", "label": "Role" },
{ "field": "status", "label": "Status" },
{ "field": "created_at", "label": "Joined" }
],
"showSearch": true,
"showCreate": true,
"showExport": true
"showFilters": true,
"operations": { "create": true, "read": true, "update": true, "delete": true, "export": true }
}
```

Expand All@@ -287,10 +285,10 @@ export default App
"type": "dashboard",
"title": "Sales Dashboard",
"widgets": [
{ "type": "stat-card", "title": "Revenue", "value": "${stats.revenue}", "trend": "+12%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Orders", "value": "${stats.orders}", "trend": "+8%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Customers", "value": "${stats.customers}", "trend": "+5%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Conversion", "value": "${stats.conversion}", "trend": "-2%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Revenue", "value": "${stats.revenue}", "trend": "up", "description": "+12%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Orders", "value": "${stats.orders}", "trend": "up", "description": "+8%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Customers", "value": "${stats.customers}", "trend": "up", "description": "+5%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Conversion", "value": "${stats.conversion}", "trend": "down", "description": "-2%", "w": 3, "h": 1 },
{ "type": "chart", "chartType": "line", "title": "Revenue Over Time", "w": 8, "h": 3 },
{ "type": "chart", "chartType": "pie", "title": "Sales by Region", "w": 4, "h": 3 }
]
Expand Down
21 changes: 14 additions & 7 deletions content/docs/guide/expressions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -382,11 +382,17 @@ Available: All standard `Math` functions

### Percentage Bar

`progress` has no row in the expression carriage map, so its `value` and `label`
are read off the node exactly as written — a `${…}` in either reaches the screen
as those characters. Compute the percentage in the data you hand the renderer;
the condition keys are evaluated on every type and stay expressions:

```json
{
"type": "progress",
"value": "${(completed / total) * 100}",
"label": "${completed} of ${total} completed"
"value": 75,
"label": "75% complete",
"visibleOn": "${total > 0}"
}
```

Expand DownExpand Up@@ -445,13 +451,14 @@ Available: All standard `Math` functions

### Computed Fields

`input` has no row in the expression carriage map either, so a computed total
cannot be carried by its `value`. Show it with a `text` node, whose `content` is
evaluated on every component type:

```json
{
"type": "input",
"name": "total",
"label": "Total",
"value": "${form.price * form.quantity}",
"disabled": true
"type": "text",
"content": "Total: ${form.price * form.quantity}"
}
```

Expand Down
10 changes: 7 additions & 3 deletions packages/plugin-dashboard/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -239,7 +239,11 @@ const schema = {

## Integration with Data Sources

Connect dashboard to live data:
Connect dashboard to live data. `metric-card` renders the `value` it is handed:
it has no row in the spec's expression carriage map, so a `${…}` written in
`value` or `trend` reaches the screen as those characters. A widget that should
read live data is one of the `object-*` types above — they resolve the spec's
per-element `dataSource` binding and query the object themselves.

```typescript
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
Expand All@@ -256,8 +260,8 @@ const schema = {
{
type: 'metric-card',
title: 'Total Users',
value: '${data.metrics.totalUsers}',
trend: '${data.metrics.userTrend}'
value: 12480,
trend: 'up'
}
]
};
Expand Down
10 changes: 8 additions & 2 deletions packages/react/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,11 +44,17 @@ import { SchemaRenderer } from '@object-ui/react'
const schema = {
type: 'form',
body: [
{
// `content` is evaluated on every component type. `input` has no row in
// the spec's expression carriage map, so a `${…}` in ITS `value` would be
// rendered as those characters rather than resolved.
type: 'text',
content: 'Editing ${user.name}'
},
{
type: 'input',
name: 'name',
label: 'Name',
value: '${user.name}'
label: 'Name'
}
]
}
Expand Down
73 changes: 72 additions & 1 deletion scripts/__tests__/check-doc-component-types.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import { execFileSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
Expand DownExpand Up@@ -715,6 +715,77 @@ describe('objectui#5342 — the key errors the widened collector found stay fixe
});
});

// ── objectui#7115: the root README joined the scan surface ───────────────────

/**
* objectui#7115 — the root `README.md` sat outside EVERY doc gate's scan
* surface. This gate walked `content/docs`; `check-doc-snippet-types.mjs` walked
* `content/docs` plus the package READMEs; the most-read authored file in the
* repository fell between the two. It taught the unregistered type `stat-card`
* four times, in the flagship "dashboard in JSON" example, for as long as the
* example existed — four OBJUI-001 panels for anyone who copied the headline
* snippet.
*
* ⚠️ Widening a scan surface is the change that can be GREEN ABOUT NOTHING, so
* what is pinned here is the three ways it can quietly stop being real: the walk
* stops reaching the file, the JUDGEMENT stops applying to what it finds there,
* or the content regresses under a surface that still technically covers it.
* The fourth — the name in `ROOT_PAGES` going dangling — is the one that would
* look healthiest, since every count this gate prints stays plausible while the
* surface shrinks back to what objectui#7115 found.
*/
describe('objectui#7115 — the root README is inside the scan surface', () => {
it('the walk really reaches it — the widening, pinned', () => {
const { sites } = scanDocs(repoRoot);
const files = new Set(sites.map((s: { file: string }) => s.file));
expect(
files.has('README.md'),
'no `type` literal was scanned in the root README — the collector narrowed back to content/docs',
).toBe(true);
});

it('judges a root page by the same rule, so an unregistered type there is a finding', () => {
// The mechanism, over a throwaway tree: reaching the file and JUDGING it are
// two different things, and a widening that only did the first would pass
// the assertion above.
const findings = withTree((write) => {
write(
'packages/demo/src/index.tsx',
"ComponentRegistry.register('statistic', S, { namespace: 'ui' });\n",
);
write('README.md', ['```json', '{ "type": "stat-card" }', '```'].join('\n'));
}, (dir) => analyze(dir, BARE).findings as Finding[]);
expect(findings.map((f) => `${f.reason} :: ${f.site} :: ${f.value ?? ''}`)).toEqual([
'unregistered-doc-type :: README.md:2 :: stat-card',
]);
});

it('the flagship dashboard example teaches `statistic`, and keeps its expressions', () => {
const readme = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8');
expect(readme, 'the defect objectui#7115 was filed for is back').not.toContain('stat-card');
const widgets = readme.split('\n').filter((line) => line.includes('"type": "statistic"'));
expect(widgets).toHaveLength(4);
// A retarget, not a downgrade to literals: `statistic` declares a `value`
// carriage row in the spec's expression map, which is precisely why the
// 2026-09-01 ruling picked it over spelling the numbers out.
for (const widget of widgets) expect(widget).toMatch(/"value": "\$\{stats\./);
});

it('refuses to run when a ROOT_PAGES name does not resolve — the silent shrink', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-doc-component-types-rootpages-'));
try {
const run = spawnSync(process.execPath, [path.join(repoRoot, SCRIPT), '--root', dir], {
encoding: 'utf8',
});
expect(run.status, 'a dangling root page must fail the run, not shrink the surface').toBe(1);
expect(run.stderr).toContain('ROOT_PAGES');
expect(run.stderr).toContain('README.md');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

describe('wiring — the gate is reachable and a docs-only PR starts it', () => {
const workflowDir = path.join(repoRoot, '.github/workflows');
const workflowPath = path.join(workflowDir, 'doc-component-types.yml');
Expand Down
40 changes: 38 additions & 2 deletions scripts/__tests__/check-doc-fence-languages.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,8 +5,18 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse as parseYaml } from 'yaml';

import { census, listDocuments as fenceDocuments, TS_FENCE_LANGUAGES as GUARD_TS_FENCES } from '../check-doc-fence-languages.mjs';
import { listDocuments as snippetDocuments, TS_FENCE_LANGUAGES as GATE_TS_FENCES } from '../check-doc-snippet-types.mjs';
import {
census,
listDocuments as fenceDocuments,
ROOT_PAGES as FENCE_ROOT_PAGES,
TS_FENCE_LANGUAGES as GUARD_TS_FENCES,
} from '../check-doc-fence-languages.mjs';
import {
listDocuments as snippetDocuments,
ROOT_PAGES as SNIPPET_ROOT_PAGES,
TS_FENCE_LANGUAGES as GATE_TS_FENCES,
} from '../check-doc-snippet-types.mjs';
import { ROOT_PAGES as COMPONENT_ROOT_PAGES } from '../check-doc-component-types.mjs';

const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..');
const GUARD = 'scripts/check-doc-fence-languages.mjs';
Expand DownExpand Up@@ -53,6 +63,32 @@ describe('check-doc-fence-languages: the scan surface is check-doc-snippet-types
it('treats exactly the snippet gate’s fence languages as already-covered', () => {
expect([...GUARD_TS_FENCES].sort()).toEqual([...GATE_TS_FENCES].sort());
});

/**
* objectui#7115 — the ROOT_PAGES half of the surface, pinned across ALL THREE
* doc gates rather than two.
*
* The document-list assertion above is what caught this: objectui#7115 widened
* `check-doc-component-types` and `check-doc-snippet-types` onto the root
* `README.md` — the two gates its ruling named — and this file went red,
* because a third gate is coupled to that surface by construction. The lists
* are equal again, but list equality alone would not have said WHERE they
* diverged, and `check-doc-component-types`'s surface is deliberately narrower
* (it does not walk the package READMEs), so it cannot join that comparison.
*
* This is the piece all three DO share. Each carries its own copy for its own
* install-free reason; comparing the copies is what keeps "copy freely" honest.
*/
it('all three doc gates carry the same ROOT_PAGES — the surface objectui#7115 widened', () => {
expect([...FENCE_ROOT_PAGES]).toEqual(['README.md']);
expect([...SNIPPET_ROOT_PAGES]).toEqual([...FENCE_ROOT_PAGES]);
expect([...COMPONENT_ROOT_PAGES]).toEqual([...FENCE_ROOT_PAGES]);
});

it('the root README is really in this gate’s walk — the widening, pinned', () => {
// Not implied by the equality above: both lists could lose it together.
expect(fenceDocuments(ROOT)).toContain('README.md');
});
});

describe('check-doc-fence-languages: non-vacuity, through the shipped module', () => {
Expand Down
46 changes: 46 additions & 0 deletions scripts/__tests__/check-doc-snippet-types.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -299,6 +299,52 @@ describe('this repository', () => {
});
});

/**
* objectui#7115 — the root `README.md` was in NO doc gate's scan set: this gate
* walked `content/docs` plus the package READMEs, its sibling
* `check-doc-component-types.mjs` walked `content/docs`, and the repository's
* landing page fell between them.
*
* ⚠️ Read the second assertion carefully. Being ON the ungated ledger is NOT a
* claim that this file compiles — it does not; objectui#7417 carries its nine
* measured diagnostics. It is the objectui#5174 distinction, which this script's
* own header states: a document outside the walk is "neither covered NOR
* declared ungated", invisible to the gate's own accounting, while a ledgered
* one is named, counted, re-derived every run and shrink-only.
*/
describe('objectui#7115 — the root README is in the scan set', () => {
it('listDocuments reaches it', () => {
expect(listDocuments(repoRoot)).toContain('README.md');
});

it('is DECLARED debt rather than absent, and its reason names the card that carries it', () => {
expect(Object.keys(UNGATED_DOCS as Record<string, string>)).toContain('README.md');
expect((UNGATED_DOCS as Record<string, string>)['README.md']).toContain('objectui#7417');
});

it('root pages are collected BY NAME, not by the packages walk', () => {
// The mechanism, isolated: a tree with no `content/docs` and no `packages`
// still lists its root page, which is what makes the entry independent of
// the two walks it sits between.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-doc-snippet-types-rootpages-'));
try {
fs.writeFileSync(path.join(dir, 'README.md'), '# root\n');
expect(listDocuments(dir)).toEqual(['README.md']);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it('states in its own source that a dangling root page fails the run', () => {
// The guard lives in `main()`, which takes no `--root`, so it cannot be
// driven from a fixture. Pinned against the source for the same reason the
// exit-code contract is: a silently narrowed surface is this card's defect.
const source = fs.readFileSync(path.join(repoRoot, 'scripts/check-doc-snippet-types.mjs'), 'utf8');
expect(source).toContain('ROOT_PAGES');
expect(source).toMatch(/for \(const name of ROOT_PAGES\) \{\n\s*if \(!existsSync\(join\(repoRoot, name\)\)\)/);
});
});

describe('third-party resolution reaches exactly as far as the imported packages declare', () => {
/** A workspace package with its own `node_modules`, the way pnpm links one. */
function treeWithDependency(files: Record<string, string> = {}): string {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/7115-root-readme-doc-gate-surface.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
---

Docs and gates only: the root `README.md` — the repository's landing page and the
most-read authored file in it — was outside the scan surface of every doc gate,
and had been teaching `stat-card` four times in its flagship "dashboard in JSON"
example. Nothing registers `stat-card`, so a reader who copied the headline
snippet got four OBJUI-001 "Unknown component type" panels.

The file now joins the scan surface of `check-doc-component-types` and
`check-doc-snippet-types`, and the four widgets are retargeted onto `statistic`,
which is registered and declares a `value` carriage row, so the example keeps its
expressions. Per the maintainer's 2026-09-01 ruling on the (A)/(B) fork —
option (B), no new carriage rows — four documentation sites that authored `${…}`
in keys with no carriage row now teach what actually renders instead.

No package source changed, so this ships nothing: `check-changeset-presence`
independently reports "no changeset is owed" for this diff. The declaration
states the release intent rather than bumping anything.
46 changes: 22 additions & 24 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,9 +134,10 @@ function UserForm() {

// Object UI: 20 lines
const schema = {
type: "crud",
api: "/api/users",
columns: [...]
type: "object-form",
objectName: "user",
mode: "create",
fields: ["name", "email", "role"]
}
```

Expand DownExpand Up@@ -217,9 +218,9 @@ const schema = {
type: "grid",
columns: 3,
items: [
{ type: "card", title: "Total Users", value: "${stats.users}" },
{ type: "card", title: "Revenue", value: "${stats.revenue}" },
{ type: "card", title: "Orders", value: "${stats.orders}" }
{ type: "statistic", label: "Total Users", value: "${stats.users}" },
{ type: "statistic", label: "Revenue", value: "${stats.revenue}" },
{ type: "statistic", label: "Orders", value: "${stats.orders}" }
]
}
}
Expand DownExpand Up@@ -253,30 +254,27 @@ export default App
]},
{ "name": "message", "type": "textarea", "label": "Message", "required": true }
],
"actions": [{ "type": "submit", "label": "Send Message" }]
"submitLabel": "Send Message"
}
```

#### 📊 Data Grid

```json
{
"type": "crud",
"api": "/api/users",
"type": "object-grid",
"objectName": "user",
"title": "Users",
"columns": [
{ "name": "name", "label": "Name", "sortable": true },
{ "name": "email", "label": "Email" },
{ "name": "role", "label": "Role", "type": "select", "options": ["Admin", "User", "Viewer"] },
{ "name": "status", "label": "Status", "type": "badge" },
{ "name": "created_at", "label": "Joined", "type": "date" }
],
"filters": [
{ "name": "role", "type": "select", "label": "Filter by Role" },
{ "name": "status", "type": "select", "label": "Filter by Status" }
{ "field": "name", "label": "Name", "sortable": true },
{ "field": "email", "label": "Email" },
{ "field": "role", "label": "Role" },
{ "field": "status", "label": "Status" },
{ "field": "created_at", "label": "Joined" }
],
"showSearch": true,
"showCreate": true,
"showExport": true
"showFilters": true,
"operations": { "create": true, "read": true, "update": true, "delete": true, "export": true }
}
```

Expand All@@ -287,10 +285,10 @@ export default App
"type": "dashboard",
"title": "Sales Dashboard",
"widgets": [
{ "type": "stat-card", "title": "Revenue", "value": "${stats.revenue}", "trend": "+12%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Orders", "value": "${stats.orders}", "trend": "+8%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Customers", "value": "${stats.customers}", "trend": "+5%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Conversion", "value": "${stats.conversion}", "trend": "-2%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Revenue", "value": "${stats.revenue}", "trend": "up", "description": "+12%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Orders", "value": "${stats.orders}", "trend": "up", "description": "+8%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Customers", "value": "${stats.customers}", "trend": "up", "description": "+5%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Conversion", "value": "${stats.conversion}", "trend": "down", "description": "-2%", "w": 3, "h": 1 },
{ "type": "chart", "chartType": "line", "title": "Revenue Over Time", "w": 8, "h": 3 },
{ "type": "chart", "chartType": "pie", "title": "Sales by Region", "w": 4, "h": 3 }
]
Expand Down
21 changes: 14 additions & 7 deletions content/docs/guide/expressions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -382,11 +382,17 @@ Available: All standard `Math` functions

### Percentage Bar

`progress` has no row in the expression carriage map, so its `value` and `label`
are read off the node exactly as written — a `${…}` in either reaches the screen
as those characters. Compute the percentage in the data you hand the renderer;
the condition keys are evaluated on every type and stay expressions:

```json
{
"type": "progress",
"value": "${(completed / total) * 100}",
"label": "${completed} of ${total} completed"
"value": 75,
"label": "75% complete",
"visibleOn": "${total > 0}"
}
```

Expand DownExpand Up@@ -445,13 +451,14 @@ Available: All standard `Math` functions

### Computed Fields

`input` has no row in the expression carriage map either, so a computed total
cannot be carried by its `value`. Show it with a `text` node, whose `content` is
evaluated on every component type:

```json
{
"type": "input",
"name": "total",
"label": "Total",
"value": "${form.price * form.quantity}",
"disabled": true
"type": "text",
"content": "Total: ${form.price * form.quantity}"
}
```

Expand Down
10 changes: 7 additions & 3 deletions packages/plugin-dashboard/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -239,7 +239,11 @@ const schema = {

## Integration with Data Sources

Connect dashboard to live data:
Connect dashboard to live data. `metric-card` renders the `value` it is handed:
it has no row in the spec's expression carriage map, so a `${…}` written in
`value` or `trend` reaches the screen as those characters. A widget that should
read live data is one of the `object-*` types above — they resolve the spec's
per-element `dataSource` binding and query the object themselves.

```typescript
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
Expand All@@ -256,8 +260,8 @@ const schema = {
{
type: 'metric-card',
title: 'Total Users',
value: '${data.metrics.totalUsers}',
trend: '${data.metrics.userTrend}'
value: 12480,
trend: 'up'
}
]
};
Expand Down
10 changes: 8 additions & 2 deletions packages/react/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,11 +44,17 @@ import { SchemaRenderer } from '@object-ui/react'
const schema = {
type: 'form',
body: [
{
// `content` is evaluated on every component type. `input` has no row in
// the spec's expression carriage map, so a `${…}` in ITS `value` would be
// rendered as those characters rather than resolved.
type: 'text',
content: 'Editing ${user.name}'
},
{
type: 'input',
name: 'name',
label: 'Name',
value: '${user.name}'
label: 'Name'
}
]
}
Expand Down
73 changes: 72 additions & 1 deletion scripts/__tests__/check-doc-component-types.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import { execFileSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
Expand DownExpand Up@@ -715,6 +715,77 @@ describe('objectui#5342 — the key errors the widened collector found stay fixe
});
});

// ── objectui#7115: the root README joined the scan surface ───────────────────

/**
* objectui#7115 — the root `README.md` sat outside EVERY doc gate's scan
* surface. This gate walked `content/docs`; `check-doc-snippet-types.mjs` walked
* `content/docs` plus the package READMEs; the most-read authored file in the
* repository fell between the two. It taught the unregistered type `stat-card`
* four times, in the flagship "dashboard in JSON" example, for as long as the
* example existed — four OBJUI-001 panels for anyone who copied the headline
* snippet.
*
* ⚠️ Widening a scan surface is the change that can be GREEN ABOUT NOTHING, so
* what is pinned here is the three ways it can quietly stop being real: the walk
* stops reaching the file, the JUDGEMENT stops applying to what it finds there,
* or the content regresses under a surface that still technically covers it.
* The fourth — the name in `ROOT_PAGES` going dangling — is the one that would
* look healthiest, since every count this gate prints stays plausible while the
* surface shrinks back to what objectui#7115 found.
*/
describe('objectui#7115 — the root README is inside the scan surface', () => {
it('the walk really reaches it — the widening, pinned', () => {
const { sites } = scanDocs(repoRoot);
const files = new Set(sites.map((s: { file: string }) => s.file));
expect(
files.has('README.md'),
'no `type` literal was scanned in the root README — the collector narrowed back to content/docs',
).toBe(true);
});

it('judges a root page by the same rule, so an unregistered type there is a finding', () => {
// The mechanism, over a throwaway tree: reaching the file and JUDGING it are
// two different things, and a widening that only did the first would pass
// the assertion above.
const findings = withTree((write) => {
write(
'packages/demo/src/index.tsx',
"ComponentRegistry.register('statistic', S, { namespace: 'ui' });\n",
);
write('README.md', ['```json', '{ "type": "stat-card" }', '```'].join('\n'));
}, (dir) => analyze(dir, BARE).findings as Finding[]);
expect(findings.map((f) => `${f.reason} :: ${f.site} :: ${f.value ?? ''}`)).toEqual([
'unregistered-doc-type :: README.md:2 :: stat-card',
]);
});

it('the flagship dashboard example teaches `statistic`, and keeps its expressions', () => {
const readme = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8');
expect(readme, 'the defect objectui#7115 was filed for is back').not.toContain('stat-card');
const widgets = readme.split('\n').filter((line) => line.includes('"type": "statistic"'));
expect(widgets).toHaveLength(4);
// A retarget, not a downgrade to literals: `statistic` declares a `value`
// carriage row in the spec's expression map, which is precisely why the
// 2026-09-01 ruling picked it over spelling the numbers out.
for (const widget of widgets) expect(widget).toMatch(/"value": "\$\{stats\./);
});

it('refuses to run when a ROOT_PAGES name does not resolve — the silent shrink', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-doc-component-types-rootpages-'));
try {
const run = spawnSync(process.execPath, [path.join(repoRoot, SCRIPT), '--root', dir], {
encoding: 'utf8',
});
expect(run.status, 'a dangling root page must fail the run, not shrink the surface').toBe(1);
expect(run.stderr).toContain('ROOT_PAGES');
expect(run.stderr).toContain('README.md');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

describe('wiring — the gate is reachable and a docs-only PR starts it', () => {
const workflowDir = path.join(repoRoot, '.github/workflows');
const workflowPath = path.join(workflowDir, 'doc-component-types.yml');
Expand Down
40 changes: 38 additions & 2 deletions scripts/__tests__/check-doc-fence-languages.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,8 +5,18 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse as parseYaml } from 'yaml';

import { census, listDocuments as fenceDocuments, TS_FENCE_LANGUAGES as GUARD_TS_FENCES } from '../check-doc-fence-languages.mjs';
import { listDocuments as snippetDocuments, TS_FENCE_LANGUAGES as GATE_TS_FENCES } from '../check-doc-snippet-types.mjs';
import {
census,
listDocuments as fenceDocuments,
ROOT_PAGES as FENCE_ROOT_PAGES,
TS_FENCE_LANGUAGES as GUARD_TS_FENCES,
} from '../check-doc-fence-languages.mjs';
import {
listDocuments as snippetDocuments,
ROOT_PAGES as SNIPPET_ROOT_PAGES,
TS_FENCE_LANGUAGES as GATE_TS_FENCES,
} from '../check-doc-snippet-types.mjs';
import { ROOT_PAGES as COMPONENT_ROOT_PAGES } from '../check-doc-component-types.mjs';

const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..');
const GUARD = 'scripts/check-doc-fence-languages.mjs';
Expand DownExpand Up@@ -53,6 +63,32 @@ describe('check-doc-fence-languages: the scan surface is check-doc-snippet-types
it('treats exactly the snippet gate’s fence languages as already-covered', () => {
expect([...GUARD_TS_FENCES].sort()).toEqual([...GATE_TS_FENCES].sort());
});

/**
* objectui#7115 — the ROOT_PAGES half of the surface, pinned across ALL THREE
* doc gates rather than two.
*
* The document-list assertion above is what caught this: objectui#7115 widened
* `check-doc-component-types` and `check-doc-snippet-types` onto the root
* `README.md` — the two gates its ruling named — and this file went red,
* because a third gate is coupled to that surface by construction. The lists
* are equal again, but list equality alone would not have said WHERE they
* diverged, and `check-doc-component-types`'s surface is deliberately narrower
* (it does not walk the package READMEs), so it cannot join that comparison.
*
* This is the piece all three DO share. Each carries its own copy for its own
* install-free reason; comparing the copies is what keeps "copy freely" honest.
*/
it('all three doc gates carry the same ROOT_PAGES — the surface objectui#7115 widened', () => {
expect([...FENCE_ROOT_PAGES]).toEqual(['README.md']);
expect([...SNIPPET_ROOT_PAGES]).toEqual([...FENCE_ROOT_PAGES]);
expect([...COMPONENT_ROOT_PAGES]).toEqual([...FENCE_ROOT_PAGES]);
});

it('the root README is really in this gate’s walk — the widening, pinned', () => {
// Not implied by the equality above: both lists could lose it together.
expect(fenceDocuments(ROOT)).toContain('README.md');
});
});

describe('check-doc-fence-languages: non-vacuity, through the shipped module', () => {
Expand Down
46 changes: 46 additions & 0 deletions scripts/__tests__/check-doc-snippet-types.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -299,6 +299,52 @@ describe('this repository', () => {
});
});

/**
* objectui#7115 — the root `README.md` was in NO doc gate's scan set: this gate
* walked `content/docs` plus the package READMEs, its sibling
* `check-doc-component-types.mjs` walked `content/docs`, and the repository's
* landing page fell between them.
*
* ⚠️ Read the second assertion carefully. Being ON the ungated ledger is NOT a
* claim that this file compiles — it does not; objectui#7417 carries its nine
* measured diagnostics. It is the objectui#5174 distinction, which this script's
* own header states: a document outside the walk is "neither covered NOR
* declared ungated", invisible to the gate's own accounting, while a ledgered
* one is named, counted, re-derived every run and shrink-only.
*/
describe('objectui#7115 — the root README is in the scan set', () => {
it('listDocuments reaches it', () => {
expect(listDocuments(repoRoot)).toContain('README.md');
});

it('is DECLARED debt rather than absent, and its reason names the card that carries it', () => {
expect(Object.keys(UNGATED_DOCS as Record<string, string>)).toContain('README.md');
expect((UNGATED_DOCS as Record<string, string>)['README.md']).toContain('objectui#7417');
});

it('root pages are collected BY NAME, not by the packages walk', () => {
// The mechanism, isolated: a tree with no `content/docs` and no `packages`
// still lists its root page, which is what makes the entry independent of
// the two walks it sits between.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-doc-snippet-types-rootpages-'));
try {
fs.writeFileSync(path.join(dir, 'README.md'), '# root\n');
expect(listDocuments(dir)).toEqual(['README.md']);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it('states in its own source that a dangling root page fails the run', () => {
// The guard lives in `main()`, which takes no `--root`, so it cannot be
// driven from a fixture. Pinned against the source for the same reason the
// exit-code contract is: a silently narrowed surface is this card's defect.
const source = fs.readFileSync(path.join(repoRoot, 'scripts/check-doc-snippet-types.mjs'), 'utf8');
expect(source).toContain('ROOT_PAGES');
expect(source).toMatch(/for \(const name of ROOT_PAGES\) \{\n\s*if \(!existsSync\(join\(repoRoot, name\)\)\)/);
});
});

describe('third-party resolution reaches exactly as far as the imported packages declare', () => {
/** A workspace package with its own `node_modules`, the way pnpm links one. */
function treeWithDependency(files: Record<string, string> = {}): string {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/7115-root-readme-doc-gate-surface.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
---

Docs and gates only: the root `README.md` — the repository's landing page and the
most-read authored file in it — was outside the scan surface of every doc gate,
and had been teaching `stat-card` four times in its flagship "dashboard in JSON"
example. Nothing registers `stat-card`, so a reader who copied the headline
snippet got four OBJUI-001 "Unknown component type" panels.

The file now joins the scan surface of `check-doc-component-types` and
`check-doc-snippet-types`, and the four widgets are retargeted onto `statistic`,
which is registered and declares a `value` carriage row, so the example keeps its
expressions. Per the maintainer's 2026-09-01 ruling on the (A)/(B) fork —
option (B), no new carriage rows — four documentation sites that authored `${…}`
in keys with no carriage row now teach what actually renders instead.

No package source changed, so this ships nothing: `check-changeset-presence`
independently reports "no changeset is owed" for this diff. The declaration
states the release intent rather than bumping anything.
46 changes: 22 additions & 24 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,9 +134,10 @@ function UserForm() {

// Object UI: 20 lines
const schema = {
type: "crud",
api: "/api/users",
columns: [...]
type: "object-form",
objectName: "user",
mode: "create",
fields: ["name", "email", "role"]
}
```

Expand DownExpand Up@@ -217,9 +218,9 @@ const schema = {
type: "grid",
columns: 3,
items: [
{ type: "card", title: "Total Users", value: "${stats.users}" },
{ type: "card", title: "Revenue", value: "${stats.revenue}" },
{ type: "card", title: "Orders", value: "${stats.orders}" }
{ type: "statistic", label: "Total Users", value: "${stats.users}" },
{ type: "statistic", label: "Revenue", value: "${stats.revenue}" },
{ type: "statistic", label: "Orders", value: "${stats.orders}" }
]
}
}
Expand DownExpand Up@@ -253,30 +254,27 @@ export default App
]},
{ "name": "message", "type": "textarea", "label": "Message", "required": true }
],
"actions": [{ "type": "submit", "label": "Send Message" }]
"submitLabel": "Send Message"
}
```

#### 📊 Data Grid

```json
{
"type": "crud",
"api": "/api/users",
"type": "object-grid",
"objectName": "user",
"title": "Users",
"columns": [
{ "name": "name", "label": "Name", "sortable": true },
{ "name": "email", "label": "Email" },
{ "name": "role", "label": "Role", "type": "select", "options": ["Admin", "User", "Viewer"] },
{ "name": "status", "label": "Status", "type": "badge" },
{ "name": "created_at", "label": "Joined", "type": "date" }
],
"filters": [
{ "name": "role", "type": "select", "label": "Filter by Role" },
{ "name": "status", "type": "select", "label": "Filter by Status" }
{ "field": "name", "label": "Name", "sortable": true },
{ "field": "email", "label": "Email" },
{ "field": "role", "label": "Role" },
{ "field": "status", "label": "Status" },
{ "field": "created_at", "label": "Joined" }
],
"showSearch": true,
"showCreate": true,
"showExport": true
"showFilters": true,
"operations": { "create": true, "read": true, "update": true, "delete": true, "export": true }
}
```

Expand All@@ -287,10 +285,10 @@ export default App
"type": "dashboard",
"title": "Sales Dashboard",
"widgets": [
{ "type": "stat-card", "title": "Revenue", "value": "${stats.revenue}", "trend": "+12%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Orders", "value": "${stats.orders}", "trend": "+8%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Customers", "value": "${stats.customers}", "trend": "+5%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Conversion", "value": "${stats.conversion}", "trend": "-2%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Revenue", "value": "${stats.revenue}", "trend": "up", "description": "+12%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Orders", "value": "${stats.orders}", "trend": "up", "description": "+8%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Customers", "value": "${stats.customers}", "trend": "up", "description": "+5%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Conversion", "value": "${stats.conversion}", "trend": "down", "description": "-2%", "w": 3, "h": 1 },
{ "type": "chart", "chartType": "line", "title": "Revenue Over Time", "w": 8, "h": 3 },
{ "type": "chart", "chartType": "pie", "title": "Sales by Region", "w": 4, "h": 3 }
]
Expand Down
21 changes: 14 additions & 7 deletions content/docs/guide/expressions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -382,11 +382,17 @@ Available: All standard `Math` functions

### Percentage Bar

`progress` has no row in the expression carriage map, so its `value` and `label`
are read off the node exactly as written — a `${…}` in either reaches the screen
as those characters. Compute the percentage in the data you hand the renderer;
the condition keys are evaluated on every type and stay expressions:

```json
{
"type": "progress",
"value": "${(completed / total) * 100}",
"label": "${completed} of ${total} completed"
"value": 75,
"label": "75% complete",
"visibleOn": "${total > 0}"
}
```

Expand DownExpand Up@@ -445,13 +451,14 @@ Available: All standard `Math` functions

### Computed Fields

`input` has no row in the expression carriage map either, so a computed total
cannot be carried by its `value`. Show it with a `text` node, whose `content` is
evaluated on every component type:

```json
{
"type": "input",
"name": "total",
"label": "Total",
"value": "${form.price * form.quantity}",
"disabled": true
"type": "text",
"content": "Total: ${form.price * form.quantity}"
}
```

Expand Down
10 changes: 7 additions & 3 deletions packages/plugin-dashboard/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -239,7 +239,11 @@ const schema = {

## Integration with Data Sources

Connect dashboard to live data:
Connect dashboard to live data. `metric-card` renders the `value` it is handed:
it has no row in the spec's expression carriage map, so a `${…}` written in
`value` or `trend` reaches the screen as those characters. A widget that should
read live data is one of the `object-*` types above — they resolve the spec's
per-element `dataSource` binding and query the object themselves.

```typescript
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
Expand All@@ -256,8 +260,8 @@ const schema = {
{
type: 'metric-card',
title: 'Total Users',
value: '${data.metrics.totalUsers}',
trend: '${data.metrics.userTrend}'
value: 12480,
trend: 'up'
}
]
};
Expand Down
10 changes: 8 additions & 2 deletions packages/react/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,11 +44,17 @@ import { SchemaRenderer } from '@object-ui/react'
const schema = {
type: 'form',
body: [
{
// `content` is evaluated on every component type. `input` has no row in
// the spec's expression carriage map, so a `${…}` in ITS `value` would be
// rendered as those characters rather than resolved.
type: 'text',
content: 'Editing ${user.name}'
},
{
type: 'input',
name: 'name',
label: 'Name',
value: '${user.name}'
label: 'Name'
}
]
}
Expand Down
73 changes: 72 additions & 1 deletion scripts/__tests__/check-doc-component-types.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import { execFileSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
Expand DownExpand Up@@ -715,6 +715,77 @@ describe('objectui#5342 — the key errors the widened collector found stay fixe
});
});

// ── objectui#7115: the root README joined the scan surface ───────────────────

/**
* objectui#7115 — the root `README.md` sat outside EVERY doc gate's scan
* surface. This gate walked `content/docs`; `check-doc-snippet-types.mjs` walked
* `content/docs` plus the package READMEs; the most-read authored file in the
* repository fell between the two. It taught the unregistered type `stat-card`
* four times, in the flagship "dashboard in JSON" example, for as long as the
* example existed — four OBJUI-001 panels for anyone who copied the headline
* snippet.
*
* ⚠️ Widening a scan surface is the change that can be GREEN ABOUT NOTHING, so
* what is pinned here is the three ways it can quietly stop being real: the walk
* stops reaching the file, the JUDGEMENT stops applying to what it finds there,
* or the content regresses under a surface that still technically covers it.
* The fourth — the name in `ROOT_PAGES` going dangling — is the one that would
* look healthiest, since every count this gate prints stays plausible while the
* surface shrinks back to what objectui#7115 found.
*/
describe('objectui#7115 — the root README is inside the scan surface', () => {
it('the walk really reaches it — the widening, pinned', () => {
const { sites } = scanDocs(repoRoot);
const files = new Set(sites.map((s: { file: string }) => s.file));
expect(
files.has('README.md'),
'no `type` literal was scanned in the root README — the collector narrowed back to content/docs',
).toBe(true);
});

it('judges a root page by the same rule, so an unregistered type there is a finding', () => {
// The mechanism, over a throwaway tree: reaching the file and JUDGING it are
// two different things, and a widening that only did the first would pass
// the assertion above.
const findings = withTree((write) => {
write(
'packages/demo/src/index.tsx',
"ComponentRegistry.register('statistic', S, { namespace: 'ui' });\n",
);
write('README.md', ['```json', '{ "type": "stat-card" }', '```'].join('\n'));
}, (dir) => analyze(dir, BARE).findings as Finding[]);
expect(findings.map((f) => `${f.reason} :: ${f.site} :: ${f.value ?? ''}`)).toEqual([
'unregistered-doc-type :: README.md:2 :: stat-card',
]);
});

it('the flagship dashboard example teaches `statistic`, and keeps its expressions', () => {
const readme = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8');
expect(readme, 'the defect objectui#7115 was filed for is back').not.toContain('stat-card');
const widgets = readme.split('\n').filter((line) => line.includes('"type": "statistic"'));
expect(widgets).toHaveLength(4);
// A retarget, not a downgrade to literals: `statistic` declares a `value`
// carriage row in the spec's expression map, which is precisely why the
// 2026-09-01 ruling picked it over spelling the numbers out.
for (const widget of widgets) expect(widget).toMatch(/"value": "\$\{stats\./);
});

it('refuses to run when a ROOT_PAGES name does not resolve — the silent shrink', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-doc-component-types-rootpages-'));
try {
const run = spawnSync(process.execPath, [path.join(repoRoot, SCRIPT), '--root', dir], {
encoding: 'utf8',
});
expect(run.status, 'a dangling root page must fail the run, not shrink the surface').toBe(1);
expect(run.stderr).toContain('ROOT_PAGES');
expect(run.stderr).toContain('README.md');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

describe('wiring — the gate is reachable and a docs-only PR starts it', () => {
const workflowDir = path.join(repoRoot, '.github/workflows');
const workflowPath = path.join(workflowDir, 'doc-component-types.yml');
Expand Down
40 changes: 38 additions & 2 deletions scripts/__tests__/check-doc-fence-languages.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,8 +5,18 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse as parseYaml } from 'yaml';

import { census, listDocuments as fenceDocuments, TS_FENCE_LANGUAGES as GUARD_TS_FENCES } from '../check-doc-fence-languages.mjs';
import { listDocuments as snippetDocuments, TS_FENCE_LANGUAGES as GATE_TS_FENCES } from '../check-doc-snippet-types.mjs';
import {
census,
listDocuments as fenceDocuments,
ROOT_PAGES as FENCE_ROOT_PAGES,
TS_FENCE_LANGUAGES as GUARD_TS_FENCES,
} from '../check-doc-fence-languages.mjs';
import {
listDocuments as snippetDocuments,
ROOT_PAGES as SNIPPET_ROOT_PAGES,
TS_FENCE_LANGUAGES as GATE_TS_FENCES,
} from '../check-doc-snippet-types.mjs';
import { ROOT_PAGES as COMPONENT_ROOT_PAGES } from '../check-doc-component-types.mjs';

const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..');
const GUARD = 'scripts/check-doc-fence-languages.mjs';
Expand DownExpand Up@@ -53,6 +63,32 @@ describe('check-doc-fence-languages: the scan surface is check-doc-snippet-types
it('treats exactly the snippet gate’s fence languages as already-covered', () => {
expect([...GUARD_TS_FENCES].sort()).toEqual([...GATE_TS_FENCES].sort());
});

/**
* objectui#7115 — the ROOT_PAGES half of the surface, pinned across ALL THREE
* doc gates rather than two.
*
* The document-list assertion above is what caught this: objectui#7115 widened
* `check-doc-component-types` and `check-doc-snippet-types` onto the root
* `README.md` — the two gates its ruling named — and this file went red,
* because a third gate is coupled to that surface by construction. The lists
* are equal again, but list equality alone would not have said WHERE they
* diverged, and `check-doc-component-types`'s surface is deliberately narrower
* (it does not walk the package READMEs), so it cannot join that comparison.
*
* This is the piece all three DO share. Each carries its own copy for its own
* install-free reason; comparing the copies is what keeps "copy freely" honest.
*/
it('all three doc gates carry the same ROOT_PAGES — the surface objectui#7115 widened', () => {
expect([...FENCE_ROOT_PAGES]).toEqual(['README.md']);
expect([...SNIPPET_ROOT_PAGES]).toEqual([...FENCE_ROOT_PAGES]);
expect([...COMPONENT_ROOT_PAGES]).toEqual([...FENCE_ROOT_PAGES]);
});

it('the root README is really in this gate’s walk — the widening, pinned', () => {
// Not implied by the equality above: both lists could lose it together.
expect(fenceDocuments(ROOT)).toContain('README.md');
});
});

describe('check-doc-fence-languages: non-vacuity, through the shipped module', () => {
Expand Down
46 changes: 46 additions & 0 deletions scripts/__tests__/check-doc-snippet-types.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -299,6 +299,52 @@ describe('this repository', () => {
});
});

/**
* objectui#7115 — the root `README.md` was in NO doc gate's scan set: this gate
* walked `content/docs` plus the package READMEs, its sibling
* `check-doc-component-types.mjs` walked `content/docs`, and the repository's
* landing page fell between them.
*
* ⚠️ Read the second assertion carefully. Being ON the ungated ledger is NOT a
* claim that this file compiles — it does not; objectui#7417 carries its nine
* measured diagnostics. It is the objectui#5174 distinction, which this script's
* own header states: a document outside the walk is "neither covered NOR
* declared ungated", invisible to the gate's own accounting, while a ledgered
* one is named, counted, re-derived every run and shrink-only.
*/
describe('objectui#7115 — the root README is in the scan set', () => {
it('listDocuments reaches it', () => {
expect(listDocuments(repoRoot)).toContain('README.md');
});

it('is DECLARED debt rather than absent, and its reason names the card that carries it', () => {
expect(Object.keys(UNGATED_DOCS as Record<string, string>)).toContain('README.md');
expect((UNGATED_DOCS as Record<string, string>)['README.md']).toContain('objectui#7417');
});

it('root pages are collected BY NAME, not by the packages walk', () => {
// The mechanism, isolated: a tree with no `content/docs` and no `packages`
// still lists its root page, which is what makes the entry independent of
// the two walks it sits between.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-doc-snippet-types-rootpages-'));
try {
fs.writeFileSync(path.join(dir, 'README.md'), '# root\n');
expect(listDocuments(dir)).toEqual(['README.md']);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it('states in its own source that a dangling root page fails the run', () => {
// The guard lives in `main()`, which takes no `--root`, so it cannot be
// driven from a fixture. Pinned against the source for the same reason the
// exit-code contract is: a silently narrowed surface is this card's defect.
const source = fs.readFileSync(path.join(repoRoot, 'scripts/check-doc-snippet-types.mjs'), 'utf8');
expect(source).toContain('ROOT_PAGES');
expect(source).toMatch(/for \(const name of ROOT_PAGES\) \{\n\s*if \(!existsSync\(join\(repoRoot, name\)\)\)/);
});
});

describe('third-party resolution reaches exactly as far as the imported packages declare', () => {
/** A workspace package with its own `node_modules`, the way pnpm links one. */
function treeWithDependency(files: Record<string, string> = {}): string {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/7115-root-readme-doc-gate-surface.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
---

Docs and gates only: the root `README.md` — the repository's landing page and the
most-read authored file in it — was outside the scan surface of every doc gate,
and had been teaching `stat-card` four times in its flagship "dashboard in JSON"
example. Nothing registers `stat-card`, so a reader who copied the headline
snippet got four OBJUI-001 "Unknown component type" panels.

The file now joins the scan surface of `check-doc-component-types` and
`check-doc-snippet-types`, and the four widgets are retargeted onto `statistic`,
which is registered and declares a `value` carriage row, so the example keeps its
expressions. Per the maintainer's 2026-09-01 ruling on the (A)/(B) fork —
option (B), no new carriage rows — four documentation sites that authored `${…}`
in keys with no carriage row now teach what actually renders instead.

No package source changed, so this ships nothing: `check-changeset-presence`
independently reports "no changeset is owed" for this diff. The declaration
states the release intent rather than bumping anything.
46 changes: 22 additions & 24 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,9 +134,10 @@ function UserForm() {

// Object UI: 20 lines
const schema = {
type: "crud",
api: "/api/users",
columns: [...]
type: "object-form",
objectName: "user",
mode: "create",
fields: ["name", "email", "role"]
}
```

Expand DownExpand Up@@ -217,9 +218,9 @@ const schema = {
type: "grid",
columns: 3,
items: [
{ type: "card", title: "Total Users", value: "${stats.users}" },
{ type: "card", title: "Revenue", value: "${stats.revenue}" },
{ type: "card", title: "Orders", value: "${stats.orders}" }
{ type: "statistic", label: "Total Users", value: "${stats.users}" },
{ type: "statistic", label: "Revenue", value: "${stats.revenue}" },
{ type: "statistic", label: "Orders", value: "${stats.orders}" }
]
}
}
Expand DownExpand Up@@ -253,30 +254,27 @@ export default App
]},
{ "name": "message", "type": "textarea", "label": "Message", "required": true }
],
"actions": [{ "type": "submit", "label": "Send Message" }]
"submitLabel": "Send Message"
}
```

#### 📊 Data Grid

```json
{
"type": "crud",
"api": "/api/users",
"type": "object-grid",
"objectName": "user",
"title": "Users",
"columns": [
{ "name": "name", "label": "Name", "sortable": true },
{ "name": "email", "label": "Email" },
{ "name": "role", "label": "Role", "type": "select", "options": ["Admin", "User", "Viewer"] },
{ "name": "status", "label": "Status", "type": "badge" },
{ "name": "created_at", "label": "Joined", "type": "date" }
],
"filters": [
{ "name": "role", "type": "select", "label": "Filter by Role" },
{ "name": "status", "type": "select", "label": "Filter by Status" }
{ "field": "name", "label": "Name", "sortable": true },
{ "field": "email", "label": "Email" },
{ "field": "role", "label": "Role" },
{ "field": "status", "label": "Status" },
{ "field": "created_at", "label": "Joined" }
],
"showSearch": true,
"showCreate": true,
"showExport": true
"showFilters": true,
"operations": { "create": true, "read": true, "update": true, "delete": true, "export": true }
}
```

Expand All@@ -287,10 +285,10 @@ export default App
"type": "dashboard",
"title": "Sales Dashboard",
"widgets": [
{ "type": "stat-card", "title": "Revenue", "value": "${stats.revenue}", "trend": "+12%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Orders", "value": "${stats.orders}", "trend": "+8%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Customers", "value": "${stats.customers}", "trend": "+5%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Conversion", "value": "${stats.conversion}", "trend": "-2%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Revenue", "value": "${stats.revenue}", "trend": "up", "description": "+12%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Orders", "value": "${stats.orders}", "trend": "up", "description": "+8%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Customers", "value": "${stats.customers}", "trend": "up", "description": "+5%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Conversion", "value": "${stats.conversion}", "trend": "down", "description": "-2%", "w": 3, "h": 1 },
{ "type": "chart", "chartType": "line", "title": "Revenue Over Time", "w": 8, "h": 3 },
{ "type": "chart", "chartType": "pie", "title": "Sales by Region", "w": 4, "h": 3 }
]
Expand Down
21 changes: 14 additions & 7 deletions content/docs/guide/expressions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -382,11 +382,17 @@ Available: All standard `Math` functions

### Percentage Bar

`progress` has no row in the expression carriage map, so its `value` and `label`
are read off the node exactly as written — a `${…}` in either reaches the screen
as those characters. Compute the percentage in the data you hand the renderer;
the condition keys are evaluated on every type and stay expressions:

```json
{
"type": "progress",
"value": "${(completed / total) * 100}",
"label": "${completed} of ${total} completed"
"value": 75,
"label": "75% complete",
"visibleOn": "${total > 0}"
}
```

Expand DownExpand Up@@ -445,13 +451,14 @@ Available: All standard `Math` functions

### Computed Fields

`input` has no row in the expression carriage map either, so a computed total
cannot be carried by its `value`. Show it with a `text` node, whose `content` is
evaluated on every component type:

```json
{
"type": "input",
"name": "total",
"label": "Total",
"value": "${form.price * form.quantity}",
"disabled": true
"type": "text",
"content": "Total: ${form.price * form.quantity}"
}
```

Expand Down
10 changes: 7 additions & 3 deletions packages/plugin-dashboard/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -239,7 +239,11 @@ const schema = {

## Integration with Data Sources

Connect dashboard to live data:
Connect dashboard to live data. `metric-card` renders the `value` it is handed:
it has no row in the spec's expression carriage map, so a `${…}` written in
`value` or `trend` reaches the screen as those characters. A widget that should
read live data is one of the `object-*` types above — they resolve the spec's
per-element `dataSource` binding and query the object themselves.

```typescript
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
Expand All@@ -256,8 +260,8 @@ const schema = {
{
type: 'metric-card',
title: 'Total Users',
value: '${data.metrics.totalUsers}',
trend: '${data.metrics.userTrend}'
value: 12480,
trend: 'up'
}
]
};
Expand Down
10 changes: 8 additions & 2 deletions packages/react/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,11 +44,17 @@ import { SchemaRenderer } from '@object-ui/react'
const schema = {
type: 'form',
body: [
{
// `content` is evaluated on every component type. `input` has no row in
// the spec's expression carriage map, so a `${…}` in ITS `value` would be
// rendered as those characters rather than resolved.
type: 'text',
content: 'Editing ${user.name}'
},
{
type: 'input',
name: 'name',
label: 'Name',
value: '${user.name}'
label: 'Name'
}
]
}
Expand Down
73 changes: 72 additions & 1 deletion scripts/__tests__/check-doc-component-types.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import { execFileSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
Expand DownExpand Up@@ -715,6 +715,77 @@ describe('objectui#5342 — the key errors the widened collector found stay fixe
});
});

// ── objectui#7115: the root README joined the scan surface ───────────────────

/**
* objectui#7115 — the root `README.md` sat outside EVERY doc gate's scan
* surface. This gate walked `content/docs`; `check-doc-snippet-types.mjs` walked
* `content/docs` plus the package READMEs; the most-read authored file in the
* repository fell between the two. It taught the unregistered type `stat-card`
* four times, in the flagship "dashboard in JSON" example, for as long as the
* example existed — four OBJUI-001 panels for anyone who copied the headline
* snippet.
*
* ⚠️ Widening a scan surface is the change that can be GREEN ABOUT NOTHING, so
* what is pinned here is the three ways it can quietly stop being real: the walk
* stops reaching the file, the JUDGEMENT stops applying to what it finds there,
* or the content regresses under a surface that still technically covers it.
* The fourth — the name in `ROOT_PAGES` going dangling — is the one that would
* look healthiest, since every count this gate prints stays plausible while the
* surface shrinks back to what objectui#7115 found.
*/
describe('objectui#7115 — the root README is inside the scan surface', () => {
it('the walk really reaches it — the widening, pinned', () => {
const { sites } = scanDocs(repoRoot);
const files = new Set(sites.map((s: { file: string }) => s.file));
expect(
files.has('README.md'),
'no `type` literal was scanned in the root README — the collector narrowed back to content/docs',
).toBe(true);
});

it('judges a root page by the same rule, so an unregistered type there is a finding', () => {
// The mechanism, over a throwaway tree: reaching the file and JUDGING it are
// two different things, and a widening that only did the first would pass
// the assertion above.
const findings = withTree((write) => {
write(
'packages/demo/src/index.tsx',
"ComponentRegistry.register('statistic', S, { namespace: 'ui' });\n",
);
write('README.md', ['```json', '{ "type": "stat-card" }', '```'].join('\n'));
}, (dir) => analyze(dir, BARE).findings as Finding[]);
expect(findings.map((f) => `${f.reason} :: ${f.site} :: ${f.value ?? ''}`)).toEqual([
'unregistered-doc-type :: README.md:2 :: stat-card',
]);
});

it('the flagship dashboard example teaches `statistic`, and keeps its expressions', () => {
const readme = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8');
expect(readme, 'the defect objectui#7115 was filed for is back').not.toContain('stat-card');
const widgets = readme.split('\n').filter((line) => line.includes('"type": "statistic"'));
expect(widgets).toHaveLength(4);
// A retarget, not a downgrade to literals: `statistic` declares a `value`
// carriage row in the spec's expression map, which is precisely why the
// 2026-09-01 ruling picked it over spelling the numbers out.
for (const widget of widgets) expect(widget).toMatch(/"value": "\$\{stats\./);
});

it('refuses to run when a ROOT_PAGES name does not resolve — the silent shrink', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-doc-component-types-rootpages-'));
try {
const run = spawnSync(process.execPath, [path.join(repoRoot, SCRIPT), '--root', dir], {
encoding: 'utf8',
});
expect(run.status, 'a dangling root page must fail the run, not shrink the surface').toBe(1);
expect(run.stderr).toContain('ROOT_PAGES');
expect(run.stderr).toContain('README.md');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

describe('wiring — the gate is reachable and a docs-only PR starts it', () => {
const workflowDir = path.join(repoRoot, '.github/workflows');
const workflowPath = path.join(workflowDir, 'doc-component-types.yml');
Expand Down
40 changes: 38 additions & 2 deletions scripts/__tests__/check-doc-fence-languages.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,8 +5,18 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse as parseYaml } from 'yaml';

import { census, listDocuments as fenceDocuments, TS_FENCE_LANGUAGES as GUARD_TS_FENCES } from '../check-doc-fence-languages.mjs';
import { listDocuments as snippetDocuments, TS_FENCE_LANGUAGES as GATE_TS_FENCES } from '../check-doc-snippet-types.mjs';
import {
census,
listDocuments as fenceDocuments,
ROOT_PAGES as FENCE_ROOT_PAGES,
TS_FENCE_LANGUAGES as GUARD_TS_FENCES,
} from '../check-doc-fence-languages.mjs';
import {
listDocuments as snippetDocuments,
ROOT_PAGES as SNIPPET_ROOT_PAGES,
TS_FENCE_LANGUAGES as GATE_TS_FENCES,
} from '../check-doc-snippet-types.mjs';
import { ROOT_PAGES as COMPONENT_ROOT_PAGES } from '../check-doc-component-types.mjs';

const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..');
const GUARD = 'scripts/check-doc-fence-languages.mjs';
Expand DownExpand Up@@ -53,6 +63,32 @@ describe('check-doc-fence-languages: the scan surface is check-doc-snippet-types
it('treats exactly the snippet gate’s fence languages as already-covered', () => {
expect([...GUARD_TS_FENCES].sort()).toEqual([...GATE_TS_FENCES].sort());
});

/**
* objectui#7115 — the ROOT_PAGES half of the surface, pinned across ALL THREE
* doc gates rather than two.
*
* The document-list assertion above is what caught this: objectui#7115 widened
* `check-doc-component-types` and `check-doc-snippet-types` onto the root
* `README.md` — the two gates its ruling named — and this file went red,
* because a third gate is coupled to that surface by construction. The lists
* are equal again, but list equality alone would not have said WHERE they
* diverged, and `check-doc-component-types`'s surface is deliberately narrower
* (it does not walk the package READMEs), so it cannot join that comparison.
*
* This is the piece all three DO share. Each carries its own copy for its own
* install-free reason; comparing the copies is what keeps "copy freely" honest.
*/
it('all three doc gates carry the same ROOT_PAGES — the surface objectui#7115 widened', () => {
expect([...FENCE_ROOT_PAGES]).toEqual(['README.md']);
expect([...SNIPPET_ROOT_PAGES]).toEqual([...FENCE_ROOT_PAGES]);
expect([...COMPONENT_ROOT_PAGES]).toEqual([...FENCE_ROOT_PAGES]);
});

it('the root README is really in this gate’s walk — the widening, pinned', () => {
// Not implied by the equality above: both lists could lose it together.
expect(fenceDocuments(ROOT)).toContain('README.md');
});
});

describe('check-doc-fence-languages: non-vacuity, through the shipped module', () => {
Expand Down
46 changes: 46 additions & 0 deletions scripts/__tests__/check-doc-snippet-types.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -299,6 +299,52 @@ describe('this repository', () => {
});
});

/**
* objectui#7115 — the root `README.md` was in NO doc gate's scan set: this gate
* walked `content/docs` plus the package READMEs, its sibling
* `check-doc-component-types.mjs` walked `content/docs`, and the repository's
* landing page fell between them.
*
* ⚠️ Read the second assertion carefully. Being ON the ungated ledger is NOT a
* claim that this file compiles — it does not; objectui#7417 carries its nine
* measured diagnostics. It is the objectui#5174 distinction, which this script's
* own header states: a document outside the walk is "neither covered NOR
* declared ungated", invisible to the gate's own accounting, while a ledgered
* one is named, counted, re-derived every run and shrink-only.
*/
describe('objectui#7115 — the root README is in the scan set', () => {
it('listDocuments reaches it', () => {
expect(listDocuments(repoRoot)).toContain('README.md');
});

it('is DECLARED debt rather than absent, and its reason names the card that carries it', () => {
expect(Object.keys(UNGATED_DOCS as Record<string, string>)).toContain('README.md');
expect((UNGATED_DOCS as Record<string, string>)['README.md']).toContain('objectui#7417');
});

it('root pages are collected BY NAME, not by the packages walk', () => {
// The mechanism, isolated: a tree with no `content/docs` and no `packages`
// still lists its root page, which is what makes the entry independent of
// the two walks it sits between.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-doc-snippet-types-rootpages-'));
try {
fs.writeFileSync(path.join(dir, 'README.md'), '# root\n');
expect(listDocuments(dir)).toEqual(['README.md']);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it('states in its own source that a dangling root page fails the run', () => {
// The guard lives in `main()`, which takes no `--root`, so it cannot be
// driven from a fixture. Pinned against the source for the same reason the
// exit-code contract is: a silently narrowed surface is this card's defect.
const source = fs.readFileSync(path.join(repoRoot, 'scripts/check-doc-snippet-types.mjs'), 'utf8');
expect(source).toContain('ROOT_PAGES');
expect(source).toMatch(/for \(const name of ROOT_PAGES\) \{\n\s*if \(!existsSync\(join\(repoRoot, name\)\)\)/);
});
});

describe('third-party resolution reaches exactly as far as the imported packages declare', () => {
/** A workspace package with its own `node_modules`, the way pnpm links one. */
function treeWithDependency(files: Record<string, string> = {}): string {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/7115-root-readme-doc-gate-surface.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
---

Docs and gates only: the root `README.md` — the repository's landing page and the
most-read authored file in it — was outside the scan surface of every doc gate,
and had been teaching `stat-card` four times in its flagship "dashboard in JSON"
example. Nothing registers `stat-card`, so a reader who copied the headline
snippet got four OBJUI-001 "Unknown component type" panels.

The file now joins the scan surface of `check-doc-component-types` and
`check-doc-snippet-types`, and the four widgets are retargeted onto `statistic`,
which is registered and declares a `value` carriage row, so the example keeps its
expressions. Per the maintainer's 2026-09-01 ruling on the (A)/(B) fork —
option (B), no new carriage rows — four documentation sites that authored `${…}`
in keys with no carriage row now teach what actually renders instead.

No package source changed, so this ships nothing: `check-changeset-presence`
independently reports "no changeset is owed" for this diff. The declaration
states the release intent rather than bumping anything.
46 changes: 22 additions & 24 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,9 +134,10 @@ function UserForm() {

// Object UI: 20 lines
const schema = {
type: "crud",
api: "/api/users",
columns: [...]
type: "object-form",
objectName: "user",
mode: "create",
fields: ["name", "email", "role"]
}
```

Expand DownExpand Up@@ -217,9 +218,9 @@ const schema = {
type: "grid",
columns: 3,
items: [
{ type: "card", title: "Total Users", value: "${stats.users}" },
{ type: "card", title: "Revenue", value: "${stats.revenue}" },
{ type: "card", title: "Orders", value: "${stats.orders}" }
{ type: "statistic", label: "Total Users", value: "${stats.users}" },
{ type: "statistic", label: "Revenue", value: "${stats.revenue}" },
{ type: "statistic", label: "Orders", value: "${stats.orders}" }
]
}
}
Expand DownExpand Up@@ -253,30 +254,27 @@ export default App
]},
{ "name": "message", "type": "textarea", "label": "Message", "required": true }
],
"actions": [{ "type": "submit", "label": "Send Message" }]
"submitLabel": "Send Message"
}
```

#### 📊 Data Grid

```json
{
"type": "crud",
"api": "/api/users",
"type": "object-grid",
"objectName": "user",
"title": "Users",
"columns": [
{ "name": "name", "label": "Name", "sortable": true },
{ "name": "email", "label": "Email" },
{ "name": "role", "label": "Role", "type": "select", "options": ["Admin", "User", "Viewer"] },
{ "name": "status", "label": "Status", "type": "badge" },
{ "name": "created_at", "label": "Joined", "type": "date" }
],
"filters": [
{ "name": "role", "type": "select", "label": "Filter by Role" },
{ "name": "status", "type": "select", "label": "Filter by Status" }
{ "field": "name", "label": "Name", "sortable": true },
{ "field": "email", "label": "Email" },
{ "field": "role", "label": "Role" },
{ "field": "status", "label": "Status" },
{ "field": "created_at", "label": "Joined" }
],
"showSearch": true,
"showCreate": true,
"showExport": true
"showFilters": true,
"operations": { "create": true, "read": true, "update": true, "delete": true, "export": true }
}
```

Expand All@@ -287,10 +285,10 @@ export default App
"type": "dashboard",
"title": "Sales Dashboard",
"widgets": [
{ "type": "stat-card", "title": "Revenue", "value": "${stats.revenue}", "trend": "+12%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Orders", "value": "${stats.orders}", "trend": "+8%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Customers", "value": "${stats.customers}", "trend": "+5%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Conversion", "value": "${stats.conversion}", "trend": "-2%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Revenue", "value": "${stats.revenue}", "trend": "up", "description": "+12%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Orders", "value": "${stats.orders}", "trend": "up", "description": "+8%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Customers", "value": "${stats.customers}", "trend": "up", "description": "+5%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Conversion", "value": "${stats.conversion}", "trend": "down", "description": "-2%", "w": 3, "h": 1 },
{ "type": "chart", "chartType": "line", "title": "Revenue Over Time", "w": 8, "h": 3 },
{ "type": "chart", "chartType": "pie", "title": "Sales by Region", "w": 4, "h": 3 }
]
Expand Down
21 changes: 14 additions & 7 deletions content/docs/guide/expressions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -382,11 +382,17 @@ Available: All standard `Math` functions

### Percentage Bar

`progress` has no row in the expression carriage map, so its `value` and `label`
are read off the node exactly as written — a `${…}` in either reaches the screen
as those characters. Compute the percentage in the data you hand the renderer;
the condition keys are evaluated on every type and stay expressions:

```json
{
"type": "progress",
"value": "${(completed / total) * 100}",
"label": "${completed} of ${total} completed"
"value": 75,
"label": "75% complete",
"visibleOn": "${total > 0}"
}
```

Expand DownExpand Up@@ -445,13 +451,14 @@ Available: All standard `Math` functions

### Computed Fields

`input` has no row in the expression carriage map either, so a computed total
cannot be carried by its `value`. Show it with a `text` node, whose `content` is
evaluated on every component type:

```json
{
"type": "input",
"name": "total",
"label": "Total",
"value": "${form.price * form.quantity}",
"disabled": true
"type": "text",
"content": "Total: ${form.price * form.quantity}"
}
```

Expand Down
10 changes: 7 additions & 3 deletions packages/plugin-dashboard/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -239,7 +239,11 @@ const schema = {

## Integration with Data Sources

Connect dashboard to live data:
Connect dashboard to live data. `metric-card` renders the `value` it is handed:
it has no row in the spec's expression carriage map, so a `${…}` written in
`value` or `trend` reaches the screen as those characters. A widget that should
read live data is one of the `object-*` types above — they resolve the spec's
per-element `dataSource` binding and query the object themselves.

```typescript
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
Expand All@@ -256,8 +260,8 @@ const schema = {
{
type: 'metric-card',
title: 'Total Users',
value: '${data.metrics.totalUsers}',
trend: '${data.metrics.userTrend}'
value: 12480,
trend: 'up'
}
]
};
Expand Down
10 changes: 8 additions & 2 deletions packages/react/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,11 +44,17 @@ import { SchemaRenderer } from '@object-ui/react'
const schema = {
type: 'form',
body: [
{
// `content` is evaluated on every component type. `input` has no row in
// the spec's expression carriage map, so a `${…}` in ITS `value` would be
// rendered as those characters rather than resolved.
type: 'text',
content: 'Editing ${user.name}'
},
{
type: 'input',
name: 'name',
label: 'Name',
value: '${user.name}'
label: 'Name'
}
]
}
Expand Down
73 changes: 72 additions & 1 deletion scripts/__tests__/check-doc-component-types.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import { execFileSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
Expand DownExpand Up@@ -715,6 +715,77 @@ describe('objectui#5342 — the key errors the widened collector found stay fixe
});
});

// ── objectui#7115: the root README joined the scan surface ───────────────────

/**
* objectui#7115 — the root `README.md` sat outside EVERY doc gate's scan
* surface. This gate walked `content/docs`; `check-doc-snippet-types.mjs` walked
* `content/docs` plus the package READMEs; the most-read authored file in the
* repository fell between the two. It taught the unregistered type `stat-card`
* four times, in the flagship "dashboard in JSON" example, for as long as the
* example existed — four OBJUI-001 panels for anyone who copied the headline
* snippet.
*
* ⚠️ Widening a scan surface is the change that can be GREEN ABOUT NOTHING, so
* what is pinned here is the three ways it can quietly stop being real: the walk
* stops reaching the file, the JUDGEMENT stops applying to what it finds there,
* or the content regresses under a surface that still technically covers it.
* The fourth — the name in `ROOT_PAGES` going dangling — is the one that would
* look healthiest, since every count this gate prints stays plausible while the
* surface shrinks back to what objectui#7115 found.
*/
describe('objectui#7115 — the root README is inside the scan surface', () => {
it('the walk really reaches it — the widening, pinned', () => {
const { sites } = scanDocs(repoRoot);
const files = new Set(sites.map((s: { file: string }) => s.file));
expect(
files.has('README.md'),
'no `type` literal was scanned in the root README — the collector narrowed back to content/docs',
).toBe(true);
});

it('judges a root page by the same rule, so an unregistered type there is a finding', () => {
// The mechanism, over a throwaway tree: reaching the file and JUDGING it are
// two different things, and a widening that only did the first would pass
// the assertion above.
const findings = withTree((write) => {
write(
'packages/demo/src/index.tsx',
"ComponentRegistry.register('statistic', S, { namespace: 'ui' });\n",
);
write('README.md', ['```json', '{ "type": "stat-card" }', '```'].join('\n'));
}, (dir) => analyze(dir, BARE).findings as Finding[]);
expect(findings.map((f) => `${f.reason} :: ${f.site} :: ${f.value ?? ''}`)).toEqual([
'unregistered-doc-type :: README.md:2 :: stat-card',
]);
});

it('the flagship dashboard example teaches `statistic`, and keeps its expressions', () => {
const readme = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8');
expect(readme, 'the defect objectui#7115 was filed for is back').not.toContain('stat-card');
const widgets = readme.split('\n').filter((line) => line.includes('"type": "statistic"'));
expect(widgets).toHaveLength(4);
// A retarget, not a downgrade to literals: `statistic` declares a `value`
// carriage row in the spec's expression map, which is precisely why the
// 2026-09-01 ruling picked it over spelling the numbers out.
for (const widget of widgets) expect(widget).toMatch(/"value": "\$\{stats\./);
});

it('refuses to run when a ROOT_PAGES name does not resolve — the silent shrink', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-doc-component-types-rootpages-'));
try {
const run = spawnSync(process.execPath, [path.join(repoRoot, SCRIPT), '--root', dir], {
encoding: 'utf8',
});
expect(run.status, 'a dangling root page must fail the run, not shrink the surface').toBe(1);
expect(run.stderr).toContain('ROOT_PAGES');
expect(run.stderr).toContain('README.md');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

describe('wiring — the gate is reachable and a docs-only PR starts it', () => {
const workflowDir = path.join(repoRoot, '.github/workflows');
const workflowPath = path.join(workflowDir, 'doc-component-types.yml');
Expand Down
40 changes: 38 additions & 2 deletions scripts/__tests__/check-doc-fence-languages.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,8 +5,18 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse as parseYaml } from 'yaml';

import { census, listDocuments as fenceDocuments, TS_FENCE_LANGUAGES as GUARD_TS_FENCES } from '../check-doc-fence-languages.mjs';
import { listDocuments as snippetDocuments, TS_FENCE_LANGUAGES as GATE_TS_FENCES } from '../check-doc-snippet-types.mjs';
import {
census,
listDocuments as fenceDocuments,
ROOT_PAGES as FENCE_ROOT_PAGES,
TS_FENCE_LANGUAGES as GUARD_TS_FENCES,
} from '../check-doc-fence-languages.mjs';
import {
listDocuments as snippetDocuments,
ROOT_PAGES as SNIPPET_ROOT_PAGES,
TS_FENCE_LANGUAGES as GATE_TS_FENCES,
} from '../check-doc-snippet-types.mjs';
import { ROOT_PAGES as COMPONENT_ROOT_PAGES } from '../check-doc-component-types.mjs';

const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..');
const GUARD = 'scripts/check-doc-fence-languages.mjs';
Expand DownExpand Up@@ -53,6 +63,32 @@ describe('check-doc-fence-languages: the scan surface is check-doc-snippet-types
it('treats exactly the snippet gate’s fence languages as already-covered', () => {
expect([...GUARD_TS_FENCES].sort()).toEqual([...GATE_TS_FENCES].sort());
});

/**
* objectui#7115 — the ROOT_PAGES half of the surface, pinned across ALL THREE
* doc gates rather than two.
*
* The document-list assertion above is what caught this: objectui#7115 widened
* `check-doc-component-types` and `check-doc-snippet-types` onto the root
* `README.md` — the two gates its ruling named — and this file went red,
* because a third gate is coupled to that surface by construction. The lists
* are equal again, but list equality alone would not have said WHERE they
* diverged, and `check-doc-component-types`'s surface is deliberately narrower
* (it does not walk the package READMEs), so it cannot join that comparison.
*
* This is the piece all three DO share. Each carries its own copy for its own
* install-free reason; comparing the copies is what keeps "copy freely" honest.
*/
it('all three doc gates carry the same ROOT_PAGES — the surface objectui#7115 widened', () => {
expect([...FENCE_ROOT_PAGES]).toEqual(['README.md']);
expect([...SNIPPET_ROOT_PAGES]).toEqual([...FENCE_ROOT_PAGES]);
expect([...COMPONENT_ROOT_PAGES]).toEqual([...FENCE_ROOT_PAGES]);
});

it('the root README is really in this gate’s walk — the widening, pinned', () => {
// Not implied by the equality above: both lists could lose it together.
expect(fenceDocuments(ROOT)).toContain('README.md');
});
});

describe('check-doc-fence-languages: non-vacuity, through the shipped module', () => {
Expand Down
46 changes: 46 additions & 0 deletions scripts/__tests__/check-doc-snippet-types.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -299,6 +299,52 @@ describe('this repository', () => {
});
});

/**
* objectui#7115 — the root `README.md` was in NO doc gate's scan set: this gate
* walked `content/docs` plus the package READMEs, its sibling
* `check-doc-component-types.mjs` walked `content/docs`, and the repository's
* landing page fell between them.
*
* ⚠️ Read the second assertion carefully. Being ON the ungated ledger is NOT a
* claim that this file compiles — it does not; objectui#7417 carries its nine
* measured diagnostics. It is the objectui#5174 distinction, which this script's
* own header states: a document outside the walk is "neither covered NOR
* declared ungated", invisible to the gate's own accounting, while a ledgered
* one is named, counted, re-derived every run and shrink-only.
*/
describe('objectui#7115 — the root README is in the scan set', () => {
it('listDocuments reaches it', () => {
expect(listDocuments(repoRoot)).toContain('README.md');
});

it('is DECLARED debt rather than absent, and its reason names the card that carries it', () => {
expect(Object.keys(UNGATED_DOCS as Record<string, string>)).toContain('README.md');
expect((UNGATED_DOCS as Record<string, string>)['README.md']).toContain('objectui#7417');
});

it('root pages are collected BY NAME, not by the packages walk', () => {
// The mechanism, isolated: a tree with no `content/docs` and no `packages`
// still lists its root page, which is what makes the entry independent of
// the two walks it sits between.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-doc-snippet-types-rootpages-'));
try {
fs.writeFileSync(path.join(dir, 'README.md'), '# root\n');
expect(listDocuments(dir)).toEqual(['README.md']);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it('states in its own source that a dangling root page fails the run', () => {
// The guard lives in `main()`, which takes no `--root`, so it cannot be
// driven from a fixture. Pinned against the source for the same reason the
// exit-code contract is: a silently narrowed surface is this card's defect.
const source = fs.readFileSync(path.join(repoRoot, 'scripts/check-doc-snippet-types.mjs'), 'utf8');
expect(source).toContain('ROOT_PAGES');
expect(source).toMatch(/for \(const name of ROOT_PAGES\) \{\n\s*if \(!existsSync\(join\(repoRoot, name\)\)\)/);
});
});

describe('third-party resolution reaches exactly as far as the imported packages declare', () => {
/** A workspace package with its own `node_modules`, the way pnpm links one. */
function treeWithDependency(files: Record<string, string> = {}): string {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/7115-root-readme-doc-gate-surface.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
---

Docs and gates only: the root `README.md` — the repository's landing page and the
most-read authored file in it — was outside the scan surface of every doc gate,
and had been teaching `stat-card` four times in its flagship "dashboard in JSON"
example. Nothing registers `stat-card`, so a reader who copied the headline
snippet got four OBJUI-001 "Unknown component type" panels.

The file now joins the scan surface of `check-doc-component-types` and
`check-doc-snippet-types`, and the four widgets are retargeted onto `statistic`,
which is registered and declares a `value` carriage row, so the example keeps its
expressions. Per the maintainer's 2026-09-01 ruling on the (A)/(B) fork —
option (B), no new carriage rows — four documentation sites that authored `${…}`
in keys with no carriage row now teach what actually renders instead.

No package source changed, so this ships nothing: `check-changeset-presence`
independently reports "no changeset is owed" for this diff. The declaration
states the release intent rather than bumping anything.
46 changes: 22 additions & 24 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,9 +134,10 @@ function UserForm() {

// Object UI: 20 lines
const schema = {
type: "crud",
api: "/api/users",
columns: [...]
type: "object-form",
objectName: "user",
mode: "create",
fields: ["name", "email", "role"]
}
```

Expand DownExpand Up@@ -217,9 +218,9 @@ const schema = {
type: "grid",
columns: 3,
items: [
{ type: "card", title: "Total Users", value: "${stats.users}" },
{ type: "card", title: "Revenue", value: "${stats.revenue}" },
{ type: "card", title: "Orders", value: "${stats.orders}" }
{ type: "statistic", label: "Total Users", value: "${stats.users}" },
{ type: "statistic", label: "Revenue", value: "${stats.revenue}" },
{ type: "statistic", label: "Orders", value: "${stats.orders}" }
]
}
}
Expand DownExpand Up@@ -253,30 +254,27 @@ export default App
]},
{ "name": "message", "type": "textarea", "label": "Message", "required": true }
],
"actions": [{ "type": "submit", "label": "Send Message" }]
"submitLabel": "Send Message"
}
```

#### 📊 Data Grid

```json
{
"type": "crud",
"api": "/api/users",
"type": "object-grid",
"objectName": "user",
"title": "Users",
"columns": [
{ "name": "name", "label": "Name", "sortable": true },
{ "name": "email", "label": "Email" },
{ "name": "role", "label": "Role", "type": "select", "options": ["Admin", "User", "Viewer"] },
{ "name": "status", "label": "Status", "type": "badge" },
{ "name": "created_at", "label": "Joined", "type": "date" }
],
"filters": [
{ "name": "role", "type": "select", "label": "Filter by Role" },
{ "name": "status", "type": "select", "label": "Filter by Status" }
{ "field": "name", "label": "Name", "sortable": true },
{ "field": "email", "label": "Email" },
{ "field": "role", "label": "Role" },
{ "field": "status", "label": "Status" },
{ "field": "created_at", "label": "Joined" }
],
"showSearch": true,
"showCreate": true,
"showExport": true
"showFilters": true,
"operations": { "create": true, "read": true, "update": true, "delete": true, "export": true }
}
```

Expand All@@ -287,10 +285,10 @@ export default App
"type": "dashboard",
"title": "Sales Dashboard",
"widgets": [
{ "type": "stat-card", "title": "Revenue", "value": "${stats.revenue}", "trend": "+12%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Orders", "value": "${stats.orders}", "trend": "+8%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Customers", "value": "${stats.customers}", "trend": "+5%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Conversion", "value": "${stats.conversion}", "trend": "-2%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Revenue", "value": "${stats.revenue}", "trend": "up", "description": "+12%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Orders", "value": "${stats.orders}", "trend": "up", "description": "+8%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Customers", "value": "${stats.customers}", "trend": "up", "description": "+5%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Conversion", "value": "${stats.conversion}", "trend": "down", "description": "-2%", "w": 3, "h": 1 },
{ "type": "chart", "chartType": "line", "title": "Revenue Over Time", "w": 8, "h": 3 },
{ "type": "chart", "chartType": "pie", "title": "Sales by Region", "w": 4, "h": 3 }
]
Expand Down
21 changes: 14 additions & 7 deletions content/docs/guide/expressions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -382,11 +382,17 @@ Available: All standard `Math` functions

### Percentage Bar

`progress` has no row in the expression carriage map, so its `value` and `label`
are read off the node exactly as written — a `${…}` in either reaches the screen
as those characters. Compute the percentage in the data you hand the renderer;
the condition keys are evaluated on every type and stay expressions:

```json
{
"type": "progress",
"value": "${(completed / total) * 100}",
"label": "${completed} of ${total} completed"
"value": 75,
"label": "75% complete",
"visibleOn": "${total > 0}"
}
```

Expand DownExpand Up@@ -445,13 +451,14 @@ Available: All standard `Math` functions

### Computed Fields

`input` has no row in the expression carriage map either, so a computed total
cannot be carried by its `value`. Show it with a `text` node, whose `content` is
evaluated on every component type:

```json
{
"type": "input",
"name": "total",
"label": "Total",
"value": "${form.price * form.quantity}",
"disabled": true
"type": "text",
"content": "Total: ${form.price * form.quantity}"
}
```

Expand Down
10 changes: 7 additions & 3 deletions packages/plugin-dashboard/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -239,7 +239,11 @@ const schema = {

## Integration with Data Sources

Connect dashboard to live data:
Connect dashboard to live data. `metric-card` renders the `value` it is handed:
it has no row in the spec's expression carriage map, so a `${…}` written in
`value` or `trend` reaches the screen as those characters. A widget that should
read live data is one of the `object-*` types above — they resolve the spec's
per-element `dataSource` binding and query the object themselves.

```typescript
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
Expand All@@ -256,8 +260,8 @@ const schema = {
{
type: 'metric-card',
title: 'Total Users',
value: '${data.metrics.totalUsers}',
trend: '${data.metrics.userTrend}'
value: 12480,
trend: 'up'
}
]
};
Expand Down
10 changes: 8 additions & 2 deletions packages/react/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,11 +44,17 @@ import { SchemaRenderer } from '@object-ui/react'
const schema = {
type: 'form',
body: [
{
// `content` is evaluated on every component type. `input` has no row in
// the spec's expression carriage map, so a `${…}` in ITS `value` would be
// rendered as those characters rather than resolved.
type: 'text',
content: 'Editing ${user.name}'
},
{
type: 'input',
name: 'name',
label: 'Name',
value: '${user.name}'
label: 'Name'
}
]
}
Expand Down
73 changes: 72 additions & 1 deletion scripts/__tests__/check-doc-component-types.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import { execFileSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
Expand DownExpand Up@@ -715,6 +715,77 @@ describe('objectui#5342 — the key errors the widened collector found stay fixe
});
});

// ── objectui#7115: the root README joined the scan surface ───────────────────

/**
* objectui#7115 — the root `README.md` sat outside EVERY doc gate's scan
* surface. This gate walked `content/docs`; `check-doc-snippet-types.mjs` walked
* `content/docs` plus the package READMEs; the most-read authored file in the
* repository fell between the two. It taught the unregistered type `stat-card`
* four times, in the flagship "dashboard in JSON" example, for as long as the
* example existed — four OBJUI-001 panels for anyone who copied the headline
* snippet.
*
* ⚠️ Widening a scan surface is the change that can be GREEN ABOUT NOTHING, so
* what is pinned here is the three ways it can quietly stop being real: the walk
* stops reaching the file, the JUDGEMENT stops applying to what it finds there,
* or the content regresses under a surface that still technically covers it.
* The fourth — the name in `ROOT_PAGES` going dangling — is the one that would
* look healthiest, since every count this gate prints stays plausible while the
* surface shrinks back to what objectui#7115 found.
*/
describe('objectui#7115 — the root README is inside the scan surface', () => {
it('the walk really reaches it — the widening, pinned', () => {
const { sites } = scanDocs(repoRoot);
const files = new Set(sites.map((s: { file: string }) => s.file));
expect(
files.has('README.md'),
'no `type` literal was scanned in the root README — the collector narrowed back to content/docs',
).toBe(true);
});

it('judges a root page by the same rule, so an unregistered type there is a finding', () => {
// The mechanism, over a throwaway tree: reaching the file and JUDGING it are
// two different things, and a widening that only did the first would pass
// the assertion above.
const findings = withTree((write) => {
write(
'packages/demo/src/index.tsx',
"ComponentRegistry.register('statistic', S, { namespace: 'ui' });\n",
);
write('README.md', ['```json', '{ "type": "stat-card" }', '```'].join('\n'));
}, (dir) => analyze(dir, BARE).findings as Finding[]);
expect(findings.map((f) => `${f.reason} :: ${f.site} :: ${f.value ?? ''}`)).toEqual([
'unregistered-doc-type :: README.md:2 :: stat-card',
]);
});

it('the flagship dashboard example teaches `statistic`, and keeps its expressions', () => {
const readme = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8');
expect(readme, 'the defect objectui#7115 was filed for is back').not.toContain('stat-card');
const widgets = readme.split('\n').filter((line) => line.includes('"type": "statistic"'));
expect(widgets).toHaveLength(4);
// A retarget, not a downgrade to literals: `statistic` declares a `value`
// carriage row in the spec's expression map, which is precisely why the
// 2026-09-01 ruling picked it over spelling the numbers out.
for (const widget of widgets) expect(widget).toMatch(/"value": "\$\{stats\./);
});

it('refuses to run when a ROOT_PAGES name does not resolve — the silent shrink', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-doc-component-types-rootpages-'));
try {
const run = spawnSync(process.execPath, [path.join(repoRoot, SCRIPT), '--root', dir], {
encoding: 'utf8',
});
expect(run.status, 'a dangling root page must fail the run, not shrink the surface').toBe(1);
expect(run.stderr).toContain('ROOT_PAGES');
expect(run.stderr).toContain('README.md');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

describe('wiring — the gate is reachable and a docs-only PR starts it', () => {
const workflowDir = path.join(repoRoot, '.github/workflows');
const workflowPath = path.join(workflowDir, 'doc-component-types.yml');
Expand Down
40 changes: 38 additions & 2 deletions scripts/__tests__/check-doc-fence-languages.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,8 +5,18 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse as parseYaml } from 'yaml';

import { census, listDocuments as fenceDocuments, TS_FENCE_LANGUAGES as GUARD_TS_FENCES } from '../check-doc-fence-languages.mjs';
import { listDocuments as snippetDocuments, TS_FENCE_LANGUAGES as GATE_TS_FENCES } from '../check-doc-snippet-types.mjs';
import {
census,
listDocuments as fenceDocuments,
ROOT_PAGES as FENCE_ROOT_PAGES,
TS_FENCE_LANGUAGES as GUARD_TS_FENCES,
} from '../check-doc-fence-languages.mjs';
import {
listDocuments as snippetDocuments,
ROOT_PAGES as SNIPPET_ROOT_PAGES,
TS_FENCE_LANGUAGES as GATE_TS_FENCES,
} from '../check-doc-snippet-types.mjs';
import { ROOT_PAGES as COMPONENT_ROOT_PAGES } from '../check-doc-component-types.mjs';

const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..');
const GUARD = 'scripts/check-doc-fence-languages.mjs';
Expand DownExpand Up@@ -53,6 +63,32 @@ describe('check-doc-fence-languages: the scan surface is check-doc-snippet-types
it('treats exactly the snippet gate’s fence languages as already-covered', () => {
expect([...GUARD_TS_FENCES].sort()).toEqual([...GATE_TS_FENCES].sort());
});

/**
* objectui#7115 — the ROOT_PAGES half of the surface, pinned across ALL THREE
* doc gates rather than two.
*
* The document-list assertion above is what caught this: objectui#7115 widened
* `check-doc-component-types` and `check-doc-snippet-types` onto the root
* `README.md` — the two gates its ruling named — and this file went red,
* because a third gate is coupled to that surface by construction. The lists
* are equal again, but list equality alone would not have said WHERE they
* diverged, and `check-doc-component-types`'s surface is deliberately narrower
* (it does not walk the package READMEs), so it cannot join that comparison.
*
* This is the piece all three DO share. Each carries its own copy for its own
* install-free reason; comparing the copies is what keeps "copy freely" honest.
*/
it('all three doc gates carry the same ROOT_PAGES — the surface objectui#7115 widened', () => {
expect([...FENCE_ROOT_PAGES]).toEqual(['README.md']);
expect([...SNIPPET_ROOT_PAGES]).toEqual([...FENCE_ROOT_PAGES]);
expect([...COMPONENT_ROOT_PAGES]).toEqual([...FENCE_ROOT_PAGES]);
});

it('the root README is really in this gate’s walk — the widening, pinned', () => {
// Not implied by the equality above: both lists could lose it together.
expect(fenceDocuments(ROOT)).toContain('README.md');
});
});

describe('check-doc-fence-languages: non-vacuity, through the shipped module', () => {
Expand Down
46 changes: 46 additions & 0 deletions scripts/__tests__/check-doc-snippet-types.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -299,6 +299,52 @@ describe('this repository', () => {
});
});

/**
* objectui#7115 — the root `README.md` was in NO doc gate's scan set: this gate
* walked `content/docs` plus the package READMEs, its sibling
* `check-doc-component-types.mjs` walked `content/docs`, and the repository's
* landing page fell between them.
*
* ⚠️ Read the second assertion carefully. Being ON the ungated ledger is NOT a
* claim that this file compiles — it does not; objectui#7417 carries its nine
* measured diagnostics. It is the objectui#5174 distinction, which this script's
* own header states: a document outside the walk is "neither covered NOR
* declared ungated", invisible to the gate's own accounting, while a ledgered
* one is named, counted, re-derived every run and shrink-only.
*/
describe('objectui#7115 — the root README is in the scan set', () => {
it('listDocuments reaches it', () => {
expect(listDocuments(repoRoot)).toContain('README.md');
});

it('is DECLARED debt rather than absent, and its reason names the card that carries it', () => {
expect(Object.keys(UNGATED_DOCS as Record<string, string>)).toContain('README.md');
expect((UNGATED_DOCS as Record<string, string>)['README.md']).toContain('objectui#7417');
});

it('root pages are collected BY NAME, not by the packages walk', () => {
// The mechanism, isolated: a tree with no `content/docs` and no `packages`
// still lists its root page, which is what makes the entry independent of
// the two walks it sits between.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-doc-snippet-types-rootpages-'));
try {
fs.writeFileSync(path.join(dir, 'README.md'), '# root\n');
expect(listDocuments(dir)).toEqual(['README.md']);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it('states in its own source that a dangling root page fails the run', () => {
// The guard lives in `main()`, which takes no `--root`, so it cannot be
// driven from a fixture. Pinned against the source for the same reason the
// exit-code contract is: a silently narrowed surface is this card's defect.
const source = fs.readFileSync(path.join(repoRoot, 'scripts/check-doc-snippet-types.mjs'), 'utf8');
expect(source).toContain('ROOT_PAGES');
expect(source).toMatch(/for \(const name of ROOT_PAGES\) \{\n\s*if \(!existsSync\(join\(repoRoot, name\)\)\)/);
});
});

describe('third-party resolution reaches exactly as far as the imported packages declare', () => {
/** A workspace package with its own `node_modules`, the way pnpm links one. */
function treeWithDependency(files: Record<string, string> = {}): string {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/7115-root-readme-doc-gate-surface.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
---

Docs and gates only: the root `README.md` — the repository's landing page and the
most-read authored file in it — was outside the scan surface of every doc gate,
and had been teaching `stat-card` four times in its flagship "dashboard in JSON"
example. Nothing registers `stat-card`, so a reader who copied the headline
snippet got four OBJUI-001 "Unknown component type" panels.

The file now joins the scan surface of `check-doc-component-types` and
`check-doc-snippet-types`, and the four widgets are retargeted onto `statistic`,
which is registered and declares a `value` carriage row, so the example keeps its
expressions. Per the maintainer's 2026-09-01 ruling on the (A)/(B) fork —
option (B), no new carriage rows — four documentation sites that authored `${…}`
in keys with no carriage row now teach what actually renders instead.

No package source changed, so this ships nothing: `check-changeset-presence`
independently reports "no changeset is owed" for this diff. The declaration
states the release intent rather than bumping anything.
46 changes: 22 additions & 24 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,9 +134,10 @@ function UserForm() {

// Object UI: 20 lines
const schema = {
type: "crud",
api: "/api/users",
columns: [...]
type: "object-form",
objectName: "user",
mode: "create",
fields: ["name", "email", "role"]
}
```

Expand DownExpand Up@@ -217,9 +218,9 @@ const schema = {
type: "grid",
columns: 3,
items: [
{ type: "card", title: "Total Users", value: "${stats.users}" },
{ type: "card", title: "Revenue", value: "${stats.revenue}" },
{ type: "card", title: "Orders", value: "${stats.orders}" }
{ type: "statistic", label: "Total Users", value: "${stats.users}" },
{ type: "statistic", label: "Revenue", value: "${stats.revenue}" },
{ type: "statistic", label: "Orders", value: "${stats.orders}" }
]
}
}
Expand DownExpand Up@@ -253,30 +254,27 @@ export default App
]},
{ "name": "message", "type": "textarea", "label": "Message", "required": true }
],
"actions": [{ "type": "submit", "label": "Send Message" }]
"submitLabel": "Send Message"
}
```

#### 📊 Data Grid

```json
{
"type": "crud",
"api": "/api/users",
"type": "object-grid",
"objectName": "user",
"title": "Users",
"columns": [
{ "name": "name", "label": "Name", "sortable": true },
{ "name": "email", "label": "Email" },
{ "name": "role", "label": "Role", "type": "select", "options": ["Admin", "User", "Viewer"] },
{ "name": "status", "label": "Status", "type": "badge" },
{ "name": "created_at", "label": "Joined", "type": "date" }
],
"filters": [
{ "name": "role", "type": "select", "label": "Filter by Role" },
{ "name": "status", "type": "select", "label": "Filter by Status" }
{ "field": "name", "label": "Name", "sortable": true },
{ "field": "email", "label": "Email" },
{ "field": "role", "label": "Role" },
{ "field": "status", "label": "Status" },
{ "field": "created_at", "label": "Joined" }
],
"showSearch": true,
"showCreate": true,
"showExport": true
"showFilters": true,
"operations": { "create": true, "read": true, "update": true, "delete": true, "export": true }
}
```

Expand All@@ -287,10 +285,10 @@ export default App
"type": "dashboard",
"title": "Sales Dashboard",
"widgets": [
{ "type": "stat-card", "title": "Revenue", "value": "${stats.revenue}", "trend": "+12%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Orders", "value": "${stats.orders}", "trend": "+8%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Customers", "value": "${stats.customers}", "trend": "+5%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Conversion", "value": "${stats.conversion}", "trend": "-2%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Revenue", "value": "${stats.revenue}", "trend": "up", "description": "+12%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Orders", "value": "${stats.orders}", "trend": "up", "description": "+8%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Customers", "value": "${stats.customers}", "trend": "up", "description": "+5%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Conversion", "value": "${stats.conversion}", "trend": "down", "description": "-2%", "w": 3, "h": 1 },
{ "type": "chart", "chartType": "line", "title": "Revenue Over Time", "w": 8, "h": 3 },
{ "type": "chart", "chartType": "pie", "title": "Sales by Region", "w": 4, "h": 3 }
]
Expand Down
21 changes: 14 additions & 7 deletions content/docs/guide/expressions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -382,11 +382,17 @@ Available: All standard `Math` functions

### Percentage Bar

`progress` has no row in the expression carriage map, so its `value` and `label`
are read off the node exactly as written — a `${…}` in either reaches the screen
as those characters. Compute the percentage in the data you hand the renderer;
the condition keys are evaluated on every type and stay expressions:

```json
{
"type": "progress",
"value": "${(completed / total) * 100}",
"label": "${completed} of ${total} completed"
"value": 75,
"label": "75% complete",
"visibleOn": "${total > 0}"
}
```

Expand DownExpand Up@@ -445,13 +451,14 @@ Available: All standard `Math` functions

### Computed Fields

`input` has no row in the expression carriage map either, so a computed total
cannot be carried by its `value`. Show it with a `text` node, whose `content` is
evaluated on every component type:

```json
{
"type": "input",
"name": "total",
"label": "Total",
"value": "${form.price * form.quantity}",
"disabled": true
"type": "text",
"content": "Total: ${form.price * form.quantity}"
}
```

Expand Down
10 changes: 7 additions & 3 deletions packages/plugin-dashboard/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -239,7 +239,11 @@ const schema = {

## Integration with Data Sources

Connect dashboard to live data:
Connect dashboard to live data. `metric-card` renders the `value` it is handed:
it has no row in the spec's expression carriage map, so a `${…}` written in
`value` or `trend` reaches the screen as those characters. A widget that should
read live data is one of the `object-*` types above — they resolve the spec's
per-element `dataSource` binding and query the object themselves.

```typescript
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
Expand All@@ -256,8 +260,8 @@ const schema = {
{
type: 'metric-card',
title: 'Total Users',
value: '${data.metrics.totalUsers}',
trend: '${data.metrics.userTrend}'
value: 12480,
trend: 'up'
}
]
};
Expand Down
10 changes: 8 additions & 2 deletions packages/react/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,11 +44,17 @@ import { SchemaRenderer } from '@object-ui/react'
const schema = {
type: 'form',
body: [
{
// `content` is evaluated on every component type. `input` has no row in
// the spec's expression carriage map, so a `${…}` in ITS `value` would be
// rendered as those characters rather than resolved.
type: 'text',
content: 'Editing ${user.name}'
},
{
type: 'input',
name: 'name',
label: 'Name',
value: '${user.name}'
label: 'Name'
}
]
}
Expand Down
73 changes: 72 additions & 1 deletion scripts/__tests__/check-doc-component-types.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import { execFileSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
Expand DownExpand Up@@ -715,6 +715,77 @@ describe('objectui#5342 — the key errors the widened collector found stay fixe
});
});

// ── objectui#7115: the root README joined the scan surface ───────────────────

/**
* objectui#7115 — the root `README.md` sat outside EVERY doc gate's scan
* surface. This gate walked `content/docs`; `check-doc-snippet-types.mjs` walked
* `content/docs` plus the package READMEs; the most-read authored file in the
* repository fell between the two. It taught the unregistered type `stat-card`
* four times, in the flagship "dashboard in JSON" example, for as long as the
* example existed — four OBJUI-001 panels for anyone who copied the headline
* snippet.
*
* ⚠️ Widening a scan surface is the change that can be GREEN ABOUT NOTHING, so
* what is pinned here is the three ways it can quietly stop being real: the walk
* stops reaching the file, the JUDGEMENT stops applying to what it finds there,
* or the content regresses under a surface that still technically covers it.
* The fourth — the name in `ROOT_PAGES` going dangling — is the one that would
* look healthiest, since every count this gate prints stays plausible while the
* surface shrinks back to what objectui#7115 found.
*/
describe('objectui#7115 — the root README is inside the scan surface', () => {
it('the walk really reaches it — the widening, pinned', () => {
const { sites } = scanDocs(repoRoot);
const files = new Set(sites.map((s: { file: string }) => s.file));
expect(
files.has('README.md'),
'no `type` literal was scanned in the root README — the collector narrowed back to content/docs',
).toBe(true);
});

it('judges a root page by the same rule, so an unregistered type there is a finding', () => {
// The mechanism, over a throwaway tree: reaching the file and JUDGING it are
// two different things, and a widening that only did the first would pass
// the assertion above.
const findings = withTree((write) => {
write(
'packages/demo/src/index.tsx',
"ComponentRegistry.register('statistic', S, { namespace: 'ui' });\n",
);
write('README.md', ['```json', '{ "type": "stat-card" }', '```'].join('\n'));
}, (dir) => analyze(dir, BARE).findings as Finding[]);
expect(findings.map((f) => `${f.reason} :: ${f.site} :: ${f.value ?? ''}`)).toEqual([
'unregistered-doc-type :: README.md:2 :: stat-card',
]);
});

it('the flagship dashboard example teaches `statistic`, and keeps its expressions', () => {
const readme = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8');
expect(readme, 'the defect objectui#7115 was filed for is back').not.toContain('stat-card');
const widgets = readme.split('\n').filter((line) => line.includes('"type": "statistic"'));
expect(widgets).toHaveLength(4);
// A retarget, not a downgrade to literals: `statistic` declares a `value`
// carriage row in the spec's expression map, which is precisely why the
// 2026-09-01 ruling picked it over spelling the numbers out.
for (const widget of widgets) expect(widget).toMatch(/"value": "\$\{stats\./);
});

it('refuses to run when a ROOT_PAGES name does not resolve — the silent shrink', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-doc-component-types-rootpages-'));
try {
const run = spawnSync(process.execPath, [path.join(repoRoot, SCRIPT), '--root', dir], {
encoding: 'utf8',
});
expect(run.status, 'a dangling root page must fail the run, not shrink the surface').toBe(1);
expect(run.stderr).toContain('ROOT_PAGES');
expect(run.stderr).toContain('README.md');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

describe('wiring — the gate is reachable and a docs-only PR starts it', () => {
const workflowDir = path.join(repoRoot, '.github/workflows');
const workflowPath = path.join(workflowDir, 'doc-component-types.yml');
Expand Down
40 changes: 38 additions & 2 deletions scripts/__tests__/check-doc-fence-languages.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,8 +5,18 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse as parseYaml } from 'yaml';

import { census, listDocuments as fenceDocuments, TS_FENCE_LANGUAGES as GUARD_TS_FENCES } from '../check-doc-fence-languages.mjs';
import { listDocuments as snippetDocuments, TS_FENCE_LANGUAGES as GATE_TS_FENCES } from '../check-doc-snippet-types.mjs';
import {
census,
listDocuments as fenceDocuments,
ROOT_PAGES as FENCE_ROOT_PAGES,
TS_FENCE_LANGUAGES as GUARD_TS_FENCES,
} from '../check-doc-fence-languages.mjs';
import {
listDocuments as snippetDocuments,
ROOT_PAGES as SNIPPET_ROOT_PAGES,
TS_FENCE_LANGUAGES as GATE_TS_FENCES,
} from '../check-doc-snippet-types.mjs';
import { ROOT_PAGES as COMPONENT_ROOT_PAGES } from '../check-doc-component-types.mjs';

const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..');
const GUARD = 'scripts/check-doc-fence-languages.mjs';
Expand DownExpand Up@@ -53,6 +63,32 @@ describe('check-doc-fence-languages: the scan surface is check-doc-snippet-types
it('treats exactly the snippet gate’s fence languages as already-covered', () => {
expect([...GUARD_TS_FENCES].sort()).toEqual([...GATE_TS_FENCES].sort());
});

/**
* objectui#7115 — the ROOT_PAGES half of the surface, pinned across ALL THREE
* doc gates rather than two.
*
* The document-list assertion above is what caught this: objectui#7115 widened
* `check-doc-component-types` and `check-doc-snippet-types` onto the root
* `README.md` — the two gates its ruling named — and this file went red,
* because a third gate is coupled to that surface by construction. The lists
* are equal again, but list equality alone would not have said WHERE they
* diverged, and `check-doc-component-types`'s surface is deliberately narrower
* (it does not walk the package READMEs), so it cannot join that comparison.
*
* This is the piece all three DO share. Each carries its own copy for its own
* install-free reason; comparing the copies is what keeps "copy freely" honest.
*/
it('all three doc gates carry the same ROOT_PAGES — the surface objectui#7115 widened', () => {
expect([...FENCE_ROOT_PAGES]).toEqual(['README.md']);
expect([...SNIPPET_ROOT_PAGES]).toEqual([...FENCE_ROOT_PAGES]);
expect([...COMPONENT_ROOT_PAGES]).toEqual([...FENCE_ROOT_PAGES]);
});

it('the root README is really in this gate’s walk — the widening, pinned', () => {
// Not implied by the equality above: both lists could lose it together.
expect(fenceDocuments(ROOT)).toContain('README.md');
});
});

describe('check-doc-fence-languages: non-vacuity, through the shipped module', () => {
Expand Down
46 changes: 46 additions & 0 deletions scripts/__tests__/check-doc-snippet-types.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -299,6 +299,52 @@ describe('this repository', () => {
});
});

/**
* objectui#7115 — the root `README.md` was in NO doc gate's scan set: this gate
* walked `content/docs` plus the package READMEs, its sibling
* `check-doc-component-types.mjs` walked `content/docs`, and the repository's
* landing page fell between them.
*
* ⚠️ Read the second assertion carefully. Being ON the ungated ledger is NOT a
* claim that this file compiles — it does not; objectui#7417 carries its nine
* measured diagnostics. It is the objectui#5174 distinction, which this script's
* own header states: a document outside the walk is "neither covered NOR
* declared ungated", invisible to the gate's own accounting, while a ledgered
* one is named, counted, re-derived every run and shrink-only.
*/
describe('objectui#7115 — the root README is in the scan set', () => {
it('listDocuments reaches it', () => {
expect(listDocuments(repoRoot)).toContain('README.md');
});

it('is DECLARED debt rather than absent, and its reason names the card that carries it', () => {
expect(Object.keys(UNGATED_DOCS as Record<string, string>)).toContain('README.md');
expect((UNGATED_DOCS as Record<string, string>)['README.md']).toContain('objectui#7417');
});

it('root pages are collected BY NAME, not by the packages walk', () => {
// The mechanism, isolated: a tree with no `content/docs` and no `packages`
// still lists its root page, which is what makes the entry independent of
// the two walks it sits between.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-doc-snippet-types-rootpages-'));
try {
fs.writeFileSync(path.join(dir, 'README.md'), '# root\n');
expect(listDocuments(dir)).toEqual(['README.md']);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it('states in its own source that a dangling root page fails the run', () => {
// The guard lives in `main()`, which takes no `--root`, so it cannot be
// driven from a fixture. Pinned against the source for the same reason the
// exit-code contract is: a silently narrowed surface is this card's defect.
const source = fs.readFileSync(path.join(repoRoot, 'scripts/check-doc-snippet-types.mjs'), 'utf8');
expect(source).toContain('ROOT_PAGES');
expect(source).toMatch(/for \(const name of ROOT_PAGES\) \{\n\s*if \(!existsSync\(join\(repoRoot, name\)\)\)/);
});
});

describe('third-party resolution reaches exactly as far as the imported packages declare', () => {
/** A workspace package with its own `node_modules`, the way pnpm links one. */
function treeWithDependency(files: Record<string, string> = {}): string {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/7115-root-readme-doc-gate-surface.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
---

Docs and gates only: the root `README.md` — the repository's landing page and the
most-read authored file in it — was outside the scan surface of every doc gate,
and had been teaching `stat-card` four times in its flagship "dashboard in JSON"
example. Nothing registers `stat-card`, so a reader who copied the headline
snippet got four OBJUI-001 "Unknown component type" panels.

The file now joins the scan surface of `check-doc-component-types` and
`check-doc-snippet-types`, and the four widgets are retargeted onto `statistic`,
which is registered and declares a `value` carriage row, so the example keeps its
expressions. Per the maintainer's 2026-09-01 ruling on the (A)/(B) fork —
option (B), no new carriage rows — four documentation sites that authored `${…}`
in keys with no carriage row now teach what actually renders instead.

No package source changed, so this ships nothing: `check-changeset-presence`
independently reports "no changeset is owed" for this diff. The declaration
states the release intent rather than bumping anything.
46 changes: 22 additions & 24 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,9 +134,10 @@ function UserForm() {

// Object UI: 20 lines
const schema = {
type: "crud",
api: "/api/users",
columns: [...]
type: "object-form",
objectName: "user",
mode: "create",
fields: ["name", "email", "role"]
}
```

Expand DownExpand Up@@ -217,9 +218,9 @@ const schema = {
type: "grid",
columns: 3,
items: [
{ type: "card", title: "Total Users", value: "${stats.users}" },
{ type: "card", title: "Revenue", value: "${stats.revenue}" },
{ type: "card", title: "Orders", value: "${stats.orders}" }
{ type: "statistic", label: "Total Users", value: "${stats.users}" },
{ type: "statistic", label: "Revenue", value: "${stats.revenue}" },
{ type: "statistic", label: "Orders", value: "${stats.orders}" }
]
}
}
Expand DownExpand Up@@ -253,30 +254,27 @@ export default App
]},
{ "name": "message", "type": "textarea", "label": "Message", "required": true }
],
"actions": [{ "type": "submit", "label": "Send Message" }]
"submitLabel": "Send Message"
}
```

#### 📊 Data Grid

```json
{
"type": "crud",
"api": "/api/users",
"type": "object-grid",
"objectName": "user",
"title": "Users",
"columns": [
{ "name": "name", "label": "Name", "sortable": true },
{ "name": "email", "label": "Email" },
{ "name": "role", "label": "Role", "type": "select", "options": ["Admin", "User", "Viewer"] },
{ "name": "status", "label": "Status", "type": "badge" },
{ "name": "created_at", "label": "Joined", "type": "date" }
],
"filters": [
{ "name": "role", "type": "select", "label": "Filter by Role" },
{ "name": "status", "type": "select", "label": "Filter by Status" }
{ "field": "name", "label": "Name", "sortable": true },
{ "field": "email", "label": "Email" },
{ "field": "role", "label": "Role" },
{ "field": "status", "label": "Status" },
{ "field": "created_at", "label": "Joined" }
],
"showSearch": true,
"showCreate": true,
"showExport": true
"showFilters": true,
"operations": { "create": true, "read": true, "update": true, "delete": true, "export": true }
}
```

Expand All@@ -287,10 +285,10 @@ export default App
"type": "dashboard",
"title": "Sales Dashboard",
"widgets": [
{ "type": "stat-card", "title": "Revenue", "value": "${stats.revenue}", "trend": "+12%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Orders", "value": "${stats.orders}", "trend": "+8%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Customers", "value": "${stats.customers}", "trend": "+5%", "w": 3, "h": 1 },
{ "type": "stat-card", "title": "Conversion", "value": "${stats.conversion}", "trend": "-2%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Revenue", "value": "${stats.revenue}", "trend": "up", "description": "+12%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Orders", "value": "${stats.orders}", "trend": "up", "description": "+8%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Customers", "value": "${stats.customers}", "trend": "up", "description": "+5%", "w": 3, "h": 1 },
{ "type": "statistic", "label": "Conversion", "value": "${stats.conversion}", "trend": "down", "description": "-2%", "w": 3, "h": 1 },
{ "type": "chart", "chartType": "line", "title": "Revenue Over Time", "w": 8, "h": 3 },
{ "type": "chart", "chartType": "pie", "title": "Sales by Region", "w": 4, "h": 3 }
]
Expand Down
21 changes: 14 additions & 7 deletions content/docs/guide/expressions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -382,11 +382,17 @@ Available: All standard `Math` functions

### Percentage Bar

`progress` has no row in the expression carriage map, so its `value` and `label`
are read off the node exactly as written — a `${…}` in either reaches the screen
as those characters. Compute the percentage in the data you hand the renderer;
the condition keys are evaluated on every type and stay expressions:

```json
{
"type": "progress",
"value": "${(completed / total) * 100}",
"label": "${completed} of ${total} completed"
"value": 75,
"label": "75% complete",
"visibleOn": "${total > 0}"
}
```

Expand DownExpand Up@@ -445,13 +451,14 @@ Available: All standard `Math` functions

### Computed Fields

`input` has no row in the expression carriage map either, so a computed total
cannot be carried by its `value`. Show it with a `text` node, whose `content` is
evaluated on every component type:

```json
{
"type": "input",
"name": "total",
"label": "Total",
"value": "${form.price * form.quantity}",
"disabled": true
"type": "text",
"content": "Total: ${form.price * form.quantity}"
}
```

Expand Down
10 changes: 7 additions & 3 deletions packages/plugin-dashboard/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -239,7 +239,11 @@ const schema = {

## Integration with Data Sources

Connect dashboard to live data:
Connect dashboard to live data. `metric-card` renders the `value` it is handed:
it has no row in the spec's expression carriage map, so a `${…}` written in
`value` or `trend` reaches the screen as those characters. A widget that should
read live data is one of the `object-*` types above — they resolve the spec's
per-element `dataSource` binding and query the object themselves.

```typescript
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
Expand All@@ -256,8 +260,8 @@ const schema = {
{
type: 'metric-card',
title: 'Total Users',
value: '${data.metrics.totalUsers}',
trend: '${data.metrics.userTrend}'
value: 12480,
trend: 'up'
}
]
};
Expand Down
10 changes: 8 additions & 2 deletions packages/react/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,11 +44,17 @@ import { SchemaRenderer } from '@object-ui/react'
const schema = {
type: 'form',
body: [
{
// `content` is evaluated on every component type. `input` has no row in
// the spec's expression carriage map, so a `${…}` in ITS `value` would be
// rendered as those characters rather than resolved.
type: 'text',
content: 'Editing ${user.name}'
},
{
type: 'input',
name: 'name',
label: 'Name',
value: '${user.name}'
label: 'Name'
}
]
}
Expand Down
73 changes: 72 additions & 1 deletion scripts/__tests__/check-doc-component-types.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import { execFileSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
Expand DownExpand Up@@ -715,6 +715,77 @@ describe('objectui#5342 — the key errors the widened collector found stay fixe
});
});

// ── objectui#7115: the root README joined the scan surface ───────────────────

/**
* objectui#7115 — the root `README.md` sat outside EVERY doc gate's scan
* surface. This gate walked `content/docs`; `check-doc-snippet-types.mjs` walked
* `content/docs` plus the package READMEs; the most-read authored file in the
* repository fell between the two. It taught the unregistered type `stat-card`
* four times, in the flagship "dashboard in JSON" example, for as long as the
* example existed — four OBJUI-001 panels for anyone who copied the headline
* snippet.
*
* ⚠️ Widening a scan surface is the change that can be GREEN ABOUT NOTHING, so
* what is pinned here is the three ways it can quietly stop being real: the walk
* stops reaching the file, the JUDGEMENT stops applying to what it finds there,
* or the content regresses under a surface that still technically covers it.
* The fourth — the name in `ROOT_PAGES` going dangling — is the one that would
* look healthiest, since every count this gate prints stays plausible while the
* surface shrinks back to what objectui#7115 found.
*/
describe('objectui#7115 — the root README is inside the scan surface', () => {
it('the walk really reaches it — the widening, pinned', () => {
const { sites } = scanDocs(repoRoot);
const files = new Set(sites.map((s: { file: string }) => s.file));
expect(
files.has('README.md'),
'no `type` literal was scanned in the root README — the collector narrowed back to content/docs',
).toBe(true);
});

it('judges a root page by the same rule, so an unregistered type there is a finding', () => {
// The mechanism, over a throwaway tree: reaching the file and JUDGING it are
// two different things, and a widening that only did the first would pass
// the assertion above.
const findings = withTree((write) => {
write(
'packages/demo/src/index.tsx',
"ComponentRegistry.register('statistic', S, { namespace: 'ui' });\n",
);
write('README.md', ['```json', '{ "type": "stat-card" }', '```'].join('\n'));
}, (dir) => analyze(dir, BARE).findings as Finding[]);
expect(findings.map((f) => `${f.reason} :: ${f.site} :: ${f.value ?? ''}`)).toEqual([
'unregistered-doc-type :: README.md:2 :: stat-card',
]);
});

it('the flagship dashboard example teaches `statistic`, and keeps its expressions', () => {
const readme = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8');
expect(readme, 'the defect objectui#7115 was filed for is back').not.toContain('stat-card');
const widgets = readme.split('\n').filter((line) => line.includes('"type": "statistic"'));
expect(widgets).toHaveLength(4);
// A retarget, not a downgrade to literals: `statistic` declares a `value`
// carriage row in the spec's expression map, which is precisely why the
// 2026-09-01 ruling picked it over spelling the numbers out.
for (const widget of widgets) expect(widget).toMatch(/"value": "\$\{stats\./);
});

it('refuses to run when a ROOT_PAGES name does not resolve — the silent shrink', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-doc-component-types-rootpages-'));
try {
const run = spawnSync(process.execPath, [path.join(repoRoot, SCRIPT), '--root', dir], {
encoding: 'utf8',
});
expect(run.status, 'a dangling root page must fail the run, not shrink the surface').toBe(1);
expect(run.stderr).toContain('ROOT_PAGES');
expect(run.stderr).toContain('README.md');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

describe('wiring — the gate is reachable and a docs-only PR starts it', () => {
const workflowDir = path.join(repoRoot, '.github/workflows');
const workflowPath = path.join(workflowDir, 'doc-component-types.yml');
Expand Down
40 changes: 38 additions & 2 deletions scripts/__tests__/check-doc-fence-languages.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,8 +5,18 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse as parseYaml } from 'yaml';

import { census, listDocuments as fenceDocuments, TS_FENCE_LANGUAGES as GUARD_TS_FENCES } from '../check-doc-fence-languages.mjs';
import { listDocuments as snippetDocuments, TS_FENCE_LANGUAGES as GATE_TS_FENCES } from '../check-doc-snippet-types.mjs';
import {
census,
listDocuments as fenceDocuments,
ROOT_PAGES as FENCE_ROOT_PAGES,
TS_FENCE_LANGUAGES as GUARD_TS_FENCES,
} from '../check-doc-fence-languages.mjs';
import {
listDocuments as snippetDocuments,
ROOT_PAGES as SNIPPET_ROOT_PAGES,
TS_FENCE_LANGUAGES as GATE_TS_FENCES,
} from '../check-doc-snippet-types.mjs';
import { ROOT_PAGES as COMPONENT_ROOT_PAGES } from '../check-doc-component-types.mjs';

const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..');
const GUARD = 'scripts/check-doc-fence-languages.mjs';
Expand DownExpand Up@@ -53,6 +63,32 @@ describe('check-doc-fence-languages: the scan surface is check-doc-snippet-types
it('treats exactly the snippet gate’s fence languages as already-covered', () => {
expect([...GUARD_TS_FENCES].sort()).toEqual([...GATE_TS_FENCES].sort());
});

/**
* objectui#7115 — the ROOT_PAGES half of the surface, pinned across ALL THREE
* doc gates rather than two.
*
* The document-list assertion above is what caught this: objectui#7115 widened
* `check-doc-component-types` and `check-doc-snippet-types` onto the root
* `README.md` — the two gates its ruling named — and this file went red,
* because a third gate is coupled to that surface by construction. The lists
* are equal again, but list equality alone would not have said WHERE they
* diverged, and `check-doc-component-types`'s surface is deliberately narrower
* (it does not walk the package READMEs), so it cannot join that comparison.
*
* This is the piece all three DO share. Each carries its own copy for its own
* install-free reason; comparing the copies is what keeps "copy freely" honest.
*/
it('all three doc gates carry the same ROOT_PAGES — the surface objectui#7115 widened', () => {
expect([...FENCE_ROOT_PAGES]).toEqual(['README.md']);
expect([...SNIPPET_ROOT_PAGES]).toEqual([...FENCE_ROOT_PAGES]);
expect([...COMPONENT_ROOT_PAGES]).toEqual([...FENCE_ROOT_PAGES]);
});

it('the root README is really in this gate’s walk — the widening, pinned', () => {
// Not implied by the equality above: both lists could lose it together.
expect(fenceDocuments(ROOT)).toContain('README.md');
});
});

describe('check-doc-fence-languages: non-vacuity, through the shipped module', () => {
Expand Down
46 changes: 46 additions & 0 deletions scripts/__tests__/check-doc-snippet-types.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -299,6 +299,52 @@ describe('this repository', () => {
});
});

/**
* objectui#7115 — the root `README.md` was in NO doc gate's scan set: this gate
* walked `content/docs` plus the package READMEs, its sibling
* `check-doc-component-types.mjs` walked `content/docs`, and the repository's
* landing page fell between them.
*
* ⚠️ Read the second assertion carefully. Being ON the ungated ledger is NOT a
* claim that this file compiles — it does not; objectui#7417 carries its nine
* measured diagnostics. It is the objectui#5174 distinction, which this script's
* own header states: a document outside the walk is "neither covered NOR
* declared ungated", invisible to the gate's own accounting, while a ledgered
* one is named, counted, re-derived every run and shrink-only.
*/
describe('objectui#7115 — the root README is in the scan set', () => {
it('listDocuments reaches it', () => {
expect(listDocuments(repoRoot)).toContain('README.md');
});

it('is DECLARED debt rather than absent, and its reason names the card that carries it', () => {
expect(Object.keys(UNGATED_DOCS as Record<string, string>)).toContain('README.md');
expect((UNGATED_DOCS as Record<string, string>)['README.md']).toContain('objectui#7417');
});

it('root pages are collected BY NAME, not by the packages walk', () => {
// The mechanism, isolated: a tree with no `content/docs` and no `packages`
// still lists its root page, which is what makes the entry independent of
// the two walks it sits between.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-doc-snippet-types-rootpages-'));
try {
fs.writeFileSync(path.join(dir, 'README.md'), '# root\n');
expect(listDocuments(dir)).toEqual(['README.md']);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it('states in its own source that a dangling root page fails the run', () => {
// The guard lives in `main()`, which takes no `--root`, so it cannot be
// driven from a fixture. Pinned against the source for the same reason the
// exit-code contract is: a silently narrowed surface is this card's defect.
const source = fs.readFileSync(path.join(repoRoot, 'scripts/check-doc-snippet-types.mjs'), 'utf8');
expect(source).toContain('ROOT_PAGES');
expect(source).toMatch(/for \(const name of ROOT_PAGES\) \{\n\s*if \(!existsSync\(join\(repoRoot, name\)\)\)/);
});
});

describe('third-party resolution reaches exactly as far as the imported packages declare', () => {
/** A workspace package with its own `node_modules`, the way pnpm links one. */
function treeWithDependency(files: Record<string, string> = {}): string {
Expand Down
Loading
Loading