diff --git a/.changeset/duelike-declared-on-detail-view-field-9738.md b/.changeset/duelike-declared-on-detail-view-field-9738.md new file mode 100644 index 0000000000..4ae0cc2bdf --- /dev/null +++ b/.changeset/duelike-declared-on-detail-view-field-9738.md @@ -0,0 +1,17 @@ +--- +'@object-ui/types': minor +--- + +`DetailViewField` declares `dueLike?: boolean` — the published TypeScript twin now accepts the key its own validator already judged and its own renderer already honours (objectui#9738). + +Three of the four faces already agreed. `views.zod.ts#DetailViewFieldSchema` validated `dueLike` and kept it; `DetailSection` spreads the authored field into the bag `enrichDetailField` returns and hands it to the resolved cell renderer, whose `resolveDueLike` reads the key; the `date` and `datetime` documentation pages teach it. Only the twin disagreed — and because `DetailViewField` carries no index signature, it disagreed loudly: the same document the validator accepted was refused by the compiler. + + base (edbcf1e7aa) const f: DetailViewField = { name: 'end_date', dueLike: true } + -> error TS2353: 'dueLike' does not exist in type 'DetailViewField' + head -> compiles + +So an author following the docs and the validator got a red build, and an author following the type had no way to reach a shipped capability. Maintainer ruling, letter A: declare it, so the meaning of the key is written once, in the declaration. + +`Clause-②: yes` — the published type's accept set widens by exactly one key. Measured by parsing the interface, comments stripped by construction: 13 members at base, 14 at head, the difference being `dueLike`; the same instrument reads `currency` on both legs, so the base zero is a measured absence rather than a silent instrument. + +Nothing else moves. The zod mirror is untouched — it already declared the key, with the `describe` text this declaration's docblock restates. The two pins move WITH the measurement rather than being repaired: the `@ts-expect-error` that recorded the refusal is replaced by the assignment it guarded plus an exact `Equal` that separates a declared member from one absorbed by an index signature, and the `MirroredUndeclared` ledger row for this pair is deleted, which is the only direction that ratchet may move in. diff --git a/packages/types/src/__tests__/mirror-only-published-keys-9729.test.ts b/packages/types/src/__tests__/mirror-only-published-keys-9729.test.ts index a05761c545..c84397a8d5 100644 --- a/packages/types/src/__tests__/mirror-only-published-keys-9729.test.ts +++ b/packages/types/src/__tests__/mirror-only-published-keys-9729.test.ts @@ -24,27 +24,42 @@ * assertion below is a statement about `origin/main` as it stands; whichever * direction the review rules, the entries here move with it. * - * ⭐ ONE OF THE TWO HAS SINCE BEEN RULED, and this file moved with it exactly as - * the paragraph above promised. objectui#9739 (maintainer, 2026-09-18) took - * letter C on `operators`: removed from the published mirror, a tombstone left - * in its place that refuses the key by name and names the spelling the upstream - * protocol declares. So the `operators` section below no longer pins a SILENT - * ACCEPTANCE — it pins the LOUD REFUSAL that replaced it, and the lit control - * beside it is what keeps the assertion honest in its new direction. The full - * per-half measurement, including the upstream re-derivation of the spelling the - * message names, lives in `object-grid-operators-tombstone-9739.test.ts`; what - * stays here is the BEFORE/AFTER continuity, so the defect this card measured - * cannot quietly return under the same name. `dueLike` is ⛔ untouched and still - * awaits its own ruling. + * ⭐ BOTH KEYS HAVE NOW BEEN RULED, and this file moved with each of them exactly + * as the paragraph above promised — in OPPOSITE directions, which is the thing to + * read here before assuming one ruling generalises to the other. + * + * - `operators` — objectui#9739 (maintainer, 2026-09-18) took letter C: + * removed from the published mirror, a tombstone left in its place that + * refuses the key by name and names the spelling the upstream protocol + * declares. So the `operators` section below no longer pins a SILENT + * ACCEPTANCE — it pins the LOUD REFUSAL that replaced it, and the lit + * control beside it is what keeps the assertion honest in its new + * direction. The full per-half measurement, including the upstream + * re-derivation of the spelling the message names, lives in + * `object-grid-operators-tombstone-9739.test.ts`. + * - `dueLike` — objectui#9738 (maintainer, 2026-09-18) took letter A: the + * key is DECLARED on the published TypeScript twin, so the validator, the + * type checker, the renderer and the docs now say the same thing. The + * mirror is ⛔ untouched — it already stated the key — so the section below + * that measures the mirror is unchanged, and it is the TypeScript half at + * the bottom of this file that inverted. + * + * What stays, in both cases, is the BEFORE/AFTER continuity, so neither defect + * this card measured can quietly return under the same name. * * ## The two keys do NOT behave the same way, and the difference is the ruling * * Both twins are hand-written interfaces, but only one of them can REFUSE: * - * - `DetailViewField` carries no index signature, so TypeScript rejects an + * - `DetailViewField` carried no index signature, so TypeScript rejected an * authored `dueLike` outright (`TS2353`). The two published faces therefore - * actively CONTRADICT each other: the validator judges the key and keeps - * it, the compiler refuses the same document. + * actively CONTRADICTED each other: the validator judged the key and kept + * it, the compiler refused the same document. ⭐ That was the loud defect, + * and it was ruled letter A (objectui#9738): the twin DECLARES the key now, + * so the accept set of the published TypeScript face widened by exactly one + * key and the contradiction is closed in the direction that keeps the + * renderer's behaviour. It still carries no index signature — which is what + * keeps the assignment at the bottom of this file a real assertion. * - `ObjectGridSchema` extends `BaseSchema`, whose `[key: string]: any` index * signature absorbs any unstated key. TypeScript neither declares nor * refuses `operators`; it types it `any`. The faces did not contradict — @@ -181,18 +196,55 @@ describe('complex.zod.ts#DashboardConfigSchema — `aria` is the CONTRAST, not t /* ── The TypeScript side, where the two keys stop behaving alike ───────────── */ /** - * `DetailViewField` REFUSES `dueLike`. The `@ts-expect-error` below IS the - * assertion: it fails the package's `tsc -p tsconfig.test.json` leg in both - * directions — if the key stops being refused (someone declares it on the twin, - * which is one of the two remedies under review) the directive becomes unused - * and TypeScript reports it. + * `DetailViewField` ACCEPTS `dueLike`. This binding IS the assertion, and it is + * read by the package's `tsc -p tsconfig.test.json` leg. + * + * ⭐ THE DIRECTION OF THIS ASSERTION IS INVERTED FROM WHAT THIS CARD MEASURED, + * deliberately and by ruling — ⛔ not relaxed, and ⛔ not deleted, to keep a leg + * green. What stood here was a `@ts-expect-error` recording the REFUSAL, written + * so that it would fail in BOTH directions; objectui#9738 letter A declared the + * key on the twin, which is precisely the direction that turns such a directive + * unused (`TS2578`, measured on this file). So the measurement moves WITH the + * declaration, onto the same literal, and stays discriminating in its new + * direction: the twin losing the member again makes this line `TS2353` on the + * same leg. + * + * ⚠️ The assignment alone would not be enough, and the second assertion below is + * why. An index signature added to `DetailViewField` would let this literal + * compile while `dueLike` is undeclared — exactly the shape `ObjectGridSchema` + * has further up this file, where an authored key is absorbed as `any` and + * nothing contradicts it. The exact `Equal` separates DECLARED from ABSORBED. */ -// @ts-expect-error objectui#9729 — `dueLike` is not a member of `DetailViewField`. export const authoredDueLike: DetailViewField = { name: 'end_date', dueLike: true }; /** LIT CONTROL: a key the twin DOES declare is accepted on the same literal. */ export const authoredCurrency: DetailViewField = { name: 'end_date', currency: 'USD' }; +/** + * Invariant equality — `extends` both ways would accept a narrowing. Spelled + * here rather than imported from `zod-mirror-parity.test.ts`, which exports the + * same two lines: no test file in this package imports another, and a two-line + * helper beside its single use is cheaper than establishing that it may. + */ +type Equal< A, B > = + (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; +type Expect< T extends true > = T; + +/** + * The member is DECLARED with a real type, not absorbed by an index signature + * and not widened to `any` — `boolean | undefined`, exactly. + * + * ⛔ `extends` is not an acceptable weakening here: `any` and a `boolean`-typed + * index signature both satisfy an `extends` written in either direction, and + * those are the two ways this declaration could be present in a listing while + * being absent as a contract. The mirror's own judgement of the same key — that + * `dueLike: 'yes'` is refused BY NAME — is pinned at the top of this file, so + * the two published faces are asserted to agree on the TYPE as well as on the + * name. + */ +export type assertionDueLikeIsDeclaredOnTheTwin = + Expect< Equal< DetailViewField['dueLike'], boolean | undefined > >; + /** * The TWIN still does NOT refuse `operators` — and the absence of a * `@ts-expect-error` here is the measurement, not an omission: adding one diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index ab9ff6299a..11e4c55a5b 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -3464,11 +3464,17 @@ export const assertionWiderLedgerRecordsEveryKey: never = 0 as unknown as WiderL * Exact MIRRORED-BUT-UNDECLARED key set per pair — the seed of the direction * objectui#9711 opened, measured on the tree this ledger landed on. * - * `this ledger seeds **8 entries** carrying **102 keys**` — and ⛔ read that off the + * `this ledger seeds **7 entries** carrying **101 keys**` — and ⛔ read that off the * census at the bottom of this file, not off this sentence: both figures are pinned * to this ledger's own AST by 'the fourth direction is enumerated and sized at test * time', so an entry or an arm added here moves them or the file reddens. * + * ⭐ Both figures moved DOWN by one when objectui#9738 retired the + * `views.zod.ts#DetailViewFieldSchema` row — the ratchet's only permitted + * direction, and the first time this seed has shrunk. The comment left where that + * row stood says which ruling took it and why the neighbouring `operators` row + * stayed after a ruling of its own. + * * ## ⛔ What this ledger is NOT * * ⛔ It is NOT a worklist to be drained by DECLARING the keys it names. Declaring a @@ -3699,28 +3705,23 @@ interface MirroredUndeclared { "objectql.zod.ts#ObjectGridSchema": | "operators"; - /** - * LOCAL, one key. An author writing `dueLike` on a detail-view field gets a green - * `safeParse` from a published validator for a key the published `DetailViewField` - * never invited — and, because that twin carries no index signature, the SAME - * document is refused by the compiler (`TS2353`). The two published faces - * contradict each other rather than one of them being merely quiet. - * - * ⛔ This entry once justified itself with "a `git grep` finds it in the mirror and - * nowhere in this package's TypeScript declarations", which is FALSE as written and - * is corrected here rather than repeated: `field-types.ts` declares `dueLike` on - * `DateFieldMetadata` and on `DateTimeFieldMetadata`, both in this package. ⭐ A - * NAME is not a KEY — a key is (interface, name) — and a package-wide grep for the - * name answers a question this ledger is not asking. What the operator measures, - * and what stays true, is that the key is absent from THIS PAIR'S twin. - * - * The per-key consequences — that the mirror judges the key rather than merely - * passing it, and that objectui's own detail-view path READS it at runtime — are - * measured by `mirror-only-published-keys-9729.test.ts` and by the two probes - * objectui#9729 landed beside the renderers that read it. ⛔ No remedy is implied. - */ - "views.zod.ts#DetailViewFieldSchema": - | "dueLike"; + // `views.zod.ts#DetailViewFieldSchema` HAD a row here, for `dueLike`, and its + // DELETION is the ratchet moving in the only direction this seed may move in + // — ⛔ not an entry lost in an edit. objectui#9738 (maintainer, 2026-09-18, + // letter A) declared `dueLike?: boolean` on the `DetailViewField` twin, so the + // mirror no longer states a key the twin does not and the pair measures + // `never`. Re-adding the row without re-adding the defect reddens the + // reconciliation below, which is the guard against exactly that. + // + // ⚠️ Read this beside the `objectql.zod.ts#ObjectGridSchema` row above, which + // STAYED after its own ruling: a retirement tombstone is still a stated + // member, a declaration on the twin is not. The two rulings landed the same + // day and moved this ledger in opposite directions. + // + // The per-key consequences — that the mirror judges the key rather than merely + // passing it, and that objectui's own detail-view path READS it at runtime — + // are measured by `mirror-only-published-keys-9729.test.ts` and by the two + // probes objectui#9729 landed beside the renderers that read it. } /** diff --git a/packages/types/src/views.ts b/packages/types/src/views.ts index 5588b7825e..7ca7f743b4 100644 --- a/packages/types/src/views.ts +++ b/packages/types/src/views.ts @@ -207,6 +207,60 @@ export interface DetailViewField { * Currency code for currency fields (e.g. 'USD', 'EUR') */ currency?: string; + /** + * Marks a `date` / `datetime` field as due/deadline-semantic (vs. a plain + * start/end/created date). It is the same key, with the same meaning, as + * `DateFieldMetadata.dueLike` and `DateTimeFieldMetadata.dueLike` in + * `./field-types.ts` — those two are OBJECT metadata; this one is the + * AUTHORED detail-view field, which is a different key of the same name. + * + * ## What it does to the rendered cell + * + * Both halves of the overdue affordance, on the `date` and the `datetime` + * cell alike: + * + * - **wording** — inside the relative face, a past due date reads + * `Overdue Nd` instead of the neutral `N days ago`. `formatRelativeDate` + * (`@object-ui/core`) gates that phrase on this key; the phrase has no + * `Intl` equivalent, so it is also the only route by which that function + * reaches the translate fn. + * - **styling** — the cell's span gains `text-red-600` once the deadline + * day has passed, whichever display face it is painting + * (`isOverdueInstant` in `@object-ui/fields`). + * + * ⚠️ The two halves have different thresholds, inherited from the shared + * relative-time path and ⛔ not re-decided here: the red styling starts the + * day after the deadline, while the `Overdue Nd` wording starts the day after + * that (`formatRelativeDate` gates its phrase on a difference of more than + * one calendar day, so `Overdue 2d` is the shortest phrase this codebase + * produces). Beyond a week the relative face falls back to an absolute date + * and the wording stops; the styling does not. + * + * ## Why it is declared HERE, on the detail-view field + * + * Because the renderer already honours it here, on the AUTHORED field of a + * detail view — not only on object metadata. `DetailSection` + * (`@object-ui/plugin-detail`) spreads the authored field into the bag + * `enrichDetailField` returns and hands that bag to the resolved cell + * renderer, whose `resolveDueLike` reads this key first and falls back to the + * due/deadline field-NAME convention only when it is not `true`. That read is + * measured by rendering rather than by grep, in that package's + * `DetailSection.dueLikeReachesTheCell-9729.test.tsx`, which draws the same + * `end_date` field with and without the key and watches the drawn wording + * change. + * + * Until objectui#9738 this interface was the one published face that refused + * the key (`TS2353` — it carries no index signature) while + * `DetailViewFieldSchema` in `./zod/views.zod.ts` validated it and the + * renderer honoured it. The declaration is that contradiction's remedy + * (maintainer ruling, letter A), ⛔ not a new capability: the obligation was + * already on the books, with a reader and two documentation pages. + * + * ⚠️ Omitting the key is NOT the same as writing `false`. Absent, the + * field-NAME convention can still turn the affordance on; `false` does not + * suppress that fallback either — only a neutral field name does. + */ + dueLike?: boolean; } /**