diff --git a/.changeset/7924-density-mode-through-fold.md b/.changeset/7924-density-mode-through-fold.md new file mode 100644 index 0000000000..b381f1d109 --- /dev/null +++ b/.changeset/7924-density-mode-through-fold.md @@ -0,0 +1,41 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-view': minor +--- + +**BREAKING** — `NamedListView.densityMode` is retired on the TypeScript authoring face +(objectui#7924, director-seat ruling **A′**). It is now a `?: never` tombstone: a +TypeScript author who writes it gets a compile error at that key, and its docblock names +top-level `rowHeight` as the key to write instead. The protocol names row density +`rowHeight`; `densityMode` was objectui's legacy spelling of it. ⚠️ Not +`userActions.rowHeight`, which is the boolean toggle for the toolbar's density control. + +```ts +// before +const dense: NamedListView = { label: 'Dense', densityMode: 'compact' }; +// after +const dense: NamedListView = { label: 'Dense', rowHeight: 'compact' }; +``` + +This supersedes the earlier objectui#7924 entry in this release that listed `densityMode` +as retained because it was read. Its only reader outside the runtime fold was the relay +that carried it by name, and both relays now read the density through the fold. + +**Both relays of a named view read the density through `normalizeListViewSchema`.** The +`renderListView` composition in `@object-ui/plugin-view`'s `ObjectView` and the Console +object page (`@object-ui/app-shell`) each hand the active view to the fold and relay its +`rowHeight`; neither names `densityMode` any more. A stored view carrying +`densityMode: 'compact'` renders the same density as before. ⚠️ A third-party host that +passes `renderListView` and read `schema.densityMode` off its argument now receives the +same density as `schema.rowHeight` (mapped through `DENSITY_MODE_TO_ROW_HEIGHT`) and no +`densityMode` key. + +`allowExport` stays declared on `NamedListView`; its docblock now names both relays that +carry it. The `@object-ui/react` `useDensityMode` JSDoc example reads and persists +`rowHeight` (through `rowHeightToDensityMode` / `DENSITY_MODE_TO_ROW_HEIGHT`) instead of +`densityMode`; that is a documentation fix with no behaviour change. + +**ADR-0087 disposition:** `densityMode` is a **D2** conversion. Stored documents are +accepted and converted at load by the existing runtime fold (`normalizeListViewSchema` +maps it onto `rowHeight`), and the TypeScript face refuses the old spelling at authoring +time. diff --git a/packages/app-shell/src/views/ObjectView.densityModeThroughFold-7924.test.tsx b/packages/app-shell/src/views/ObjectView.densityModeThroughFold-7924.test.tsx new file mode 100644 index 0000000000..a8d5f0a141 --- /dev/null +++ b/packages/app-shell/src/views/ObjectView.densityModeThroughFold-7924.test.tsx @@ -0,0 +1,253 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#7924, ruling A′ — `densityMode` is retired on `NamedListView`, and + * BOTH relays of a named view read the density THROUGH the fold. + * + * ## The chain this pins, end to end + * + * A stored view's density reaches `ListView` through two hops and one terminal + * consumer: `objectDef.listViews` → `buildViewTabs` → `mergedViews` → the + * `renderListView` composition in `plugin-view`'s `ObjectView` (hop 1) → this + * package's object-page relay over that composition (hop 2) → `ListView`, which + * runs `normalizeListViewSchema` before it reads anything. Before the ruling, + * both hops relayed `densityMode` BY NAME and only the fold turned it into + * `rowHeight`. After it, each hop hands its view to `normalizeListViewSchema` + * and relays the result's `rowHeight`; the fold is the one place left that + * names the retired key. + * + * ⭐ So this file mounts the REAL `plugin-view` `ObjectView` under the REAL + * object page, and stubs only `ListView` to capture the schema it is handed. + * Mocking `plugin-view` (what the sibling relay pins do) would stand in for + * hop 1 and leave half the chain unmeasured. The plugin-view host is pinned on + * its own as well, through a spy `renderListView`, because a third-party host + * receives exactly that composition. + * + * ## What "behaves identically" means here, and how it is measured + * + * The renderer's density is `normalizeListViewSchema(schema).rowHeight` — the + * value `ListView` reads after its own fold. That is computed from the captured + * schema below, so the EFFECTIVE density is compared, not the key it travelled + * under. Stored views must render the same density as before the ruling; what + * changes is only that the retired key is no longer handed down by name. + * + * ## Direction, written before the reverse-verification run + * + * Restoring the two by-name hop lines on top of this change is PREDICTED to + * turn exactly the cases titled `THE CHANGE` red (a `densityMode` key arrives, + * and the pre-fold `rowHeight` is absent) and to leave every `SAME DENSITY` and + * `CONTROL` case green — the fold inside `ListView` answered the same density + * on either shape, which is the whole claim that stored views are unaffected. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { normalizeListViewSchema } from '@object-ui/core'; + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + usePermissions: () => ({ + check: () => ({ allowed: true }), + checkField: () => true, + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + isLoaded: false, + hasCapabilities: () => true, + can: () => true, + cannot: () => false, + }), + useFieldPermissions: () => ({ canRead: () => true, canWrite: () => true, permissions: [] }), + }; +}); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada' }, activeOrganization: null }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRealtimeSubscription: () => ({ lastMessage: null }), + useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }), +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); + +/** The list schema the object page hands down — captured, not rendered. */ +let captured: any = null; +vi.mock('@object-ui/plugin-list', async (importOriginal) => ({ + ...(await importOriginal()), + ListView: (props: any) => { + captured = props.schema; + return null; + }, +})); + +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); +vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null })); + +import { ObjectView } from './ObjectView'; +import { ObjectView as PluginObjectView } from '@object-ui/plugin-view'; +import { ExpressionProvider } from '../providers/ExpressionProvider'; + +const OBJECT_NAME = 'duly_task'; + +function objectsWith(view: Record) { + return [ + { + name: OBJECT_NAME, + label: 'Task', + fields: { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + }, + listViews: { + dense: { label: 'Dense', type: 'grid', columns: ['name'], ...view }, + }, + }, + ]; +} + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: [], total: 0 })), + findOne: vi.fn(async () => null), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + } as any; +} + +/** HOST 2 — the object page over the real plugin-view: the schema `ListView` receives. */ +async function objectPageSchema(view: Record): Promise { + captured = null; + render( + + + + {}} />} + /> + + + , + ); + // `options` is written by the same relay literal unconditionally, so its + // arrival is the signal the relay ran; waiting on the density itself would + // hang rather than fail on a regression. + await waitFor(() => { + expect(captured?.options).toBeTruthy(); + }); + return captured; +} + +/** HOST 1 — plugin-view's `renderListView` composition, as a third-party host receives it. */ +function pluginViewSchema(view: Record): any { + let handed: any = null; + const renderListView = vi.fn(({ schema }: any) => { + handed = schema; + return null; + }); + render( + , + ); + expect(renderListView).toHaveBeenCalled(); + return handed; +} + +/** The density `ListView` renders: its own fold over what it was handed. */ +const effectiveRowHeight = (schema: any): unknown => + (normalizeListViewSchema(schema) as { rowHeight?: unknown }).rowHeight; + +beforeEach(() => { + cleanup(); + captured = null; + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe('objectui#7924 ruling A′ — the plugin-view host reads the density through the fold', () => { + it('SAME DENSITY: a stored `densityMode: compact` view renders `compact`, as before the ruling', () => { + expect(effectiveRowHeight(pluginViewSchema({ densityMode: 'compact' }))).toBe('compact'); + }); + + it('THE CHANGE: the composition hands down `rowHeight`, and never the retired key by name', () => { + const schema = pluginViewSchema({ densityMode: 'spacious' }); + expect(schema.rowHeight).toBe('tall'); + expect(schema).not.toHaveProperty('densityMode'); + }); + + it('SAME DENSITY: an authored `rowHeight` still wins over a legacy `densityMode` on the same view', () => { + const schema = pluginViewSchema({ rowHeight: 'extra_tall', densityMode: 'compact' }); + expect(schema.rowHeight).toBe('extra_tall'); + expect(effectiveRowHeight(schema)).toBe('extra_tall'); + }); + + it('CONTROL: a view with no density hands down none, and an off-vocabulary `densityMode` is not coerced', () => { + expect(effectiveRowHeight(pluginViewSchema({}))).toBeUndefined(); + expect(effectiveRowHeight(pluginViewSchema({ densityMode: 'cozy' }))).toBeUndefined(); + }); +}); + +describe('objectui#7924 ruling A′ — the object page, over the real plugin-view, reads the density through the fold', () => { + it('SAME DENSITY: a stored `densityMode: compact` view renders `compact`, as before the ruling', async () => { + expect(effectiveRowHeight(await objectPageSchema({ densityMode: 'compact' }))).toBe('compact'); + }); + + it('THE CHANGE: the relay hands `ListView` `rowHeight`, and never the retired key by name', async () => { + const schema = await objectPageSchema({ densityMode: 'spacious' }); + expect(schema.rowHeight).toBe('tall'); + expect(schema.densityMode).toBeUndefined(); + }); + + it('SAME DENSITY: an authored `rowHeight` still wins over a legacy `densityMode` on the same view', async () => { + expect(effectiveRowHeight(await objectPageSchema({ rowHeight: 'short', densityMode: 'spacious' }))).toBe('short'); + }); + + it('CONTROL: a canonical `rowHeight` view is relayed unchanged, and no density stays absent', async () => { + expect((await objectPageSchema({ rowHeight: 'medium' })).rowHeight).toBe('medium'); + cleanup(); + expect(effectiveRowHeight(await objectPageSchema({}))).toBeUndefined(); + }); +}); diff --git a/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts b/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts index 877619253c..2856c76068 100644 --- a/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts +++ b/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts @@ -461,7 +461,8 @@ const ABSENCES: Record = { showHideFields: { kind: 'legacy-alias', canonical: 'userActions', mentions: 'normalizeListViewSchema', reason: 'Legacy toolbar flag, folded into `userActions` by the same rung.' }, showGroup: { kind: 'legacy-alias', canonical: 'userActions', mentions: 'normalizeListViewSchema', reason: 'Legacy toolbar flag, folded into `userActions` by the same rung.' }, showColor: { kind: 'legacy-alias', canonical: 'userActions', mentions: 'normalizeListViewSchema', reason: 'Legacy toolbar flag, folded into `userActions` by the same rung.' }, - showDensity: { kind: 'legacy-alias', canonical: 'userActions', mentions: 'normalizeListViewSchema', reason: 'Legacy toolbar flag, folded into `userActions` by the same rung; the density VALUE has its own `densityMode` / `rowHeight` rungs.' }, + showDensity: { kind: 'legacy-alias', canonical: 'userActions', mentions: 'normalizeListViewSchema', reason: 'Legacy toolbar flag, folded into `userActions` by the same rung; the density VALUE has its own `rowHeight` rung, which reads through the same fold.' }, + densityMode: { kind: 'legacy-alias', canonical: 'rowHeight', mentions: 'normalizeListViewSchema', reason: "Legacy spelling of the density VALUE, retired on `NamedListView` (objectui#7924, ruling A′). The `rowHeight` rung runs the view through `normalizeListViewSchema` and relays its `rowHeight`, so a stored view's `densityMode` arrives folded and this relay never names the retired key." }, // ── Declared on the type, consumed by nobody on this path ───────────────── // A rung for one of these would relay a value into a void, and the void is diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index ff273eb501..8ed20555b9 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -2712,9 +2712,14 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an * Same two-rung shape as `label` above. */ description: viewDef.description ?? listSchema.description, - // Propagate appearance/view-config properties for live preview - rowHeight: viewDef.rowHeight ?? listSchema.rowHeight, - densityMode: viewDef.densityMode ?? listSchema.densityMode, + // Propagate appearance/view-config properties for live preview. + // objectui#7924 (ruling A′): the view's density is read THROUGH the + // fold, view over list, the same route as the `userActions` rung + // below. A stored view that still spells it `densityMode` arrives + // here as `rowHeight`, and this relay never names the retired key. + rowHeight: + (normalizeListViewSchema(viewDef ?? {}) as Pick).rowHeight + ?? listSchema.rowHeight, // Hydrate the persisted view settings so they survive reload // (Airtable-style toolbar config — objectstack#7494's ruling: // ORG-WIDE shared, not a per-user preference; a true per-user diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index a5da47e324..1265d0801f 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -2396,9 +2396,11 @@ export const ObjectView: React.FC = ({ || schema.table?.columns || schema.table?.fields, filter: mergedFilters, sort: mergedSort, - // Propagate appearance/view-config properties for live preview - rowHeight: activeView?.rowHeight, - densityMode: activeView?.densityMode, + // Propagate appearance/view-config properties for live preview. + // objectui#7924 (ruling A′): the density is read THROUGH the fold, + // so a stored view that still spells it `densityMode` is handed + // down as `rowHeight`, and the retired key is never relayed by name. + rowHeight: (normalizeListViewSchema(activeView ?? {}) as { rowHeight?: string }).rowHeight, groupBy: activeView?.groupBy, groupBy2: activeView?.groupBy2, // objectui#8980 — the protocol declares `grouping` on a named list @@ -2410,8 +2412,7 @@ export const ObjectView: React.FC = ({ // Toolbar policy — one vocabulary (#2890). The host node and the // active view may still carry the legacy bare `show*` flags, so both // go through `normalizeListViewSchema` (the single fold) and merge, - // view over host. `densityMode`/`rowHeight` above take the same route - // once step 2's fold runs at the ListView boundary. + // view over host. `rowHeight` above takes the same fold. userActions: { ...(normalizeListViewSchema(schema ?? {}) as { userActions?: object }).userActions, ...(normalizeListViewSchema(activeView ?? {}) as { userActions?: object }).userActions, diff --git a/packages/react/src/hooks/useDensityMode.ts b/packages/react/src/hooks/useDensityMode.ts index a0c1a33ed7..37c8d65321 100644 --- a/packages/react/src/hooks/useDensityMode.ts +++ b/packages/react/src/hooks/useDensityMode.ts @@ -76,8 +76,12 @@ const DENSITY_ORDER: DensityModeValue[] = ['compact', 'comfortable', 'spacious'] * * @example * ```tsx - * const density = useDensityMode(activeView?.densityMode ?? 'comfortable', { - * onChange: (m) => dataSource.updateViewConfig(obj, vid, { densityMode: m }), + * import { DENSITY_MODE_TO_ROW_HEIGHT, rowHeightToDensityMode } from '@object-ui/core'; + * + * // A view stores the protocol's `rowHeight`; the legacy `densityMode` key is + * // retired on `NamedListView` (objectui#7924), so persist the spec value. + * const density = useDensityMode(rowHeightToDensityMode(activeView?.rowHeight) ?? 'comfortable', { + * onChange: (m) => dataSource.updateViewConfig(obj, vid, { rowHeight: DENSITY_MODE_TO_ROW_HEIGHT[m] }), * }); * ``` */ diff --git a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts index a0b1182232..8c3dff8d14 100644 --- a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts +++ b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts @@ -95,7 +95,8 @@ * * ⚠️ The distinction the whole finding turns on: a member reached through * `activeView?.KEY` — the host's `views` prop — is NOT read off the named view. - * `rowHeight: activeView?.rowHeight` is the pinned counter-control below. + * `rowHeight`, read off `activeView` through the fold, is the pinned + * counter-control below. */ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; @@ -354,8 +355,9 @@ const BUCKET_LEGACY_SHOW_SPELLINGS = [ * fold table, and read off no named view by the `object-view` renderer. * * ⚠️ CORRECTED at objectui#7924's retirement: this block used to add "no runtime - * fold, and no reader on any path". Two of the ten falsify it — - * {@link BUCKET_LOCAL_ONLY_RETAINED} — because the `object-view` renderer is not + * fold, and no reader on any path". Two of the ten falsified it — + * `allowExport` ({@link BUCKET_LOCAL_ONLY_RETAINED}) and `densityMode`, retired + * later by ruling A′ ({@link DENSITY_MODE_RETIRED}) — because the `object-view` renderer is not * the only host of a named view: `app-shell`'s object page holds the active * named view as a flat `NamedListView` and relays members off it into the * `list-view` node. The membership below is unchanged (it is derived by the @@ -368,22 +370,36 @@ const BUCKET_LOCAL_ONLY_UNREAD = [ ] as const; /** - * The two bucket-③ members objectui#7924 did NOT tombstone, because each is - * read and acted on: `allowExport` gates `ListView`'s export control, and - * `densityMode` is folded onto `rowHeight` by `normalizeListViewSchema`. Each - * carries its read as a source assertion in the retirement block below. + * The bucket-③ member objectui#7924 did NOT tombstone, because it is read and + * acted on: `allowExport` gates `ListView`'s export control. It carries its + * read as a source assertion in the retirement block below. + * + * ⚠️ This held TWO names until ruling A′ (objectui#7924): `densityMode` was kept + * because both relays of a named view carried it by name into the fold. Once + * both relays read the density THROUGH the fold (`normalizeListViewSchema`), + * nothing outside the fold reads it, so it retired as a legacy spelling of + * top-level `rowHeight` — see {@link DENSITY_MODE_RETIRED}. */ -const BUCKET_LOCAL_ONLY_RETAINED = ['allowExport', 'densityMode'] as const; +const BUCKET_LOCAL_ONLY_RETAINED = ['allowExport'] as const; + +/** + * The bucket-③ member retired LATER than its neighbours, by ruling A′ on + * objectui#7924: a legacy spelling of top-level `rowHeight` whose only reader + * is the runtime fold. + */ +const DENSITY_MODE_RETIRED = 'densityMode'; /** * Every `?: never` tombstone objectui#7924 put on `NamedListView`: the eight - * bucket-② spellings plus the eight bucket-③ members that are read nowhere. - * Derived off the AST (a member whose type node is the `never` keyword). + * bucket-② spellings plus the nine bucket-③ members nothing outside the fold + * reads (`densityMode` joined the eight at ruling A′). Derived off the AST (a + * member whose type node is the `never` keyword). */ const NAMED_LIST_VIEW_TOMBSTONES = [ 'addDeleteRecordsInline', 'addRecordViaForm', 'clickIntoRecordDetails', 'collapseAllByDefault', - 'color', 'fieldTextColor', 'prefixField', 'showColor', 'showDensity', 'showDescription', - 'showFilters', 'showGroup', 'showHideFields', 'showSearch', 'showSort', 'wrapHeaders', + 'color', 'densityMode', 'fieldTextColor', 'prefixField', 'showColor', 'showDensity', + 'showDescription', 'showFilters', 'showGroup', 'showHideFields', 'showSearch', 'showSort', + 'wrapHeaders', ] as const; /** @@ -610,7 +626,7 @@ interface NamedViewReadDerivation { /** * What the renderer reads OFF A NAMED VIEW — kept strictly apart from what it * reads off `activeView` (the host's `views` prop), which is the whole finding: - * `rowHeight: activeView?.rowHeight` is NOT a named-view read. + * `rowHeight`, read off `activeView` through the fold, is NOT a named-view read. * * Every route from the record to a named view is derived, not listed: * RECORD — a variable initialised from `schema.listViews`. @@ -1346,11 +1362,11 @@ describe('objectui#7924 — the per-member liveness census on `NamedListView`, r expect(reads).toContain(NAMED_VIEW_READ_CONTROL); expect(declared).toContain(NAMED_VIEW_READ_CONTROL); // …and its negative twin: declared, and NOT read off a named view. It is - // read off `activeView` instead (`rowHeight: activeView?.rowHeight`), which - // is the distinction the whole finding turns on. + // read off `activeView` instead, through the fold since objectui#7924's + // ruling A′ — which is the distinction the whole finding turns on. expect(declared).toContain(NAMED_VIEW_UNREAD_CONTROL); expect(reads).not.toContain(NAMED_VIEW_UNREAD_CONTROL); - expect(readRepo(READER)).toContain('rowHeight: activeView?.rowHeight,'); + expect(readRepo(READER)).toContain('rowHeight: (normalizeListViewSchema(activeView ?? {})'); // A spelling that is in neither set: non-vacuity for both probes at once. expect(declared).not.toContain(NAMED_VIEW_ABSENT_CONTROL); expect(reads).not.toContain(NAMED_VIEW_ABSENT_CONTROL); @@ -1618,8 +1634,10 @@ describe('objectui#7924 — the 43 unread members are FOUR populations, derived * as `?: never` tombstones on the TypeScript face only — the runtime fold in * `normalizeListViewSchema` stays, so stored documents keep parsing. * - * Two bucket-③ members were measured READ at retirement time and are NOT - * tombstoned ({@link BUCKET_LOCAL_ONLY_RETAINED}). + * Two bucket-③ members were measured READ at retirement time and were NOT + * tombstoned then. Ruling A′ (objectui#7924) retired one of them later: + * `densityMode`, once both relays of a named view read the density through the + * fold instead of by name. `allowExport` stays ({@link BUCKET_LOCAL_ONLY_RETAINED}). */ const LISTVIEW = 'packages/plugin-list/src/ListView.tsx'; @@ -1634,9 +1652,12 @@ type TombstoneTypes = { [K in Tombstone]: NamedListView[K] }; // (to `boolean`, `string`, or `any` through a deletion) makes this union not // `undefined`, and the type-check goes red. export type _EveryTombstoneAdmitsOnlyAbsence = Expect>; -// The two retained members keep their declared types. +// The retained member keeps its declared type. export type _AllowExportRetained = Expect>; -export type _DensityModeRetained = Expect>; +// `densityMode` — this pin asserted the member RETAINED (its three-value union) +// until ruling A′ (objectui#7924); re-pointed, not deleted, it now asserts the +// tombstone: the member admits absence and nothing else. +export type _DensityModeTombstoned = Expect>; // The canonical blocks are the `list-view` node's own spec-derived members. export type _UserActionsIsTheSpecDerivedSlot = Expect>; export type _AppearanceIsTheSpecDerivedSlot = Expect>; @@ -1676,6 +1697,8 @@ export const _refusedFieldTextColor: NamedListView = { ...BASE_VIEW, fieldTextCo export const _refusedPrefixField: NamedListView = { ...BASE_VIEW, prefixField: 'code' }; // @ts-expect-error — `wrapHeaders` is RETIRED (objectui#7924): protocol-silent and unread export const _refusedWrapHeaders: NamedListView = { ...BASE_VIEW, wrapHeaders: true }; +// @ts-expect-error — `densityMode` is RETIRED (objectui#7924, ruling A′): author top-level `rowHeight` +export const _refusedDensityMode: NamedListView = { ...BASE_VIEW, densityMode: 'compact' }; // …and the canonical blocks ACCEPT a protocol-shaped value on a literal. The // same values are parsed against the protocol's own slots below, so "protocol @@ -1691,14 +1714,17 @@ export const _acceptedCanonical: NamedListView = { appearance: { ...PROTOCOL_APPEARANCE }, rowColor: { field: 'status' }, }; -// The two retained members still accept their values. -export const _acceptedRetained: NamedListView = { ...BASE_VIEW, allowExport: false, densityMode: 'compact' }; - -describe('objectui#7924 — the retirement: sixteen `?: never` tombstones, two retained reads, the canonical blocks', () => { - it('the tombstone set is EXACTLY bucket ② plus bucket ③ minus the two retained reads, derived off the AST', () => { +// The retained member still accepts its value… +export const _acceptedRetained: NamedListView = { ...BASE_VIEW, allowExport: false }; +// …and the density the retired `densityMode` spelled is authored as the +// protocol's top-level `rowHeight`, which a named view accepts. +export const _acceptedDensityCanonical: NamedListView = { ...BASE_VIEW, rowHeight: 'compact' }; + +describe('objectui#7924 — the retirement: seventeen `?: never` tombstones, one retained read, the canonical blocks', () => { + it('the tombstone set is EXACTLY bucket ② plus bucket ③ minus the retained read, derived off the AST', () => { const { tombstones, names } = namedListViewMembers(); expect([...tombstones].sort()).toEqual([...NAMED_LIST_VIEW_TOMBSTONES]); - expect(tombstones).toHaveLength(16); + expect(tombstones).toHaveLength(17); const expected = [ ...BUCKET_LEGACY_SHOW_SPELLINGS, ...BUCKET_LOCAL_ONLY_UNREAD.filter((m) => !(BUCKET_LOCAL_ONLY_RETAINED as readonly string[]).includes(m)), @@ -1737,6 +1763,9 @@ describe('objectui#7924 — the retirement: sixteen `?: never` tombstones, two r expect(docs.color).toContain('`rowColor`'); expect(docs.color).toContain('Not `userActions.rowColor`'); expect(docs.addRecordViaForm).toContain('`userActions.addRecordForm`'); + // `densityMode` names the top-level VALUE and warns off the toggle. + expect(docs[DENSITY_MODE_RETIRED]).toContain('top-level `rowHeight`'); + expect(docs[DENSITY_MODE_RETIRED]).toContain('Not `userActions.rowHeight`'); for (const m of NAMED_LIST_VIEW_TOMBSTONES) expect(docs[m]).toContain('RETIRED (objectui#7924)'); // No alias: the fold table does not learn any bucket-③ member. const map = foldFlagMap(); @@ -1749,9 +1778,19 @@ describe('objectui#7924 — the retirement: sixteen `?: never` tombstones, two r expect(readRepo(LISTVIEW)).toContain('schema.allowExport !== false &&'); }); - it('`densityMode` is RETAINED because it is read: the runtime fold maps it onto `rowHeight`', () => { - expect(namedListViewMembers().tombstones).not.toContain('densityMode'); - expect(readRepo(APP_SHELL_OBJECT_VIEW)).toContain('densityMode: viewDef.densityMode ?? listSchema.densityMode,'); + it('`densityMode` is RETIRED by ruling A′: both relays read the density THROUGH the fold, and the fold still maps stored views', () => { + // Re-pointed from the RETAINED pin it replaces (objectui#7924): that pin + // held the app-shell relay line naming the key; this one holds its absence + // at BOTH relays, with the fold-through line as the firing control on the + // same file, so an absence is a reading and not a missed file. + expect(namedListViewMembers().tombstones).toContain(DENSITY_MODE_RETIRED); + const appShell = readRepo(APP_SHELL_OBJECT_VIEW); + const pluginView = readRepo(READER); + expect(appShell).not.toContain('densityMode: viewDef.densityMode'); + expect(appShell).toContain('(normalizeListViewSchema(viewDef ?? {}) as Pick).rowHeight'); + expect(pluginView).not.toContain('densityMode: activeView?.densityMode'); + expect(pluginView).toContain('rowHeight: (normalizeListViewSchema(activeView ?? {})'); + // The fold is the one reader left, and stored views keep rendering through it. const fold = readRepo(FOLD); expect(fold).toContain('const legacyDensity = s.densityMode;'); expect(fold).toContain('delete next.densityMode;'); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 9e9514bd04..0e86900da0 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -2291,10 +2291,12 @@ export interface NamedListView { * Allow data export @default undefined * * ⚠️ objectui-only (the protocol declares no `allowExport` on a view), but - * ⛔ NOT retired with its bucket-③ neighbours: it is READ. `app-shell`'s - * object page relays it off the active named view into the `list-view` node, - * and `ListView` gates its export control on `schema.allowExport !== false`. - * Reported on objectui#7924 rather than tombstoned. + * ⛔ NOT retired with its bucket-③ neighbours: it is READ. BOTH relays carry + * it off the active view into the `list-view` node — `plugin-view`'s + * `ObjectView` in its `renderListView` composition, and `app-shell`'s object + * page in its own relay over that composition — and `ListView` gates its + * export control on `schema.allowExport !== false`. Kept declared by ruling A + * on objectui#7924. */ allowExport?: boolean; @@ -2355,16 +2357,16 @@ export interface NamedListView { resizable?: boolean; /** - * Density mode for controlling row/item spacing - * - * ⚠️ objectui-only (the protocol's name for this is `rowHeight`), but ⛔ NOT - * retired with its bucket-③ neighbours: it is READ. `normalizeListViewSchema` - * (`@object-ui/core`) folds it onto `rowHeight` at the `ListView` boundary, so - * it is a legacy SPELLING with a runtime fold — the bucket-② shape, whose - * canonical key is top-level `rowHeight` (⛔ not `userActions.rowHeight`, the - * boolean toolbar toggle). Reported on objectui#7924 rather than tombstoned. + * @deprecated RETIRED (objectui#7924) — author top-level `rowHeight` instead. + * Not a protocol key on a view: the protocol's name for row density is + * `rowHeight`, and `densityMode` was objectui's legacy spelling of it. + * ⚠️ Not `userActions.rowHeight`: that is the boolean toggle for the + * toolbar's density control. A stored view that still carries this key keeps + * rendering: `normalizeListViewSchema` (`@object-ui/core`) folds it onto + * `rowHeight` through `DENSITY_MODE_TO_ROW_HEIGHT`, and both relays of a + * named view read the density through that fold rather than by this name. */ - densityMode?: 'compact' | 'comfortable' | 'spacious'; + densityMode?: never; /** * Row height for list/grid view rows. @@ -2480,8 +2482,9 @@ export interface NamedListView { * * The 19 legacy spellings this interface declared BEYOND the protocol were * objectui#7924's remedy (ruling item 4) — ⛔ not touched by that card. It - * retired sixteen of them as tombstones; `options`, `allowExport` and - * `densityMode` stay declared because each is read. + * retired seventeen of them as tombstones, `densityMode` last (ruling A′, + * once both of its relays read through the fold); `options` and + * `allowExport` stay declared because each is read. */ /**