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/render-publish-advisory-findings-5026.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
'@object-ui/data-objectstack': minor
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

Studio surfaces the runtime authoring gate's advisory findings after a **publish**, not only after a save

objectui#4133 / PR #4236 wired the gate's advisories to the save door and recorded, honestly, what that left unsurfaced: Studio's designer stages every edit as a `mode: 'draft'` save, drafts are never gated (the framework returns at its D1 early-return before a single rule runs), and the publish step that *is* gated returned no `advisories` field at all. So on the flow most tenants actually use, the author was told nothing at either door — for two different reasons, only one of which was objectui's.

The second reason has expired. `PublishMetaItemResponseSchema` now declares the same optional, omitted-when-empty `advisories` key that `SaveMetaItemResponseSchema` has carried since #4717, and `publishMetaItem` populates it. Measured against the installed `@objectstack/spec` (17.2.0) rather than inferred from the version number: the key survives a `safeParse`, a half-shaped finding is rejected, and a clean publish omits the key entirely. That reading is now a test rather than a note, so a spec drift fails CI instead of silently re-muting the door.

`MetadataClient.publish` and `MetadataClient.publishDraft` — the two methods over the single-item publish route `POST /meta/:type/:name/publish` — now report through the **same** sink, the same event and the same renderer the save door already used. No new UI shape: same warning tier, same 10s duration, same per-finding `rule` + `message` + `hint` formatting, findings still rendered verbatim as server prose. The wiring lands in the data layer rather than at the call sites, so `ResourceEditPage`'s Publish button and the runtime `RuntimeDraftBar` promotion (ObjectView / ReportView / DashboardView) are covered by one change, as are future ones.

One thing had to differ, and it is the frame's verb. Save and Publish are two different buttons in this product, so a toast that says "Saved" after a Publish tells the author their change is still a draft — the opposite of what happened. `MetadataSaveAdvisoryEvent` therefore gains a required `door: 'save' | 'publish'` and the renderer picks `console.publishAdvisoryTitle` (added to all ten locale packs) accordingly. `door` exists because `mode` cannot answer this: a direct active save and a draft promotion both report `mode: 'publish'`, since both land the body in the active overlay. It is required rather than optional so a future third door cannot be wired without saying which one it is, and the renderer branches on it through an exhaustive switch with a `never` check, so adding a third member is a compile error rather than a silently wrong verb.

**BREAKING for event constructors — `MetadataSaveAdvisoryEvent.door` is required.** Reading the event is unaffected: a listener that ignores `door` behaves exactly as before, and every other member is unchanged. Constructing one is a compile break — a door-less event literal that type-checked before now fails with TS2741, `Property 'door' is missing`. Measured on the emitted `dist/index.d.ts` of `@object-ui/data-objectstack` on both sides: that single required member is the entire non-comment delta of the package's published surface. **Migration:** add `door: 'save'` or `door: 'publish'` to the literal, whichever write it models — `'save'` for `PUT /meta/:type/:name`, `'publish'` for `POST /meta/:type/:name/publish`. Scored `minor` rather than `major` per the repo's version policy: objectui's major is pinned to `@objectstack`'s so that "same major means compatible" holds across the two repos, so objectui's own breaking changes ship as `minor` with the break named here (`scripts/check-changeset-no-major.mjs`). Every publishable package sits in one `fixed` group, so this entry carries the group.

Unchanged, deliberately: the **batch** door. "Publish whole app" (`POST /packages/:id/publish-drafts`) still discards per-draft advisories server-side — objectstack#9343, open and unruled — and nothing here compensates for that from the client side. A test pins the absence, so a later traversal of a batch-shaped `published[]` cannot be added without turning it red.
107 changes: 107 additions & 0 deletions packages/app-shell/src/providers/saveAdvisoryToast.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,7 @@ function event(overrides: Partial<MetadataSaveAdvisoryEvent> = {}): MetadataSave
return {
type: 'flow',
name: 'nightly_purge',
door: 'save',
mode: 'publish',
advisories: [FINDING],
...overrides,
Expand DownExpand Up@@ -160,3 +161,109 @@ describe('emitSaveAdvisories (#4133)', () => {
expect(description).toContain(FINDING.message);
});
});

/**
* The publish door renders through this SAME function (objectui#5026) — same
* warning tier, same duration, same per-finding formatting. One source more,
* not one surface more. What must differ is the frame's verb, because in this
* product Save and Publish are two different buttons: a toast that says "Saved"
* after a Publish tells the author their change is still a draft, which is the
* opposite of what happened.
*/
describe('emitSaveAdvisories — the publish door (#5026)', () => {
const published = () => event({ door: 'publish' });

it('says "Published", not "Saved", when the findings came through the publish door', () => {
const sink = makeSink();

emitSaveAdvisories(published(), t, sink);

const [title] = sink.warning.mock.calls[0]!;
expect(title).toContain('Published');
expect(title).not.toContain('Saved');
});

it('keeps saying "Saved" for the save door — the existing wording is untouched', () => {
const sink = makeSink();

emitSaveAdvisories(event({ door: 'save' }), t, sink);

expect(sink.warning.mock.calls[0]![0]).toContain('Saved');
});

it('reads the DOOR, not the mode — both doors report `mode: "publish"`', () => {
// The discriminating case: a direct active save also carries
// `mode: 'publish'`, so a renderer that branched on `mode` would call it a
// publish. Same mode on both events here; only `door` differs.
const saveSink = makeSink();
const publishSink = makeSink();

emitSaveAdvisories(event({ door: 'save', mode: 'publish' }), t, saveSink);
emitSaveAdvisories(event({ door: 'publish', mode: 'publish' }), t, publishSink);

expect(saveSink.warning.mock.calls[0]![0]).toContain('Saved');
expect(publishSink.warning.mock.calls[0]![0]).toContain('Published');
});

it('is the same surface otherwise — warning tier, same body, same duration', () => {
const sink = makeSink();

emitSaveAdvisories(published(), t, sink);

expect(sink.warning).toHaveBeenCalledTimes(1);
expect(sink.error).not.toHaveBeenCalled();
const [, opts] = sink.warning.mock.calls[0]!;
expect(opts!.description).toContain(FINDING.message);
expect(opts!.description).toContain(FINDING.hint);
expect(opts!.duration).toBeGreaterThanOrEqual(10_000);
});

it('says nothing on a clean publish', () => {
const sink = makeSink();

emitSaveAdvisories(event({ door: 'publish', advisories: [] }), t, sink);

expect(sink.warning).not.toHaveBeenCalled();
});
});

/**
* The exhaustiveness guarantee (#5026, contract-review condition 2).
*
* `door` being REQUIRED buys "every type-checked constructor must state a
* door". It does NOT by itself buy "the renderer handles the door it was
* given" — with a two-way ternary, a third union member would compile at its
* constructor, declare itself honestly, and still silently render "Saved",
* which is the exact class `door` exists to kill, reintroduced one level up.
*
* The compile-time half of the fix is the `never` check in `advisoryTitle`,
* enforced by `tsc` and not expressible here. What IS pinned here is its
* runtime consequence, which is what an untyped consumer would hit: an
* unhandled door must NOT come out wearing the save wording.
*/
describe('emitSaveAdvisories — the door union is handled exhaustively', () => {
it('refuses an unhandled door instead of silently calling it "Saved"', () => {
const sink = makeSink();
// An untyped consumer's event. The cast is the point: inside the type
// system this is unreachable, which is what the `never` check enforces.
const rogue = event({ door: 'rollback' as unknown as MetadataSaveAdvisoryEvent['door'] });

expect(() => emitSaveAdvisories(rogue, t, sink)).toThrow(/advisory door/);

// The load-bearing assertion: nothing was rendered. A wrong verb about a
// write that already touched the author's data is worse than no toast,
// and both emitters swallow this throw, so "no toast" is what ships.
expect(sink.warning).not.toHaveBeenCalled();
expect(sink.error).not.toHaveBeenCalled();
});

it('still handles every door the union actually declares', () => {
// The control for the case above: the refusal must be specific to an
// unhandled member, not a renderer that throws at everything.
for (const door of ['save', 'publish'] as const) {
const sink = makeSink();
emitSaveAdvisories(event({ door }), t, sink);
expect(sink.warning).toHaveBeenCalledTimes(1);
}
});
});
65 changes: 58 additions & 7 deletions packages/app-shell/src/providers/saveAdvisoryToast.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,12 +78,66 @@ function formatFinding(f: MetadataSaveAdvisoryEvent['advisories'][number]): stri
}

/**
* Announce the gate's advisory findings for a save that SUCCEEDED.
* The frame's verb, chosen by the door the write came through.
*
* An exhaustive `switch` with a `never` check rather than a two-way ternary,
* and the difference is the whole point of the field. A ternary answers
* "is it publish, else save" — so a THIRD door added to the union would
* compile everywhere, declare itself honestly at its constructor, and still
* silently render "Saved". That is precisely the silent-wrong-verb class
* `door` exists to kill, reintroduced one level up. Here a new member makes
* this function a compile error instead, which is the only form of the
* guarantee worth having: the type must not merely be STATED, it must be
* HANDLED.
*
* The `default` branch is unreachable for type-checked callers — it exists
* for an untyped one (the event type is published, and JS consumers are not
* bound by it). It throws rather than falling back to the save wording,
* because both emitters wrap the sink in a try/catch that swallows: the
* failure mode is therefore "no toast", never "a toast that says the wrong
* thing about what just happened to the author's data".
*/
function advisoryTitle(ev: MetadataSaveAdvisoryEvent, t: TranslateFn): string {
const count = ev.advisories.length;
switch (ev.door) {
case 'save':
return t('console.saveAdvisoryTitle', {
count,
defaultValue: 'Saved — the authoring check raised {{count}} advisory finding(s)',
});
case 'publish':
return t('console.publishAdvisoryTitle', {
count,
defaultValue: 'Published — the authoring check raised {{count}} advisory finding(s)',
});
default: {
const unhandled: never = ev.door;
throw new Error(
`saveAdvisoryToast: no title for advisory door ${JSON.stringify(unhandled)}`,
);
}
}
}

/**
* Announce the gate's advisory findings for a metadata write that SUCCEEDED.
*
* Says nothing when there is nothing to say: the server omits `advisories`
* entirely on a clean save, so the common case never reaches here, and an event
* that somehow carried an empty list is dropped rather than toasted as
* entirely on a clean write, so the common case never reaches here, and an
* event that somehow carried an empty list is dropped rather than toasted as
* "0 findings".
*
* ## One renderer, two doors (#5026)
*
* The publish door reports through this same function, the same warning tier,
* the same duration and the same per-finding formatting — a second SOURCE, not
* a second surface. Only the frame's verb changes, and it has to: Save and
* Publish are two different buttons in this product, so "Saved" after a Publish
* would tell the author their change is still a draft. `ev.door` is what says
* which one, because `ev.mode` cannot — a direct active save and a draft
* promotion both report `mode: 'publish'`. The choice is an exhaustive switch,
* not a two-way test: see {@link advisoryTitle} for why that distinction is
* the field's actual guarantee.
*/
export function emitSaveAdvisories(
ev: MetadataSaveAdvisoryEvent,
Expand All@@ -93,10 +147,7 @@ export function emitSaveAdvisories(
if (!ev.advisories || ev.advisories.length === 0) return;

sink.warning(
t('console.saveAdvisoryTitle', {
count: ev.advisories.length,
defaultValue: 'Saved — the authoring check raised {{count}} advisory finding(s)',
}),
advisoryTitle(ev, t),
{
description: ev.advisories.map(formatFinding).join('\n'),
duration: ADVISORY_TOAST_MS,
Expand Down
6 changes: 6 additions & 0 deletions packages/data-objectstack/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2750,6 +2750,12 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
this.emitSaveAdvisory({
type,
name,
// #5026 — this interceptor wraps `meta.saveItem`, the SAVE door
// (`PUT /meta/:type/:name`) and only that one. The SDK's publish
// door (`meta.publishItem`) has no caller in this repo, so wiring
// it here would be a surface with no consumer; `MetadataClient` is
// where the publish door is actually taken.
door: 'save',
mode: (result as { state?: string } | null | undefined)?.state === 'draft' ? 'draft' : 'publish',
advisories,
});
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 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/render-publish-advisory-findings-5026.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
'@object-ui/data-objectstack': minor
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

Studio surfaces the runtime authoring gate's advisory findings after a **publish**, not only after a save

objectui#4133 / PR #4236 wired the gate's advisories to the save door and recorded, honestly, what that left unsurfaced: Studio's designer stages every edit as a `mode: 'draft'` save, drafts are never gated (the framework returns at its D1 early-return before a single rule runs), and the publish step that *is* gated returned no `advisories` field at all. So on the flow most tenants actually use, the author was told nothing at either door — for two different reasons, only one of which was objectui's.

The second reason has expired. `PublishMetaItemResponseSchema` now declares the same optional, omitted-when-empty `advisories` key that `SaveMetaItemResponseSchema` has carried since #4717, and `publishMetaItem` populates it. Measured against the installed `@objectstack/spec` (17.2.0) rather than inferred from the version number: the key survives a `safeParse`, a half-shaped finding is rejected, and a clean publish omits the key entirely. That reading is now a test rather than a note, so a spec drift fails CI instead of silently re-muting the door.

`MetadataClient.publish` and `MetadataClient.publishDraft` — the two methods over the single-item publish route `POST /meta/:type/:name/publish` — now report through the **same** sink, the same event and the same renderer the save door already used. No new UI shape: same warning tier, same 10s duration, same per-finding `rule` + `message` + `hint` formatting, findings still rendered verbatim as server prose. The wiring lands in the data layer rather than at the call sites, so `ResourceEditPage`'s Publish button and the runtime `RuntimeDraftBar` promotion (ObjectView / ReportView / DashboardView) are covered by one change, as are future ones.

One thing had to differ, and it is the frame's verb. Save and Publish are two different buttons in this product, so a toast that says "Saved" after a Publish tells the author their change is still a draft — the opposite of what happened. `MetadataSaveAdvisoryEvent` therefore gains a required `door: 'save' | 'publish'` and the renderer picks `console.publishAdvisoryTitle` (added to all ten locale packs) accordingly. `door` exists because `mode` cannot answer this: a direct active save and a draft promotion both report `mode: 'publish'`, since both land the body in the active overlay. It is required rather than optional so a future third door cannot be wired without saying which one it is, and the renderer branches on it through an exhaustive switch with a `never` check, so adding a third member is a compile error rather than a silently wrong verb.

**BREAKING for event constructors — `MetadataSaveAdvisoryEvent.door` is required.** Reading the event is unaffected: a listener that ignores `door` behaves exactly as before, and every other member is unchanged. Constructing one is a compile break — a door-less event literal that type-checked before now fails with TS2741, `Property 'door' is missing`. Measured on the emitted `dist/index.d.ts` of `@object-ui/data-objectstack` on both sides: that single required member is the entire non-comment delta of the package's published surface. **Migration:** add `door: 'save'` or `door: 'publish'` to the literal, whichever write it models — `'save'` for `PUT /meta/:type/:name`, `'publish'` for `POST /meta/:type/:name/publish`. Scored `minor` rather than `major` per the repo's version policy: objectui's major is pinned to `@objectstack`'s so that "same major means compatible" holds across the two repos, so objectui's own breaking changes ship as `minor` with the break named here (`scripts/check-changeset-no-major.mjs`). Every publishable package sits in one `fixed` group, so this entry carries the group.

Unchanged, deliberately: the **batch** door. "Publish whole app" (`POST /packages/:id/publish-drafts`) still discards per-draft advisories server-side — objectstack#9343, open and unruled — and nothing here compensates for that from the client side. A test pins the absence, so a later traversal of a batch-shaped `published[]` cannot be added without turning it red.
107 changes: 107 additions & 0 deletions packages/app-shell/src/providers/saveAdvisoryToast.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,7 @@ function event(overrides: Partial<MetadataSaveAdvisoryEvent> = {}): MetadataSave
return {
type: 'flow',
name: 'nightly_purge',
door: 'save',
mode: 'publish',
advisories: [FINDING],
...overrides,
Expand DownExpand Up@@ -160,3 +161,109 @@ describe('emitSaveAdvisories (#4133)', () => {
expect(description).toContain(FINDING.message);
});
});

/**
* The publish door renders through this SAME function (objectui#5026) — same
* warning tier, same duration, same per-finding formatting. One source more,
* not one surface more. What must differ is the frame's verb, because in this
* product Save and Publish are two different buttons: a toast that says "Saved"
* after a Publish tells the author their change is still a draft, which is the
* opposite of what happened.
*/
describe('emitSaveAdvisories — the publish door (#5026)', () => {
const published = () => event({ door: 'publish' });

it('says "Published", not "Saved", when the findings came through the publish door', () => {
const sink = makeSink();

emitSaveAdvisories(published(), t, sink);

const [title] = sink.warning.mock.calls[0]!;
expect(title).toContain('Published');
expect(title).not.toContain('Saved');
});

it('keeps saying "Saved" for the save door — the existing wording is untouched', () => {
const sink = makeSink();

emitSaveAdvisories(event({ door: 'save' }), t, sink);

expect(sink.warning.mock.calls[0]![0]).toContain('Saved');
});

it('reads the DOOR, not the mode — both doors report `mode: "publish"`', () => {
// The discriminating case: a direct active save also carries
// `mode: 'publish'`, so a renderer that branched on `mode` would call it a
// publish. Same mode on both events here; only `door` differs.
const saveSink = makeSink();
const publishSink = makeSink();

emitSaveAdvisories(event({ door: 'save', mode: 'publish' }), t, saveSink);
emitSaveAdvisories(event({ door: 'publish', mode: 'publish' }), t, publishSink);

expect(saveSink.warning.mock.calls[0]![0]).toContain('Saved');
expect(publishSink.warning.mock.calls[0]![0]).toContain('Published');
});

it('is the same surface otherwise — warning tier, same body, same duration', () => {
const sink = makeSink();

emitSaveAdvisories(published(), t, sink);

expect(sink.warning).toHaveBeenCalledTimes(1);
expect(sink.error).not.toHaveBeenCalled();
const [, opts] = sink.warning.mock.calls[0]!;
expect(opts!.description).toContain(FINDING.message);
expect(opts!.description).toContain(FINDING.hint);
expect(opts!.duration).toBeGreaterThanOrEqual(10_000);
});

it('says nothing on a clean publish', () => {
const sink = makeSink();

emitSaveAdvisories(event({ door: 'publish', advisories: [] }), t, sink);

expect(sink.warning).not.toHaveBeenCalled();
});
});

/**
* The exhaustiveness guarantee (#5026, contract-review condition 2).
*
* `door` being REQUIRED buys "every type-checked constructor must state a
* door". It does NOT by itself buy "the renderer handles the door it was
* given" — with a two-way ternary, a third union member would compile at its
* constructor, declare itself honestly, and still silently render "Saved",
* which is the exact class `door` exists to kill, reintroduced one level up.
*
* The compile-time half of the fix is the `never` check in `advisoryTitle`,
* enforced by `tsc` and not expressible here. What IS pinned here is its
* runtime consequence, which is what an untyped consumer would hit: an
* unhandled door must NOT come out wearing the save wording.
*/
describe('emitSaveAdvisories — the door union is handled exhaustively', () => {
it('refuses an unhandled door instead of silently calling it "Saved"', () => {
const sink = makeSink();
// An untyped consumer's event. The cast is the point: inside the type
// system this is unreachable, which is what the `never` check enforces.
const rogue = event({ door: 'rollback' as unknown as MetadataSaveAdvisoryEvent['door'] });

expect(() => emitSaveAdvisories(rogue, t, sink)).toThrow(/advisory door/);

// The load-bearing assertion: nothing was rendered. A wrong verb about a
// write that already touched the author's data is worse than no toast,
// and both emitters swallow this throw, so "no toast" is what ships.
expect(sink.warning).not.toHaveBeenCalled();
expect(sink.error).not.toHaveBeenCalled();
});

it('still handles every door the union actually declares', () => {
// The control for the case above: the refusal must be specific to an
// unhandled member, not a renderer that throws at everything.
for (const door of ['save', 'publish'] as const) {
const sink = makeSink();
emitSaveAdvisories(event({ door }), t, sink);
expect(sink.warning).toHaveBeenCalledTimes(1);
}
});
});
65 changes: 58 additions & 7 deletions packages/app-shell/src/providers/saveAdvisoryToast.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,12 +78,66 @@ function formatFinding(f: MetadataSaveAdvisoryEvent['advisories'][number]): stri
}

/**
* Announce the gate's advisory findings for a save that SUCCEEDED.
* The frame's verb, chosen by the door the write came through.
*
* An exhaustive `switch` with a `never` check rather than a two-way ternary,
* and the difference is the whole point of the field. A ternary answers
* "is it publish, else save" — so a THIRD door added to the union would
* compile everywhere, declare itself honestly at its constructor, and still
* silently render "Saved". That is precisely the silent-wrong-verb class
* `door` exists to kill, reintroduced one level up. Here a new member makes
* this function a compile error instead, which is the only form of the
* guarantee worth having: the type must not merely be STATED, it must be
* HANDLED.
*
* The `default` branch is unreachable for type-checked callers — it exists
* for an untyped one (the event type is published, and JS consumers are not
* bound by it). It throws rather than falling back to the save wording,
* because both emitters wrap the sink in a try/catch that swallows: the
* failure mode is therefore "no toast", never "a toast that says the wrong
* thing about what just happened to the author's data".
*/
function advisoryTitle(ev: MetadataSaveAdvisoryEvent, t: TranslateFn): string {
const count = ev.advisories.length;
switch (ev.door) {
case 'save':
return t('console.saveAdvisoryTitle', {
count,
defaultValue: 'Saved — the authoring check raised {{count}} advisory finding(s)',
});
case 'publish':
return t('console.publishAdvisoryTitle', {
count,
defaultValue: 'Published — the authoring check raised {{count}} advisory finding(s)',
});
default: {
const unhandled: never = ev.door;
throw new Error(
`saveAdvisoryToast: no title for advisory door ${JSON.stringify(unhandled)}`,
);
}
}
}

/**
* Announce the gate's advisory findings for a metadata write that SUCCEEDED.
*
* Says nothing when there is nothing to say: the server omits `advisories`
* entirely on a clean save, so the common case never reaches here, and an event
* that somehow carried an empty list is dropped rather than toasted as
* entirely on a clean write, so the common case never reaches here, and an
* event that somehow carried an empty list is dropped rather than toasted as
* "0 findings".
*
* ## One renderer, two doors (#5026)
*
* The publish door reports through this same function, the same warning tier,
* the same duration and the same per-finding formatting — a second SOURCE, not
* a second surface. Only the frame's verb changes, and it has to: Save and
* Publish are two different buttons in this product, so "Saved" after a Publish
* would tell the author their change is still a draft. `ev.door` is what says
* which one, because `ev.mode` cannot — a direct active save and a draft
* promotion both report `mode: 'publish'`. The choice is an exhaustive switch,
* not a two-way test: see {@link advisoryTitle} for why that distinction is
* the field's actual guarantee.
*/
export function emitSaveAdvisories(
ev: MetadataSaveAdvisoryEvent,
Expand All@@ -93,10 +147,7 @@ export function emitSaveAdvisories(
if (!ev.advisories || ev.advisories.length === 0) return;

sink.warning(
t('console.saveAdvisoryTitle', {
count: ev.advisories.length,
defaultValue: 'Saved — the authoring check raised {{count}} advisory finding(s)',
}),
advisoryTitle(ev, t),
{
description: ev.advisories.map(formatFinding).join('\n'),
duration: ADVISORY_TOAST_MS,
Expand Down
6 changes: 6 additions & 0 deletions packages/data-objectstack/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2750,6 +2750,12 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
this.emitSaveAdvisory({
type,
name,
// #5026 — this interceptor wraps `meta.saveItem`, the SAVE door
// (`PUT /meta/:type/:name`) and only that one. The SDK's publish
// door (`meta.publishItem`) has no caller in this repo, so wiring
// it here would be a surface with no consumer; `MetadataClient` is
// where the publish door is actually taken.
door: 'save',
mode: (result as { state?: string } | null | undefined)?.state === 'draft' ? 'draft' : 'publish',
advisories,
});
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/render-publish-advisory-findings-5026.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
'@object-ui/data-objectstack': minor
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

Studio surfaces the runtime authoring gate's advisory findings after a **publish**, not only after a save

objectui#4133 / PR #4236 wired the gate's advisories to the save door and recorded, honestly, what that left unsurfaced: Studio's designer stages every edit as a `mode: 'draft'` save, drafts are never gated (the framework returns at its D1 early-return before a single rule runs), and the publish step that *is* gated returned no `advisories` field at all. So on the flow most tenants actually use, the author was told nothing at either door — for two different reasons, only one of which was objectui's.

The second reason has expired. `PublishMetaItemResponseSchema` now declares the same optional, omitted-when-empty `advisories` key that `SaveMetaItemResponseSchema` has carried since #4717, and `publishMetaItem` populates it. Measured against the installed `@objectstack/spec` (17.2.0) rather than inferred from the version number: the key survives a `safeParse`, a half-shaped finding is rejected, and a clean publish omits the key entirely. That reading is now a test rather than a note, so a spec drift fails CI instead of silently re-muting the door.

`MetadataClient.publish` and `MetadataClient.publishDraft` — the two methods over the single-item publish route `POST /meta/:type/:name/publish` — now report through the **same** sink, the same event and the same renderer the save door already used. No new UI shape: same warning tier, same 10s duration, same per-finding `rule` + `message` + `hint` formatting, findings still rendered verbatim as server prose. The wiring lands in the data layer rather than at the call sites, so `ResourceEditPage`'s Publish button and the runtime `RuntimeDraftBar` promotion (ObjectView / ReportView / DashboardView) are covered by one change, as are future ones.

One thing had to differ, and it is the frame's verb. Save and Publish are two different buttons in this product, so a toast that says "Saved" after a Publish tells the author their change is still a draft — the opposite of what happened. `MetadataSaveAdvisoryEvent` therefore gains a required `door: 'save' | 'publish'` and the renderer picks `console.publishAdvisoryTitle` (added to all ten locale packs) accordingly. `door` exists because `mode` cannot answer this: a direct active save and a draft promotion both report `mode: 'publish'`, since both land the body in the active overlay. It is required rather than optional so a future third door cannot be wired without saying which one it is, and the renderer branches on it through an exhaustive switch with a `never` check, so adding a third member is a compile error rather than a silently wrong verb.

**BREAKING for event constructors — `MetadataSaveAdvisoryEvent.door` is required.** Reading the event is unaffected: a listener that ignores `door` behaves exactly as before, and every other member is unchanged. Constructing one is a compile break — a door-less event literal that type-checked before now fails with TS2741, `Property 'door' is missing`. Measured on the emitted `dist/index.d.ts` of `@object-ui/data-objectstack` on both sides: that single required member is the entire non-comment delta of the package's published surface. **Migration:** add `door: 'save'` or `door: 'publish'` to the literal, whichever write it models — `'save'` for `PUT /meta/:type/:name`, `'publish'` for `POST /meta/:type/:name/publish`. Scored `minor` rather than `major` per the repo's version policy: objectui's major is pinned to `@objectstack`'s so that "same major means compatible" holds across the two repos, so objectui's own breaking changes ship as `minor` with the break named here (`scripts/check-changeset-no-major.mjs`). Every publishable package sits in one `fixed` group, so this entry carries the group.

Unchanged, deliberately: the **batch** door. "Publish whole app" (`POST /packages/:id/publish-drafts`) still discards per-draft advisories server-side — objectstack#9343, open and unruled — and nothing here compensates for that from the client side. A test pins the absence, so a later traversal of a batch-shaped `published[]` cannot be added without turning it red.
107 changes: 107 additions & 0 deletions packages/app-shell/src/providers/saveAdvisoryToast.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,7 @@ function event(overrides: Partial<MetadataSaveAdvisoryEvent> = {}): MetadataSave
return {
type: 'flow',
name: 'nightly_purge',
door: 'save',
mode: 'publish',
advisories: [FINDING],
...overrides,
Expand DownExpand Up@@ -160,3 +161,109 @@ describe('emitSaveAdvisories (#4133)', () => {
expect(description).toContain(FINDING.message);
});
});

/**
* The publish door renders through this SAME function (objectui#5026) — same
* warning tier, same duration, same per-finding formatting. One source more,
* not one surface more. What must differ is the frame's verb, because in this
* product Save and Publish are two different buttons: a toast that says "Saved"
* after a Publish tells the author their change is still a draft, which is the
* opposite of what happened.
*/
describe('emitSaveAdvisories — the publish door (#5026)', () => {
const published = () => event({ door: 'publish' });

it('says "Published", not "Saved", when the findings came through the publish door', () => {
const sink = makeSink();

emitSaveAdvisories(published(), t, sink);

const [title] = sink.warning.mock.calls[0]!;
expect(title).toContain('Published');
expect(title).not.toContain('Saved');
});

it('keeps saying "Saved" for the save door — the existing wording is untouched', () => {
const sink = makeSink();

emitSaveAdvisories(event({ door: 'save' }), t, sink);

expect(sink.warning.mock.calls[0]![0]).toContain('Saved');
});

it('reads the DOOR, not the mode — both doors report `mode: "publish"`', () => {
// The discriminating case: a direct active save also carries
// `mode: 'publish'`, so a renderer that branched on `mode` would call it a
// publish. Same mode on both events here; only `door` differs.
const saveSink = makeSink();
const publishSink = makeSink();

emitSaveAdvisories(event({ door: 'save', mode: 'publish' }), t, saveSink);
emitSaveAdvisories(event({ door: 'publish', mode: 'publish' }), t, publishSink);

expect(saveSink.warning.mock.calls[0]![0]).toContain('Saved');
expect(publishSink.warning.mock.calls[0]![0]).toContain('Published');
});

it('is the same surface otherwise — warning tier, same body, same duration', () => {
const sink = makeSink();

emitSaveAdvisories(published(), t, sink);

expect(sink.warning).toHaveBeenCalledTimes(1);
expect(sink.error).not.toHaveBeenCalled();
const [, opts] = sink.warning.mock.calls[0]!;
expect(opts!.description).toContain(FINDING.message);
expect(opts!.description).toContain(FINDING.hint);
expect(opts!.duration).toBeGreaterThanOrEqual(10_000);
});

it('says nothing on a clean publish', () => {
const sink = makeSink();

emitSaveAdvisories(event({ door: 'publish', advisories: [] }), t, sink);

expect(sink.warning).not.toHaveBeenCalled();
});
});

/**
* The exhaustiveness guarantee (#5026, contract-review condition 2).
*
* `door` being REQUIRED buys "every type-checked constructor must state a
* door". It does NOT by itself buy "the renderer handles the door it was
* given" — with a two-way ternary, a third union member would compile at its
* constructor, declare itself honestly, and still silently render "Saved",
* which is the exact class `door` exists to kill, reintroduced one level up.
*
* The compile-time half of the fix is the `never` check in `advisoryTitle`,
* enforced by `tsc` and not expressible here. What IS pinned here is its
* runtime consequence, which is what an untyped consumer would hit: an
* unhandled door must NOT come out wearing the save wording.
*/
describe('emitSaveAdvisories — the door union is handled exhaustively', () => {
it('refuses an unhandled door instead of silently calling it "Saved"', () => {
const sink = makeSink();
// An untyped consumer's event. The cast is the point: inside the type
// system this is unreachable, which is what the `never` check enforces.
const rogue = event({ door: 'rollback' as unknown as MetadataSaveAdvisoryEvent['door'] });

expect(() => emitSaveAdvisories(rogue, t, sink)).toThrow(/advisory door/);

// The load-bearing assertion: nothing was rendered. A wrong verb about a
// write that already touched the author's data is worse than no toast,
// and both emitters swallow this throw, so "no toast" is what ships.
expect(sink.warning).not.toHaveBeenCalled();
expect(sink.error).not.toHaveBeenCalled();
});

it('still handles every door the union actually declares', () => {
// The control for the case above: the refusal must be specific to an
// unhandled member, not a renderer that throws at everything.
for (const door of ['save', 'publish'] as const) {
const sink = makeSink();
emitSaveAdvisories(event({ door }), t, sink);
expect(sink.warning).toHaveBeenCalledTimes(1);
}
});
});
65 changes: 58 additions & 7 deletions packages/app-shell/src/providers/saveAdvisoryToast.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,12 +78,66 @@ function formatFinding(f: MetadataSaveAdvisoryEvent['advisories'][number]): stri
}

/**
* Announce the gate's advisory findings for a save that SUCCEEDED.
* The frame's verb, chosen by the door the write came through.
*
* An exhaustive `switch` with a `never` check rather than a two-way ternary,
* and the difference is the whole point of the field. A ternary answers
* "is it publish, else save" — so a THIRD door added to the union would
* compile everywhere, declare itself honestly at its constructor, and still
* silently render "Saved". That is precisely the silent-wrong-verb class
* `door` exists to kill, reintroduced one level up. Here a new member makes
* this function a compile error instead, which is the only form of the
* guarantee worth having: the type must not merely be STATED, it must be
* HANDLED.
*
* The `default` branch is unreachable for type-checked callers — it exists
* for an untyped one (the event type is published, and JS consumers are not
* bound by it). It throws rather than falling back to the save wording,
* because both emitters wrap the sink in a try/catch that swallows: the
* failure mode is therefore "no toast", never "a toast that says the wrong
* thing about what just happened to the author's data".
*/
function advisoryTitle(ev: MetadataSaveAdvisoryEvent, t: TranslateFn): string {
const count = ev.advisories.length;
switch (ev.door) {
case 'save':
return t('console.saveAdvisoryTitle', {
count,
defaultValue: 'Saved — the authoring check raised {{count}} advisory finding(s)',
});
case 'publish':
return t('console.publishAdvisoryTitle', {
count,
defaultValue: 'Published — the authoring check raised {{count}} advisory finding(s)',
});
default: {
const unhandled: never = ev.door;
throw new Error(
`saveAdvisoryToast: no title for advisory door ${JSON.stringify(unhandled)}`,
);
}
}
}

/**
* Announce the gate's advisory findings for a metadata write that SUCCEEDED.
*
* Says nothing when there is nothing to say: the server omits `advisories`
* entirely on a clean save, so the common case never reaches here, and an event
* that somehow carried an empty list is dropped rather than toasted as
* entirely on a clean write, so the common case never reaches here, and an
* event that somehow carried an empty list is dropped rather than toasted as
* "0 findings".
*
* ## One renderer, two doors (#5026)
*
* The publish door reports through this same function, the same warning tier,
* the same duration and the same per-finding formatting — a second SOURCE, not
* a second surface. Only the frame's verb changes, and it has to: Save and
* Publish are two different buttons in this product, so "Saved" after a Publish
* would tell the author their change is still a draft. `ev.door` is what says
* which one, because `ev.mode` cannot — a direct active save and a draft
* promotion both report `mode: 'publish'`. The choice is an exhaustive switch,
* not a two-way test: see {@link advisoryTitle} for why that distinction is
* the field's actual guarantee.
*/
export function emitSaveAdvisories(
ev: MetadataSaveAdvisoryEvent,
Expand All@@ -93,10 +147,7 @@ export function emitSaveAdvisories(
if (!ev.advisories || ev.advisories.length === 0) return;

sink.warning(
t('console.saveAdvisoryTitle', {
count: ev.advisories.length,
defaultValue: 'Saved — the authoring check raised {{count}} advisory finding(s)',
}),
advisoryTitle(ev, t),
{
description: ev.advisories.map(formatFinding).join('\n'),
duration: ADVISORY_TOAST_MS,
Expand Down
6 changes: 6 additions & 0 deletions packages/data-objectstack/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2750,6 +2750,12 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
this.emitSaveAdvisory({
type,
name,
// #5026 — this interceptor wraps `meta.saveItem`, the SAVE door
// (`PUT /meta/:type/:name`) and only that one. The SDK's publish
// door (`meta.publishItem`) has no caller in this repo, so wiring
// it here would be a surface with no consumer; `MetadataClient` is
// where the publish door is actually taken.
door: 'save',
mode: (result as { state?: string } | null | undefined)?.state === 'draft' ? 'draft' : 'publish',
advisories,
});
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 > 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/render-publish-advisory-findings-5026.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
'@object-ui/data-objectstack': minor
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

Studio surfaces the runtime authoring gate's advisory findings after a **publish**, not only after a save

objectui#4133 / PR #4236 wired the gate's advisories to the save door and recorded, honestly, what that left unsurfaced: Studio's designer stages every edit as a `mode: 'draft'` save, drafts are never gated (the framework returns at its D1 early-return before a single rule runs), and the publish step that *is* gated returned no `advisories` field at all. So on the flow most tenants actually use, the author was told nothing at either door — for two different reasons, only one of which was objectui's.

The second reason has expired. `PublishMetaItemResponseSchema` now declares the same optional, omitted-when-empty `advisories` key that `SaveMetaItemResponseSchema` has carried since #4717, and `publishMetaItem` populates it. Measured against the installed `@objectstack/spec` (17.2.0) rather than inferred from the version number: the key survives a `safeParse`, a half-shaped finding is rejected, and a clean publish omits the key entirely. That reading is now a test rather than a note, so a spec drift fails CI instead of silently re-muting the door.

`MetadataClient.publish` and `MetadataClient.publishDraft` — the two methods over the single-item publish route `POST /meta/:type/:name/publish` — now report through the **same** sink, the same event and the same renderer the save door already used. No new UI shape: same warning tier, same 10s duration, same per-finding `rule` + `message` + `hint` formatting, findings still rendered verbatim as server prose. The wiring lands in the data layer rather than at the call sites, so `ResourceEditPage`'s Publish button and the runtime `RuntimeDraftBar` promotion (ObjectView / ReportView / DashboardView) are covered by one change, as are future ones.

One thing had to differ, and it is the frame's verb. Save and Publish are two different buttons in this product, so a toast that says "Saved" after a Publish tells the author their change is still a draft — the opposite of what happened. `MetadataSaveAdvisoryEvent` therefore gains a required `door: 'save' | 'publish'` and the renderer picks `console.publishAdvisoryTitle` (added to all ten locale packs) accordingly. `door` exists because `mode` cannot answer this: a direct active save and a draft promotion both report `mode: 'publish'`, since both land the body in the active overlay. It is required rather than optional so a future third door cannot be wired without saying which one it is, and the renderer branches on it through an exhaustive switch with a `never` check, so adding a third member is a compile error rather than a silently wrong verb.

**BREAKING for event constructors — `MetadataSaveAdvisoryEvent.door` is required.** Reading the event is unaffected: a listener that ignores `door` behaves exactly as before, and every other member is unchanged. Constructing one is a compile break — a door-less event literal that type-checked before now fails with TS2741, `Property 'door' is missing`. Measured on the emitted `dist/index.d.ts` of `@object-ui/data-objectstack` on both sides: that single required member is the entire non-comment delta of the package's published surface. **Migration:** add `door: 'save'` or `door: 'publish'` to the literal, whichever write it models — `'save'` for `PUT /meta/:type/:name`, `'publish'` for `POST /meta/:type/:name/publish`. Scored `minor` rather than `major` per the repo's version policy: objectui's major is pinned to `@objectstack`'s so that "same major means compatible" holds across the two repos, so objectui's own breaking changes ship as `minor` with the break named here (`scripts/check-changeset-no-major.mjs`). Every publishable package sits in one `fixed` group, so this entry carries the group.

Unchanged, deliberately: the **batch** door. "Publish whole app" (`POST /packages/:id/publish-drafts`) still discards per-draft advisories server-side — objectstack#9343, open and unruled — and nothing here compensates for that from the client side. A test pins the absence, so a later traversal of a batch-shaped `published[]` cannot be added without turning it red.
107 changes: 107 additions & 0 deletions packages/app-shell/src/providers/saveAdvisoryToast.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,7 @@ function event(overrides: Partial<MetadataSaveAdvisoryEvent> = {}): MetadataSave
return {
type: 'flow',
name: 'nightly_purge',
door: 'save',
mode: 'publish',
advisories: [FINDING],
...overrides,
Expand DownExpand Up@@ -160,3 +161,109 @@ describe('emitSaveAdvisories (#4133)', () => {
expect(description).toContain(FINDING.message);
});
});

/**
* The publish door renders through this SAME function (objectui#5026) — same
* warning tier, same duration, same per-finding formatting. One source more,
* not one surface more. What must differ is the frame's verb, because in this
* product Save and Publish are two different buttons: a toast that says "Saved"
* after a Publish tells the author their change is still a draft, which is the
* opposite of what happened.
*/
describe('emitSaveAdvisories — the publish door (#5026)', () => {
const published = () => event({ door: 'publish' });

it('says "Published", not "Saved", when the findings came through the publish door', () => {
const sink = makeSink();

emitSaveAdvisories(published(), t, sink);

const [title] = sink.warning.mock.calls[0]!;
expect(title).toContain('Published');
expect(title).not.toContain('Saved');
});

it('keeps saying "Saved" for the save door — the existing wording is untouched', () => {
const sink = makeSink();

emitSaveAdvisories(event({ door: 'save' }), t, sink);

expect(sink.warning.mock.calls[0]![0]).toContain('Saved');
});

it('reads the DOOR, not the mode — both doors report `mode: "publish"`', () => {
// The discriminating case: a direct active save also carries
// `mode: 'publish'`, so a renderer that branched on `mode` would call it a
// publish. Same mode on both events here; only `door` differs.
const saveSink = makeSink();
const publishSink = makeSink();

emitSaveAdvisories(event({ door: 'save', mode: 'publish' }), t, saveSink);
emitSaveAdvisories(event({ door: 'publish', mode: 'publish' }), t, publishSink);

expect(saveSink.warning.mock.calls[0]![0]).toContain('Saved');
expect(publishSink.warning.mock.calls[0]![0]).toContain('Published');
});

it('is the same surface otherwise — warning tier, same body, same duration', () => {
const sink = makeSink();

emitSaveAdvisories(published(), t, sink);

expect(sink.warning).toHaveBeenCalledTimes(1);
expect(sink.error).not.toHaveBeenCalled();
const [, opts] = sink.warning.mock.calls[0]!;
expect(opts!.description).toContain(FINDING.message);
expect(opts!.description).toContain(FINDING.hint);
expect(opts!.duration).toBeGreaterThanOrEqual(10_000);
});

it('says nothing on a clean publish', () => {
const sink = makeSink();

emitSaveAdvisories(event({ door: 'publish', advisories: [] }), t, sink);

expect(sink.warning).not.toHaveBeenCalled();
});
});

/**
* The exhaustiveness guarantee (#5026, contract-review condition 2).
*
* `door` being REQUIRED buys "every type-checked constructor must state a
* door". It does NOT by itself buy "the renderer handles the door it was
* given" — with a two-way ternary, a third union member would compile at its
* constructor, declare itself honestly, and still silently render "Saved",
* which is the exact class `door` exists to kill, reintroduced one level up.
*
* The compile-time half of the fix is the `never` check in `advisoryTitle`,
* enforced by `tsc` and not expressible here. What IS pinned here is its
* runtime consequence, which is what an untyped consumer would hit: an
* unhandled door must NOT come out wearing the save wording.
*/
describe('emitSaveAdvisories — the door union is handled exhaustively', () => {
it('refuses an unhandled door instead of silently calling it "Saved"', () => {
const sink = makeSink();
// An untyped consumer's event. The cast is the point: inside the type
// system this is unreachable, which is what the `never` check enforces.
const rogue = event({ door: 'rollback' as unknown as MetadataSaveAdvisoryEvent['door'] });

expect(() => emitSaveAdvisories(rogue, t, sink)).toThrow(/advisory door/);

// The load-bearing assertion: nothing was rendered. A wrong verb about a
// write that already touched the author's data is worse than no toast,
// and both emitters swallow this throw, so "no toast" is what ships.
expect(sink.warning).not.toHaveBeenCalled();
expect(sink.error).not.toHaveBeenCalled();
});

it('still handles every door the union actually declares', () => {
// The control for the case above: the refusal must be specific to an
// unhandled member, not a renderer that throws at everything.
for (const door of ['save', 'publish'] as const) {
const sink = makeSink();
emitSaveAdvisories(event({ door }), t, sink);
expect(sink.warning).toHaveBeenCalledTimes(1);
}
});
});
65 changes: 58 additions & 7 deletions packages/app-shell/src/providers/saveAdvisoryToast.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,12 +78,66 @@ function formatFinding(f: MetadataSaveAdvisoryEvent['advisories'][number]): stri
}

/**
* Announce the gate's advisory findings for a save that SUCCEEDED.
* The frame's verb, chosen by the door the write came through.
*
* An exhaustive `switch` with a `never` check rather than a two-way ternary,
* and the difference is the whole point of the field. A ternary answers
* "is it publish, else save" — so a THIRD door added to the union would
* compile everywhere, declare itself honestly at its constructor, and still
* silently render "Saved". That is precisely the silent-wrong-verb class
* `door` exists to kill, reintroduced one level up. Here a new member makes
* this function a compile error instead, which is the only form of the
* guarantee worth having: the type must not merely be STATED, it must be
* HANDLED.
*
* The `default` branch is unreachable for type-checked callers — it exists
* for an untyped one (the event type is published, and JS consumers are not
* bound by it). It throws rather than falling back to the save wording,
* because both emitters wrap the sink in a try/catch that swallows: the
* failure mode is therefore "no toast", never "a toast that says the wrong
* thing about what just happened to the author's data".
*/
function advisoryTitle(ev: MetadataSaveAdvisoryEvent, t: TranslateFn): string {
const count = ev.advisories.length;
switch (ev.door) {
case 'save':
return t('console.saveAdvisoryTitle', {
count,
defaultValue: 'Saved — the authoring check raised {{count}} advisory finding(s)',
});
case 'publish':
return t('console.publishAdvisoryTitle', {
count,
defaultValue: 'Published — the authoring check raised {{count}} advisory finding(s)',
});
default: {
const unhandled: never = ev.door;
throw new Error(
`saveAdvisoryToast: no title for advisory door ${JSON.stringify(unhandled)}`,
);
}
}
}

/**
* Announce the gate's advisory findings for a metadata write that SUCCEEDED.
*
* Says nothing when there is nothing to say: the server omits `advisories`
* entirely on a clean save, so the common case never reaches here, and an event
* that somehow carried an empty list is dropped rather than toasted as
* entirely on a clean write, so the common case never reaches here, and an
* event that somehow carried an empty list is dropped rather than toasted as
* "0 findings".
*
* ## One renderer, two doors (#5026)
*
* The publish door reports through this same function, the same warning tier,
* the same duration and the same per-finding formatting — a second SOURCE, not
* a second surface. Only the frame's verb changes, and it has to: Save and
* Publish are two different buttons in this product, so "Saved" after a Publish
* would tell the author their change is still a draft. `ev.door` is what says
* which one, because `ev.mode` cannot — a direct active save and a draft
* promotion both report `mode: 'publish'`. The choice is an exhaustive switch,
* not a two-way test: see {@link advisoryTitle} for why that distinction is
* the field's actual guarantee.
*/
export function emitSaveAdvisories(
ev: MetadataSaveAdvisoryEvent,
Expand All@@ -93,10 +147,7 @@ export function emitSaveAdvisories(
if (!ev.advisories || ev.advisories.length === 0) return;

sink.warning(
t('console.saveAdvisoryTitle', {
count: ev.advisories.length,
defaultValue: 'Saved — the authoring check raised {{count}} advisory finding(s)',
}),
advisoryTitle(ev, t),
{
description: ev.advisories.map(formatFinding).join('\n'),
duration: ADVISORY_TOAST_MS,
Expand Down
6 changes: 6 additions & 0 deletions packages/data-objectstack/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2750,6 +2750,12 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
this.emitSaveAdvisory({
type,
name,
// #5026 — this interceptor wraps `meta.saveItem`, the SAVE door
// (`PUT /meta/:type/:name`) and only that one. The SDK's publish
// door (`meta.publishItem`) has no caller in this repo, so wiring
// it here would be a surface with no consumer; `MetadataClient` is
// where the publish door is actually taken.
door: 'save',
mode: (result as { state?: string } | null | undefined)?.state === 'draft' ? 'draft' : 'publish',
advisories,
});
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/render-publish-advisory-findings-5026.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
'@object-ui/data-objectstack': minor
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

Studio surfaces the runtime authoring gate's advisory findings after a **publish**, not only after a save

objectui#4133 / PR #4236 wired the gate's advisories to the save door and recorded, honestly, what that left unsurfaced: Studio's designer stages every edit as a `mode: 'draft'` save, drafts are never gated (the framework returns at its D1 early-return before a single rule runs), and the publish step that *is* gated returned no `advisories` field at all. So on the flow most tenants actually use, the author was told nothing at either door — for two different reasons, only one of which was objectui's.

The second reason has expired. `PublishMetaItemResponseSchema` now declares the same optional, omitted-when-empty `advisories` key that `SaveMetaItemResponseSchema` has carried since #4717, and `publishMetaItem` populates it. Measured against the installed `@objectstack/spec` (17.2.0) rather than inferred from the version number: the key survives a `safeParse`, a half-shaped finding is rejected, and a clean publish omits the key entirely. That reading is now a test rather than a note, so a spec drift fails CI instead of silently re-muting the door.

`MetadataClient.publish` and `MetadataClient.publishDraft` — the two methods over the single-item publish route `POST /meta/:type/:name/publish` — now report through the **same** sink, the same event and the same renderer the save door already used. No new UI shape: same warning tier, same 10s duration, same per-finding `rule` + `message` + `hint` formatting, findings still rendered verbatim as server prose. The wiring lands in the data layer rather than at the call sites, so `ResourceEditPage`'s Publish button and the runtime `RuntimeDraftBar` promotion (ObjectView / ReportView / DashboardView) are covered by one change, as are future ones.

One thing had to differ, and it is the frame's verb. Save and Publish are two different buttons in this product, so a toast that says "Saved" after a Publish tells the author their change is still a draft — the opposite of what happened. `MetadataSaveAdvisoryEvent` therefore gains a required `door: 'save' | 'publish'` and the renderer picks `console.publishAdvisoryTitle` (added to all ten locale packs) accordingly. `door` exists because `mode` cannot answer this: a direct active save and a draft promotion both report `mode: 'publish'`, since both land the body in the active overlay. It is required rather than optional so a future third door cannot be wired without saying which one it is, and the renderer branches on it through an exhaustive switch with a `never` check, so adding a third member is a compile error rather than a silently wrong verb.

**BREAKING for event constructors — `MetadataSaveAdvisoryEvent.door` is required.** Reading the event is unaffected: a listener that ignores `door` behaves exactly as before, and every other member is unchanged. Constructing one is a compile break — a door-less event literal that type-checked before now fails with TS2741, `Property 'door' is missing`. Measured on the emitted `dist/index.d.ts` of `@object-ui/data-objectstack` on both sides: that single required member is the entire non-comment delta of the package's published surface. **Migration:** add `door: 'save'` or `door: 'publish'` to the literal, whichever write it models — `'save'` for `PUT /meta/:type/:name`, `'publish'` for `POST /meta/:type/:name/publish`. Scored `minor` rather than `major` per the repo's version policy: objectui's major is pinned to `@objectstack`'s so that "same major means compatible" holds across the two repos, so objectui's own breaking changes ship as `minor` with the break named here (`scripts/check-changeset-no-major.mjs`). Every publishable package sits in one `fixed` group, so this entry carries the group.

Unchanged, deliberately: the **batch** door. "Publish whole app" (`POST /packages/:id/publish-drafts`) still discards per-draft advisories server-side — objectstack#9343, open and unruled — and nothing here compensates for that from the client side. A test pins the absence, so a later traversal of a batch-shaped `published[]` cannot be added without turning it red.
107 changes: 107 additions & 0 deletions packages/app-shell/src/providers/saveAdvisoryToast.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,7 @@ function event(overrides: Partial<MetadataSaveAdvisoryEvent> = {}): MetadataSave
return {
type: 'flow',
name: 'nightly_purge',
door: 'save',
mode: 'publish',
advisories: [FINDING],
...overrides,
Expand DownExpand Up@@ -160,3 +161,109 @@ describe('emitSaveAdvisories (#4133)', () => {
expect(description).toContain(FINDING.message);
});
});

/**
* The publish door renders through this SAME function (objectui#5026) — same
* warning tier, same duration, same per-finding formatting. One source more,
* not one surface more. What must differ is the frame's verb, because in this
* product Save and Publish are two different buttons: a toast that says "Saved"
* after a Publish tells the author their change is still a draft, which is the
* opposite of what happened.
*/
describe('emitSaveAdvisories — the publish door (#5026)', () => {
const published = () => event({ door: 'publish' });

it('says "Published", not "Saved", when the findings came through the publish door', () => {
const sink = makeSink();

emitSaveAdvisories(published(), t, sink);

const [title] = sink.warning.mock.calls[0]!;
expect(title).toContain('Published');
expect(title).not.toContain('Saved');
});

it('keeps saying "Saved" for the save door — the existing wording is untouched', () => {
const sink = makeSink();

emitSaveAdvisories(event({ door: 'save' }), t, sink);

expect(sink.warning.mock.calls[0]![0]).toContain('Saved');
});

it('reads the DOOR, not the mode — both doors report `mode: "publish"`', () => {
// The discriminating case: a direct active save also carries
// `mode: 'publish'`, so a renderer that branched on `mode` would call it a
// publish. Same mode on both events here; only `door` differs.
const saveSink = makeSink();
const publishSink = makeSink();

emitSaveAdvisories(event({ door: 'save', mode: 'publish' }), t, saveSink);
emitSaveAdvisories(event({ door: 'publish', mode: 'publish' }), t, publishSink);

expect(saveSink.warning.mock.calls[0]![0]).toContain('Saved');
expect(publishSink.warning.mock.calls[0]![0]).toContain('Published');
});

it('is the same surface otherwise — warning tier, same body, same duration', () => {
const sink = makeSink();

emitSaveAdvisories(published(), t, sink);

expect(sink.warning).toHaveBeenCalledTimes(1);
expect(sink.error).not.toHaveBeenCalled();
const [, opts] = sink.warning.mock.calls[0]!;
expect(opts!.description).toContain(FINDING.message);
expect(opts!.description).toContain(FINDING.hint);
expect(opts!.duration).toBeGreaterThanOrEqual(10_000);
});

it('says nothing on a clean publish', () => {
const sink = makeSink();

emitSaveAdvisories(event({ door: 'publish', advisories: [] }), t, sink);

expect(sink.warning).not.toHaveBeenCalled();
});
});

/**
* The exhaustiveness guarantee (#5026, contract-review condition 2).
*
* `door` being REQUIRED buys "every type-checked constructor must state a
* door". It does NOT by itself buy "the renderer handles the door it was
* given" — with a two-way ternary, a third union member would compile at its
* constructor, declare itself honestly, and still silently render "Saved",
* which is the exact class `door` exists to kill, reintroduced one level up.
*
* The compile-time half of the fix is the `never` check in `advisoryTitle`,
* enforced by `tsc` and not expressible here. What IS pinned here is its
* runtime consequence, which is what an untyped consumer would hit: an
* unhandled door must NOT come out wearing the save wording.
*/
describe('emitSaveAdvisories — the door union is handled exhaustively', () => {
it('refuses an unhandled door instead of silently calling it "Saved"', () => {
const sink = makeSink();
// An untyped consumer's event. The cast is the point: inside the type
// system this is unreachable, which is what the `never` check enforces.
const rogue = event({ door: 'rollback' as unknown as MetadataSaveAdvisoryEvent['door'] });

expect(() => emitSaveAdvisories(rogue, t, sink)).toThrow(/advisory door/);

// The load-bearing assertion: nothing was rendered. A wrong verb about a
// write that already touched the author's data is worse than no toast,
// and both emitters swallow this throw, so "no toast" is what ships.
expect(sink.warning).not.toHaveBeenCalled();
expect(sink.error).not.toHaveBeenCalled();
});

it('still handles every door the union actually declares', () => {
// The control for the case above: the refusal must be specific to an
// unhandled member, not a renderer that throws at everything.
for (const door of ['save', 'publish'] as const) {
const sink = makeSink();
emitSaveAdvisories(event({ door }), t, sink);
expect(sink.warning).toHaveBeenCalledTimes(1);
}
});
});
65 changes: 58 additions & 7 deletions packages/app-shell/src/providers/saveAdvisoryToast.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,12 +78,66 @@ function formatFinding(f: MetadataSaveAdvisoryEvent['advisories'][number]): stri
}

/**
* Announce the gate's advisory findings for a save that SUCCEEDED.
* The frame's verb, chosen by the door the write came through.
*
* An exhaustive `switch` with a `never` check rather than a two-way ternary,
* and the difference is the whole point of the field. A ternary answers
* "is it publish, else save" — so a THIRD door added to the union would
* compile everywhere, declare itself honestly at its constructor, and still
* silently render "Saved". That is precisely the silent-wrong-verb class
* `door` exists to kill, reintroduced one level up. Here a new member makes
* this function a compile error instead, which is the only form of the
* guarantee worth having: the type must not merely be STATED, it must be
* HANDLED.
*
* The `default` branch is unreachable for type-checked callers — it exists
* for an untyped one (the event type is published, and JS consumers are not
* bound by it). It throws rather than falling back to the save wording,
* because both emitters wrap the sink in a try/catch that swallows: the
* failure mode is therefore "no toast", never "a toast that says the wrong
* thing about what just happened to the author's data".
*/
function advisoryTitle(ev: MetadataSaveAdvisoryEvent, t: TranslateFn): string {
const count = ev.advisories.length;
switch (ev.door) {
case 'save':
return t('console.saveAdvisoryTitle', {
count,
defaultValue: 'Saved — the authoring check raised {{count}} advisory finding(s)',
});
case 'publish':
return t('console.publishAdvisoryTitle', {
count,
defaultValue: 'Published — the authoring check raised {{count}} advisory finding(s)',
});
default: {
const unhandled: never = ev.door;
throw new Error(
`saveAdvisoryToast: no title for advisory door ${JSON.stringify(unhandled)}`,
);
}
}
}

/**
* Announce the gate's advisory findings for a metadata write that SUCCEEDED.
*
* Says nothing when there is nothing to say: the server omits `advisories`
* entirely on a clean save, so the common case never reaches here, and an event
* that somehow carried an empty list is dropped rather than toasted as
* entirely on a clean write, so the common case never reaches here, and an
* event that somehow carried an empty list is dropped rather than toasted as
* "0 findings".
*
* ## One renderer, two doors (#5026)
*
* The publish door reports through this same function, the same warning tier,
* the same duration and the same per-finding formatting — a second SOURCE, not
* a second surface. Only the frame's verb changes, and it has to: Save and
* Publish are two different buttons in this product, so "Saved" after a Publish
* would tell the author their change is still a draft. `ev.door` is what says
* which one, because `ev.mode` cannot — a direct active save and a draft
* promotion both report `mode: 'publish'`. The choice is an exhaustive switch,
* not a two-way test: see {@link advisoryTitle} for why that distinction is
* the field's actual guarantee.
*/
export function emitSaveAdvisories(
ev: MetadataSaveAdvisoryEvent,
Expand All@@ -93,10 +147,7 @@ export function emitSaveAdvisories(
if (!ev.advisories || ev.advisories.length === 0) return;

sink.warning(
t('console.saveAdvisoryTitle', {
count: ev.advisories.length,
defaultValue: 'Saved — the authoring check raised {{count}} advisory finding(s)',
}),
advisoryTitle(ev, t),
{
description: ev.advisories.map(formatFinding).join('\n'),
duration: ADVISORY_TOAST_MS,
Expand Down
6 changes: 6 additions & 0 deletions packages/data-objectstack/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2750,6 +2750,12 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
this.emitSaveAdvisory({
type,
name,
// #5026 — this interceptor wraps `meta.saveItem`, the SAVE door
// (`PUT /meta/:type/:name`) and only that one. The SDK's publish
// door (`meta.publishItem`) has no caller in this repo, so wiring
// it here would be a surface with no consumer; `MetadataClient` is
// where the publish door is actually taken.
door: 'save',
mode: (result as { state?: string } | null | undefined)?.state === 'draft' ? 'draft' : 'publish',
advisories,
});
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/render-publish-advisory-findings-5026.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
'@object-ui/data-objectstack': minor
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

Studio surfaces the runtime authoring gate's advisory findings after a **publish**, not only after a save

objectui#4133 / PR #4236 wired the gate's advisories to the save door and recorded, honestly, what that left unsurfaced: Studio's designer stages every edit as a `mode: 'draft'` save, drafts are never gated (the framework returns at its D1 early-return before a single rule runs), and the publish step that *is* gated returned no `advisories` field at all. So on the flow most tenants actually use, the author was told nothing at either door — for two different reasons, only one of which was objectui's.

The second reason has expired. `PublishMetaItemResponseSchema` now declares the same optional, omitted-when-empty `advisories` key that `SaveMetaItemResponseSchema` has carried since #4717, and `publishMetaItem` populates it. Measured against the installed `@objectstack/spec` (17.2.0) rather than inferred from the version number: the key survives a `safeParse`, a half-shaped finding is rejected, and a clean publish omits the key entirely. That reading is now a test rather than a note, so a spec drift fails CI instead of silently re-muting the door.

`MetadataClient.publish` and `MetadataClient.publishDraft` — the two methods over the single-item publish route `POST /meta/:type/:name/publish` — now report through the **same** sink, the same event and the same renderer the save door already used. No new UI shape: same warning tier, same 10s duration, same per-finding `rule` + `message` + `hint` formatting, findings still rendered verbatim as server prose. The wiring lands in the data layer rather than at the call sites, so `ResourceEditPage`'s Publish button and the runtime `RuntimeDraftBar` promotion (ObjectView / ReportView / DashboardView) are covered by one change, as are future ones.

One thing had to differ, and it is the frame's verb. Save and Publish are two different buttons in this product, so a toast that says "Saved" after a Publish tells the author their change is still a draft — the opposite of what happened. `MetadataSaveAdvisoryEvent` therefore gains a required `door: 'save' | 'publish'` and the renderer picks `console.publishAdvisoryTitle` (added to all ten locale packs) accordingly. `door` exists because `mode` cannot answer this: a direct active save and a draft promotion both report `mode: 'publish'`, since both land the body in the active overlay. It is required rather than optional so a future third door cannot be wired without saying which one it is, and the renderer branches on it through an exhaustive switch with a `never` check, so adding a third member is a compile error rather than a silently wrong verb.

**BREAKING for event constructors — `MetadataSaveAdvisoryEvent.door` is required.** Reading the event is unaffected: a listener that ignores `door` behaves exactly as before, and every other member is unchanged. Constructing one is a compile break — a door-less event literal that type-checked before now fails with TS2741, `Property 'door' is missing`. Measured on the emitted `dist/index.d.ts` of `@object-ui/data-objectstack` on both sides: that single required member is the entire non-comment delta of the package's published surface. **Migration:** add `door: 'save'` or `door: 'publish'` to the literal, whichever write it models — `'save'` for `PUT /meta/:type/:name`, `'publish'` for `POST /meta/:type/:name/publish`. Scored `minor` rather than `major` per the repo's version policy: objectui's major is pinned to `@objectstack`'s so that "same major means compatible" holds across the two repos, so objectui's own breaking changes ship as `minor` with the break named here (`scripts/check-changeset-no-major.mjs`). Every publishable package sits in one `fixed` group, so this entry carries the group.

Unchanged, deliberately: the **batch** door. "Publish whole app" (`POST /packages/:id/publish-drafts`) still discards per-draft advisories server-side — objectstack#9343, open and unruled — and nothing here compensates for that from the client side. A test pins the absence, so a later traversal of a batch-shaped `published[]` cannot be added without turning it red.
107 changes: 107 additions & 0 deletions packages/app-shell/src/providers/saveAdvisoryToast.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,7 @@ function event(overrides: Partial<MetadataSaveAdvisoryEvent> = {}): MetadataSave
return {
type: 'flow',
name: 'nightly_purge',
door: 'save',
mode: 'publish',
advisories: [FINDING],
...overrides,
Expand DownExpand Up@@ -160,3 +161,109 @@ describe('emitSaveAdvisories (#4133)', () => {
expect(description).toContain(FINDING.message);
});
});

/**
* The publish door renders through this SAME function (objectui#5026) — same
* warning tier, same duration, same per-finding formatting. One source more,
* not one surface more. What must differ is the frame's verb, because in this
* product Save and Publish are two different buttons: a toast that says "Saved"
* after a Publish tells the author their change is still a draft, which is the
* opposite of what happened.
*/
describe('emitSaveAdvisories — the publish door (#5026)', () => {
const published = () => event({ door: 'publish' });

it('says "Published", not "Saved", when the findings came through the publish door', () => {
const sink = makeSink();

emitSaveAdvisories(published(), t, sink);

const [title] = sink.warning.mock.calls[0]!;
expect(title).toContain('Published');
expect(title).not.toContain('Saved');
});

it('keeps saying "Saved" for the save door — the existing wording is untouched', () => {
const sink = makeSink();

emitSaveAdvisories(event({ door: 'save' }), t, sink);

expect(sink.warning.mock.calls[0]![0]).toContain('Saved');
});

it('reads the DOOR, not the mode — both doors report `mode: "publish"`', () => {
// The discriminating case: a direct active save also carries
// `mode: 'publish'`, so a renderer that branched on `mode` would call it a
// publish. Same mode on both events here; only `door` differs.
const saveSink = makeSink();
const publishSink = makeSink();

emitSaveAdvisories(event({ door: 'save', mode: 'publish' }), t, saveSink);
emitSaveAdvisories(event({ door: 'publish', mode: 'publish' }), t, publishSink);

expect(saveSink.warning.mock.calls[0]![0]).toContain('Saved');
expect(publishSink.warning.mock.calls[0]![0]).toContain('Published');
});

it('is the same surface otherwise — warning tier, same body, same duration', () => {
const sink = makeSink();

emitSaveAdvisories(published(), t, sink);

expect(sink.warning).toHaveBeenCalledTimes(1);
expect(sink.error).not.toHaveBeenCalled();
const [, opts] = sink.warning.mock.calls[0]!;
expect(opts!.description).toContain(FINDING.message);
expect(opts!.description).toContain(FINDING.hint);
expect(opts!.duration).toBeGreaterThanOrEqual(10_000);
});

it('says nothing on a clean publish', () => {
const sink = makeSink();

emitSaveAdvisories(event({ door: 'publish', advisories: [] }), t, sink);

expect(sink.warning).not.toHaveBeenCalled();
});
});

/**
* The exhaustiveness guarantee (#5026, contract-review condition 2).
*
* `door` being REQUIRED buys "every type-checked constructor must state a
* door". It does NOT by itself buy "the renderer handles the door it was
* given" — with a two-way ternary, a third union member would compile at its
* constructor, declare itself honestly, and still silently render "Saved",
* which is the exact class `door` exists to kill, reintroduced one level up.
*
* The compile-time half of the fix is the `never` check in `advisoryTitle`,
* enforced by `tsc` and not expressible here. What IS pinned here is its
* runtime consequence, which is what an untyped consumer would hit: an
* unhandled door must NOT come out wearing the save wording.
*/
describe('emitSaveAdvisories — the door union is handled exhaustively', () => {
it('refuses an unhandled door instead of silently calling it "Saved"', () => {
const sink = makeSink();
// An untyped consumer's event. The cast is the point: inside the type
// system this is unreachable, which is what the `never` check enforces.
const rogue = event({ door: 'rollback' as unknown as MetadataSaveAdvisoryEvent['door'] });

expect(() => emitSaveAdvisories(rogue, t, sink)).toThrow(/advisory door/);

// The load-bearing assertion: nothing was rendered. A wrong verb about a
// write that already touched the author's data is worse than no toast,
// and both emitters swallow this throw, so "no toast" is what ships.
expect(sink.warning).not.toHaveBeenCalled();
expect(sink.error).not.toHaveBeenCalled();
});

it('still handles every door the union actually declares', () => {
// The control for the case above: the refusal must be specific to an
// unhandled member, not a renderer that throws at everything.
for (const door of ['save', 'publish'] as const) {
const sink = makeSink();
emitSaveAdvisories(event({ door }), t, sink);
expect(sink.warning).toHaveBeenCalledTimes(1);
}
});
});
65 changes: 58 additions & 7 deletions packages/app-shell/src/providers/saveAdvisoryToast.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,12 +78,66 @@ function formatFinding(f: MetadataSaveAdvisoryEvent['advisories'][number]): stri
}

/**
* Announce the gate's advisory findings for a save that SUCCEEDED.
* The frame's verb, chosen by the door the write came through.
*
* An exhaustive `switch` with a `never` check rather than a two-way ternary,
* and the difference is the whole point of the field. A ternary answers
* "is it publish, else save" — so a THIRD door added to the union would
* compile everywhere, declare itself honestly at its constructor, and still
* silently render "Saved". That is precisely the silent-wrong-verb class
* `door` exists to kill, reintroduced one level up. Here a new member makes
* this function a compile error instead, which is the only form of the
* guarantee worth having: the type must not merely be STATED, it must be
* HANDLED.
*
* The `default` branch is unreachable for type-checked callers — it exists
* for an untyped one (the event type is published, and JS consumers are not
* bound by it). It throws rather than falling back to the save wording,
* because both emitters wrap the sink in a try/catch that swallows: the
* failure mode is therefore "no toast", never "a toast that says the wrong
* thing about what just happened to the author's data".
*/
function advisoryTitle(ev: MetadataSaveAdvisoryEvent, t: TranslateFn): string {
const count = ev.advisories.length;
switch (ev.door) {
case 'save':
return t('console.saveAdvisoryTitle', {
count,
defaultValue: 'Saved — the authoring check raised {{count}} advisory finding(s)',
});
case 'publish':
return t('console.publishAdvisoryTitle', {
count,
defaultValue: 'Published — the authoring check raised {{count}} advisory finding(s)',
});
default: {
const unhandled: never = ev.door;
throw new Error(
`saveAdvisoryToast: no title for advisory door ${JSON.stringify(unhandled)}`,
);
}
}
}

/**
* Announce the gate's advisory findings for a metadata write that SUCCEEDED.
*
* Says nothing when there is nothing to say: the server omits `advisories`
* entirely on a clean save, so the common case never reaches here, and an event
* that somehow carried an empty list is dropped rather than toasted as
* entirely on a clean write, so the common case never reaches here, and an
* event that somehow carried an empty list is dropped rather than toasted as
* "0 findings".
*
* ## One renderer, two doors (#5026)
*
* The publish door reports through this same function, the same warning tier,
* the same duration and the same per-finding formatting — a second SOURCE, not
* a second surface. Only the frame's verb changes, and it has to: Save and
* Publish are two different buttons in this product, so "Saved" after a Publish
* would tell the author their change is still a draft. `ev.door` is what says
* which one, because `ev.mode` cannot — a direct active save and a draft
* promotion both report `mode: 'publish'`. The choice is an exhaustive switch,
* not a two-way test: see {@link advisoryTitle} for why that distinction is
* the field's actual guarantee.
*/
export function emitSaveAdvisories(
ev: MetadataSaveAdvisoryEvent,
Expand All@@ -93,10 +147,7 @@ export function emitSaveAdvisories(
if (!ev.advisories || ev.advisories.length === 0) return;

sink.warning(
t('console.saveAdvisoryTitle', {
count: ev.advisories.length,
defaultValue: 'Saved — the authoring check raised {{count}} advisory finding(s)',
}),
advisoryTitle(ev, t),
{
description: ev.advisories.map(formatFinding).join('\n'),
duration: ADVISORY_TOAST_MS,
Expand Down
6 changes: 6 additions & 0 deletions packages/data-objectstack/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2750,6 +2750,12 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
this.emitSaveAdvisory({
type,
name,
// #5026 — this interceptor wraps `meta.saveItem`, the SAVE door
// (`PUT /meta/:type/:name`) and only that one. The SDK's publish
// door (`meta.publishItem`) has no caller in this repo, so wiring
// it here would be a surface with no consumer; `MetadataClient` is
// where the publish door is actually taken.
door: 'save',
mode: (result as { state?: string } | null | undefined)?.state === 'draft' ? 'draft' : 'publish',
advisories,
});
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/render-publish-advisory-findings-5026.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
'@object-ui/data-objectstack': minor
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

Studio surfaces the runtime authoring gate's advisory findings after a **publish**, not only after a save

objectui#4133 / PR #4236 wired the gate's advisories to the save door and recorded, honestly, what that left unsurfaced: Studio's designer stages every edit as a `mode: 'draft'` save, drafts are never gated (the framework returns at its D1 early-return before a single rule runs), and the publish step that *is* gated returned no `advisories` field at all. So on the flow most tenants actually use, the author was told nothing at either door — for two different reasons, only one of which was objectui's.

The second reason has expired. `PublishMetaItemResponseSchema` now declares the same optional, omitted-when-empty `advisories` key that `SaveMetaItemResponseSchema` has carried since #4717, and `publishMetaItem` populates it. Measured against the installed `@objectstack/spec` (17.2.0) rather than inferred from the version number: the key survives a `safeParse`, a half-shaped finding is rejected, and a clean publish omits the key entirely. That reading is now a test rather than a note, so a spec drift fails CI instead of silently re-muting the door.

`MetadataClient.publish` and `MetadataClient.publishDraft` — the two methods over the single-item publish route `POST /meta/:type/:name/publish` — now report through the **same** sink, the same event and the same renderer the save door already used. No new UI shape: same warning tier, same 10s duration, same per-finding `rule` + `message` + `hint` formatting, findings still rendered verbatim as server prose. The wiring lands in the data layer rather than at the call sites, so `ResourceEditPage`'s Publish button and the runtime `RuntimeDraftBar` promotion (ObjectView / ReportView / DashboardView) are covered by one change, as are future ones.

One thing had to differ, and it is the frame's verb. Save and Publish are two different buttons in this product, so a toast that says "Saved" after a Publish tells the author their change is still a draft — the opposite of what happened. `MetadataSaveAdvisoryEvent` therefore gains a required `door: 'save' | 'publish'` and the renderer picks `console.publishAdvisoryTitle` (added to all ten locale packs) accordingly. `door` exists because `mode` cannot answer this: a direct active save and a draft promotion both report `mode: 'publish'`, since both land the body in the active overlay. It is required rather than optional so a future third door cannot be wired without saying which one it is, and the renderer branches on it through an exhaustive switch with a `never` check, so adding a third member is a compile error rather than a silently wrong verb.

**BREAKING for event constructors — `MetadataSaveAdvisoryEvent.door` is required.** Reading the event is unaffected: a listener that ignores `door` behaves exactly as before, and every other member is unchanged. Constructing one is a compile break — a door-less event literal that type-checked before now fails with TS2741, `Property 'door' is missing`. Measured on the emitted `dist/index.d.ts` of `@object-ui/data-objectstack` on both sides: that single required member is the entire non-comment delta of the package's published surface. **Migration:** add `door: 'save'` or `door: 'publish'` to the literal, whichever write it models — `'save'` for `PUT /meta/:type/:name`, `'publish'` for `POST /meta/:type/:name/publish`. Scored `minor` rather than `major` per the repo's version policy: objectui's major is pinned to `@objectstack`'s so that "same major means compatible" holds across the two repos, so objectui's own breaking changes ship as `minor` with the break named here (`scripts/check-changeset-no-major.mjs`). Every publishable package sits in one `fixed` group, so this entry carries the group.

Unchanged, deliberately: the **batch** door. "Publish whole app" (`POST /packages/:id/publish-drafts`) still discards per-draft advisories server-side — objectstack#9343, open and unruled — and nothing here compensates for that from the client side. A test pins the absence, so a later traversal of a batch-shaped `published[]` cannot be added without turning it red.
107 changes: 107 additions & 0 deletions packages/app-shell/src/providers/saveAdvisoryToast.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,7 @@ function event(overrides: Partial<MetadataSaveAdvisoryEvent> = {}): MetadataSave
return {
type: 'flow',
name: 'nightly_purge',
door: 'save',
mode: 'publish',
advisories: [FINDING],
...overrides,
Expand DownExpand Up@@ -160,3 +161,109 @@ describe('emitSaveAdvisories (#4133)', () => {
expect(description).toContain(FINDING.message);
});
});

/**
* The publish door renders through this SAME function (objectui#5026) — same
* warning tier, same duration, same per-finding formatting. One source more,
* not one surface more. What must differ is the frame's verb, because in this
* product Save and Publish are two different buttons: a toast that says "Saved"
* after a Publish tells the author their change is still a draft, which is the
* opposite of what happened.
*/
describe('emitSaveAdvisories — the publish door (#5026)', () => {
const published = () => event({ door: 'publish' });

it('says "Published", not "Saved", when the findings came through the publish door', () => {
const sink = makeSink();

emitSaveAdvisories(published(), t, sink);

const [title] = sink.warning.mock.calls[0]!;
expect(title).toContain('Published');
expect(title).not.toContain('Saved');
});

it('keeps saying "Saved" for the save door — the existing wording is untouched', () => {
const sink = makeSink();

emitSaveAdvisories(event({ door: 'save' }), t, sink);

expect(sink.warning.mock.calls[0]![0]).toContain('Saved');
});

it('reads the DOOR, not the mode — both doors report `mode: "publish"`', () => {
// The discriminating case: a direct active save also carries
// `mode: 'publish'`, so a renderer that branched on `mode` would call it a
// publish. Same mode on both events here; only `door` differs.
const saveSink = makeSink();
const publishSink = makeSink();

emitSaveAdvisories(event({ door: 'save', mode: 'publish' }), t, saveSink);
emitSaveAdvisories(event({ door: 'publish', mode: 'publish' }), t, publishSink);

expect(saveSink.warning.mock.calls[0]![0]).toContain('Saved');
expect(publishSink.warning.mock.calls[0]![0]).toContain('Published');
});

it('is the same surface otherwise — warning tier, same body, same duration', () => {
const sink = makeSink();

emitSaveAdvisories(published(), t, sink);

expect(sink.warning).toHaveBeenCalledTimes(1);
expect(sink.error).not.toHaveBeenCalled();
const [, opts] = sink.warning.mock.calls[0]!;
expect(opts!.description).toContain(FINDING.message);
expect(opts!.description).toContain(FINDING.hint);
expect(opts!.duration).toBeGreaterThanOrEqual(10_000);
});

it('says nothing on a clean publish', () => {
const sink = makeSink();

emitSaveAdvisories(event({ door: 'publish', advisories: [] }), t, sink);

expect(sink.warning).not.toHaveBeenCalled();
});
});

/**
* The exhaustiveness guarantee (#5026, contract-review condition 2).
*
* `door` being REQUIRED buys "every type-checked constructor must state a
* door". It does NOT by itself buy "the renderer handles the door it was
* given" — with a two-way ternary, a third union member would compile at its
* constructor, declare itself honestly, and still silently render "Saved",
* which is the exact class `door` exists to kill, reintroduced one level up.
*
* The compile-time half of the fix is the `never` check in `advisoryTitle`,
* enforced by `tsc` and not expressible here. What IS pinned here is its
* runtime consequence, which is what an untyped consumer would hit: an
* unhandled door must NOT come out wearing the save wording.
*/
describe('emitSaveAdvisories — the door union is handled exhaustively', () => {
it('refuses an unhandled door instead of silently calling it "Saved"', () => {
const sink = makeSink();
// An untyped consumer's event. The cast is the point: inside the type
// system this is unreachable, which is what the `never` check enforces.
const rogue = event({ door: 'rollback' as unknown as MetadataSaveAdvisoryEvent['door'] });

expect(() => emitSaveAdvisories(rogue, t, sink)).toThrow(/advisory door/);

// The load-bearing assertion: nothing was rendered. A wrong verb about a
// write that already touched the author's data is worse than no toast,
// and both emitters swallow this throw, so "no toast" is what ships.
expect(sink.warning).not.toHaveBeenCalled();
expect(sink.error).not.toHaveBeenCalled();
});

it('still handles every door the union actually declares', () => {
// The control for the case above: the refusal must be specific to an
// unhandled member, not a renderer that throws at everything.
for (const door of ['save', 'publish'] as const) {
const sink = makeSink();
emitSaveAdvisories(event({ door }), t, sink);
expect(sink.warning).toHaveBeenCalledTimes(1);
}
});
});
65 changes: 58 additions & 7 deletions packages/app-shell/src/providers/saveAdvisoryToast.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,12 +78,66 @@ function formatFinding(f: MetadataSaveAdvisoryEvent['advisories'][number]): stri
}

/**
* Announce the gate's advisory findings for a save that SUCCEEDED.
* The frame's verb, chosen by the door the write came through.
*
* An exhaustive `switch` with a `never` check rather than a two-way ternary,
* and the difference is the whole point of the field. A ternary answers
* "is it publish, else save" — so a THIRD door added to the union would
* compile everywhere, declare itself honestly at its constructor, and still
* silently render "Saved". That is precisely the silent-wrong-verb class
* `door` exists to kill, reintroduced one level up. Here a new member makes
* this function a compile error instead, which is the only form of the
* guarantee worth having: the type must not merely be STATED, it must be
* HANDLED.
*
* The `default` branch is unreachable for type-checked callers — it exists
* for an untyped one (the event type is published, and JS consumers are not
* bound by it). It throws rather than falling back to the save wording,
* because both emitters wrap the sink in a try/catch that swallows: the
* failure mode is therefore "no toast", never "a toast that says the wrong
* thing about what just happened to the author's data".
*/
function advisoryTitle(ev: MetadataSaveAdvisoryEvent, t: TranslateFn): string {
const count = ev.advisories.length;
switch (ev.door) {
case 'save':
return t('console.saveAdvisoryTitle', {
count,
defaultValue: 'Saved — the authoring check raised {{count}} advisory finding(s)',
});
case 'publish':
return t('console.publishAdvisoryTitle', {
count,
defaultValue: 'Published — the authoring check raised {{count}} advisory finding(s)',
});
default: {
const unhandled: never = ev.door;
throw new Error(
`saveAdvisoryToast: no title for advisory door ${JSON.stringify(unhandled)}`,
);
}
}
}

/**
* Announce the gate's advisory findings for a metadata write that SUCCEEDED.
*
* Says nothing when there is nothing to say: the server omits `advisories`
* entirely on a clean save, so the common case never reaches here, and an event
* that somehow carried an empty list is dropped rather than toasted as
* entirely on a clean write, so the common case never reaches here, and an
* event that somehow carried an empty list is dropped rather than toasted as
* "0 findings".
*
* ## One renderer, two doors (#5026)
*
* The publish door reports through this same function, the same warning tier,
* the same duration and the same per-finding formatting — a second SOURCE, not
* a second surface. Only the frame's verb changes, and it has to: Save and
* Publish are two different buttons in this product, so "Saved" after a Publish
* would tell the author their change is still a draft. `ev.door` is what says
* which one, because `ev.mode` cannot — a direct active save and a draft
* promotion both report `mode: 'publish'`. The choice is an exhaustive switch,
* not a two-way test: see {@link advisoryTitle} for why that distinction is
* the field's actual guarantee.
*/
export function emitSaveAdvisories(
ev: MetadataSaveAdvisoryEvent,
Expand All@@ -93,10 +147,7 @@ export function emitSaveAdvisories(
if (!ev.advisories || ev.advisories.length === 0) return;

sink.warning(
t('console.saveAdvisoryTitle', {
count: ev.advisories.length,
defaultValue: 'Saved — the authoring check raised {{count}} advisory finding(s)',
}),
advisoryTitle(ev, t),
{
description: ev.advisories.map(formatFinding).join('\n'),
duration: ADVISORY_TOAST_MS,
Expand Down
6 changes: 6 additions & 0 deletions packages/data-objectstack/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2750,6 +2750,12 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
this.emitSaveAdvisory({
type,
name,
// #5026 — this interceptor wraps `meta.saveItem`, the SAVE door
// (`PUT /meta/:type/:name`) and only that one. The SDK's publish
// door (`meta.publishItem`) has no caller in this repo, so wiring
// it here would be a surface with no consumer; `MetadataClient` is
// where the publish door is actually taken.
door: 'save',
mode: (result as { state?: string } | null | undefined)?.state === 'draft' ? 'draft' : 'publish',
advisories,
});
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/render-publish-advisory-findings-5026.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
'@object-ui/data-objectstack': minor
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

Studio surfaces the runtime authoring gate's advisory findings after a **publish**, not only after a save

objectui#4133 / PR #4236 wired the gate's advisories to the save door and recorded, honestly, what that left unsurfaced: Studio's designer stages every edit as a `mode: 'draft'` save, drafts are never gated (the framework returns at its D1 early-return before a single rule runs), and the publish step that *is* gated returned no `advisories` field at all. So on the flow most tenants actually use, the author was told nothing at either door — for two different reasons, only one of which was objectui's.

The second reason has expired. `PublishMetaItemResponseSchema` now declares the same optional, omitted-when-empty `advisories` key that `SaveMetaItemResponseSchema` has carried since #4717, and `publishMetaItem` populates it. Measured against the installed `@objectstack/spec` (17.2.0) rather than inferred from the version number: the key survives a `safeParse`, a half-shaped finding is rejected, and a clean publish omits the key entirely. That reading is now a test rather than a note, so a spec drift fails CI instead of silently re-muting the door.

`MetadataClient.publish` and `MetadataClient.publishDraft` — the two methods over the single-item publish route `POST /meta/:type/:name/publish` — now report through the **same** sink, the same event and the same renderer the save door already used. No new UI shape: same warning tier, same 10s duration, same per-finding `rule` + `message` + `hint` formatting, findings still rendered verbatim as server prose. The wiring lands in the data layer rather than at the call sites, so `ResourceEditPage`'s Publish button and the runtime `RuntimeDraftBar` promotion (ObjectView / ReportView / DashboardView) are covered by one change, as are future ones.

One thing had to differ, and it is the frame's verb. Save and Publish are two different buttons in this product, so a toast that says "Saved" after a Publish tells the author their change is still a draft — the opposite of what happened. `MetadataSaveAdvisoryEvent` therefore gains a required `door: 'save' | 'publish'` and the renderer picks `console.publishAdvisoryTitle` (added to all ten locale packs) accordingly. `door` exists because `mode` cannot answer this: a direct active save and a draft promotion both report `mode: 'publish'`, since both land the body in the active overlay. It is required rather than optional so a future third door cannot be wired without saying which one it is, and the renderer branches on it through an exhaustive switch with a `never` check, so adding a third member is a compile error rather than a silently wrong verb.

**BREAKING for event constructors — `MetadataSaveAdvisoryEvent.door` is required.** Reading the event is unaffected: a listener that ignores `door` behaves exactly as before, and every other member is unchanged. Constructing one is a compile break — a door-less event literal that type-checked before now fails with TS2741, `Property 'door' is missing`. Measured on the emitted `dist/index.d.ts` of `@object-ui/data-objectstack` on both sides: that single required member is the entire non-comment delta of the package's published surface. **Migration:** add `door: 'save'` or `door: 'publish'` to the literal, whichever write it models — `'save'` for `PUT /meta/:type/:name`, `'publish'` for `POST /meta/:type/:name/publish`. Scored `minor` rather than `major` per the repo's version policy: objectui's major is pinned to `@objectstack`'s so that "same major means compatible" holds across the two repos, so objectui's own breaking changes ship as `minor` with the break named here (`scripts/check-changeset-no-major.mjs`). Every publishable package sits in one `fixed` group, so this entry carries the group.

Unchanged, deliberately: the **batch** door. "Publish whole app" (`POST /packages/:id/publish-drafts`) still discards per-draft advisories server-side — objectstack#9343, open and unruled — and nothing here compensates for that from the client side. A test pins the absence, so a later traversal of a batch-shaped `published[]` cannot be added without turning it red.
107 changes: 107 additions & 0 deletions packages/app-shell/src/providers/saveAdvisoryToast.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,7 @@ function event(overrides: Partial<MetadataSaveAdvisoryEvent> = {}): MetadataSave
return {
type: 'flow',
name: 'nightly_purge',
door: 'save',
mode: 'publish',
advisories: [FINDING],
...overrides,
Expand DownExpand Up@@ -160,3 +161,109 @@ describe('emitSaveAdvisories (#4133)', () => {
expect(description).toContain(FINDING.message);
});
});

/**
* The publish door renders through this SAME function (objectui#5026) — same
* warning tier, same duration, same per-finding formatting. One source more,
* not one surface more. What must differ is the frame's verb, because in this
* product Save and Publish are two different buttons: a toast that says "Saved"
* after a Publish tells the author their change is still a draft, which is the
* opposite of what happened.
*/
describe('emitSaveAdvisories — the publish door (#5026)', () => {
const published = () => event({ door: 'publish' });

it('says "Published", not "Saved", when the findings came through the publish door', () => {
const sink = makeSink();

emitSaveAdvisories(published(), t, sink);

const [title] = sink.warning.mock.calls[0]!;
expect(title).toContain('Published');
expect(title).not.toContain('Saved');
});

it('keeps saying "Saved" for the save door — the existing wording is untouched', () => {
const sink = makeSink();

emitSaveAdvisories(event({ door: 'save' }), t, sink);

expect(sink.warning.mock.calls[0]![0]).toContain('Saved');
});

it('reads the DOOR, not the mode — both doors report `mode: "publish"`', () => {
// The discriminating case: a direct active save also carries
// `mode: 'publish'`, so a renderer that branched on `mode` would call it a
// publish. Same mode on both events here; only `door` differs.
const saveSink = makeSink();
const publishSink = makeSink();

emitSaveAdvisories(event({ door: 'save', mode: 'publish' }), t, saveSink);
emitSaveAdvisories(event({ door: 'publish', mode: 'publish' }), t, publishSink);

expect(saveSink.warning.mock.calls[0]![0]).toContain('Saved');
expect(publishSink.warning.mock.calls[0]![0]).toContain('Published');
});

it('is the same surface otherwise — warning tier, same body, same duration', () => {
const sink = makeSink();

emitSaveAdvisories(published(), t, sink);

expect(sink.warning).toHaveBeenCalledTimes(1);
expect(sink.error).not.toHaveBeenCalled();
const [, opts] = sink.warning.mock.calls[0]!;
expect(opts!.description).toContain(FINDING.message);
expect(opts!.description).toContain(FINDING.hint);
expect(opts!.duration).toBeGreaterThanOrEqual(10_000);
});

it('says nothing on a clean publish', () => {
const sink = makeSink();

emitSaveAdvisories(event({ door: 'publish', advisories: [] }), t, sink);

expect(sink.warning).not.toHaveBeenCalled();
});
});

/**
* The exhaustiveness guarantee (#5026, contract-review condition 2).
*
* `door` being REQUIRED buys "every type-checked constructor must state a
* door". It does NOT by itself buy "the renderer handles the door it was
* given" — with a two-way ternary, a third union member would compile at its
* constructor, declare itself honestly, and still silently render "Saved",
* which is the exact class `door` exists to kill, reintroduced one level up.
*
* The compile-time half of the fix is the `never` check in `advisoryTitle`,
* enforced by `tsc` and not expressible here. What IS pinned here is its
* runtime consequence, which is what an untyped consumer would hit: an
* unhandled door must NOT come out wearing the save wording.
*/
describe('emitSaveAdvisories — the door union is handled exhaustively', () => {
it('refuses an unhandled door instead of silently calling it "Saved"', () => {
const sink = makeSink();
// An untyped consumer's event. The cast is the point: inside the type
// system this is unreachable, which is what the `never` check enforces.
const rogue = event({ door: 'rollback' as unknown as MetadataSaveAdvisoryEvent['door'] });

expect(() => emitSaveAdvisories(rogue, t, sink)).toThrow(/advisory door/);

// The load-bearing assertion: nothing was rendered. A wrong verb about a
// write that already touched the author's data is worse than no toast,
// and both emitters swallow this throw, so "no toast" is what ships.
expect(sink.warning).not.toHaveBeenCalled();
expect(sink.error).not.toHaveBeenCalled();
});

it('still handles every door the union actually declares', () => {
// The control for the case above: the refusal must be specific to an
// unhandled member, not a renderer that throws at everything.
for (const door of ['save', 'publish'] as const) {
const sink = makeSink();
emitSaveAdvisories(event({ door }), t, sink);
expect(sink.warning).toHaveBeenCalledTimes(1);
}
});
});
65 changes: 58 additions & 7 deletions packages/app-shell/src/providers/saveAdvisoryToast.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,12 +78,66 @@ function formatFinding(f: MetadataSaveAdvisoryEvent['advisories'][number]): stri
}

/**
* Announce the gate's advisory findings for a save that SUCCEEDED.
* The frame's verb, chosen by the door the write came through.
*
* An exhaustive `switch` with a `never` check rather than a two-way ternary,
* and the difference is the whole point of the field. A ternary answers
* "is it publish, else save" — so a THIRD door added to the union would
* compile everywhere, declare itself honestly at its constructor, and still
* silently render "Saved". That is precisely the silent-wrong-verb class
* `door` exists to kill, reintroduced one level up. Here a new member makes
* this function a compile error instead, which is the only form of the
* guarantee worth having: the type must not merely be STATED, it must be
* HANDLED.
*
* The `default` branch is unreachable for type-checked callers — it exists
* for an untyped one (the event type is published, and JS consumers are not
* bound by it). It throws rather than falling back to the save wording,
* because both emitters wrap the sink in a try/catch that swallows: the
* failure mode is therefore "no toast", never "a toast that says the wrong
* thing about what just happened to the author's data".
*/
function advisoryTitle(ev: MetadataSaveAdvisoryEvent, t: TranslateFn): string {
const count = ev.advisories.length;
switch (ev.door) {
case 'save':
return t('console.saveAdvisoryTitle', {
count,
defaultValue: 'Saved — the authoring check raised {{count}} advisory finding(s)',
});
case 'publish':
return t('console.publishAdvisoryTitle', {
count,
defaultValue: 'Published — the authoring check raised {{count}} advisory finding(s)',
});
default: {
const unhandled: never = ev.door;
throw new Error(
`saveAdvisoryToast: no title for advisory door ${JSON.stringify(unhandled)}`,
);
}
}
}

/**
* Announce the gate's advisory findings for a metadata write that SUCCEEDED.
*
* Says nothing when there is nothing to say: the server omits `advisories`
* entirely on a clean save, so the common case never reaches here, and an event
* that somehow carried an empty list is dropped rather than toasted as
* entirely on a clean write, so the common case never reaches here, and an
* event that somehow carried an empty list is dropped rather than toasted as
* "0 findings".
*
* ## One renderer, two doors (#5026)
*
* The publish door reports through this same function, the same warning tier,
* the same duration and the same per-finding formatting — a second SOURCE, not
* a second surface. Only the frame's verb changes, and it has to: Save and
* Publish are two different buttons in this product, so "Saved" after a Publish
* would tell the author their change is still a draft. `ev.door` is what says
* which one, because `ev.mode` cannot — a direct active save and a draft
* promotion both report `mode: 'publish'`. The choice is an exhaustive switch,
* not a two-way test: see {@link advisoryTitle} for why that distinction is
* the field's actual guarantee.
*/
export function emitSaveAdvisories(
ev: MetadataSaveAdvisoryEvent,
Expand All@@ -93,10 +147,7 @@ export function emitSaveAdvisories(
if (!ev.advisories || ev.advisories.length === 0) return;

sink.warning(
t('console.saveAdvisoryTitle', {
count: ev.advisories.length,
defaultValue: 'Saved — the authoring check raised {{count}} advisory finding(s)',
}),
advisoryTitle(ev, t),
{
description: ev.advisories.map(formatFinding).join('\n'),
duration: ADVISORY_TOAST_MS,
Expand Down
6 changes: 6 additions & 0 deletions packages/data-objectstack/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2750,6 +2750,12 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
this.emitSaveAdvisory({
type,
name,
// #5026 — this interceptor wraps `meta.saveItem`, the SAVE door
// (`PUT /meta/:type/:name`) and only that one. The SDK's publish
// door (`meta.publishItem`) has no caller in this repo, so wiring
// it here would be a surface with no consumer; `MetadataClient` is
// where the publish door is actually taken.
door: 'save',
mode: (result as { state?: string } | null | undefined)?.state === 'draft' ? 'draft' : 'publish',
advisories,
});
Expand Down
Loading
Loading