diff --git a/.changeset/tall-maps-declare.md b/.changeset/tall-maps-declare.md new file mode 100644 index 0000000000..46ebac4551 --- /dev/null +++ b/.changeset/tall-maps-declare.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': minor +--- + +Add the `map` visualization config block to `ListViewSchema` — the eighth +per-visualization block, alongside kanban / calendar / gantt / gallery / +timeline / chart / tree. `ListMapConfigSchema` (named like +`ListChartConfigSchema`, because the automation `map` flow node already exports +`MapConfigSchema`) declares the map renderer's documented read surface: +`latitudeField`, `longitudeField`, `locationField`, `titleField`, +`descriptionField`, `zoom` (1-20), `center` (`[latitude, longitude]`). All keys +are optional and none carries a default — when no camera is declared the +renderer fits the camera to the queried records. Before this block a +`type: 'map'` list view could not declare its field mapping at all +(`ListViewSchema` is strict), so a marker title field other than the renderer +default `name` was unreachable — the showcase task map rendered every marker +title as `undefined`. The showcase task map view now declares +`map: { titleField: 'title', locationField: 'location' }`. diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index ebe2a3e2e7..57e3c30b79 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1584 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1585 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -32,8 +32,8 @@ counts are sums of the rows they head. Regenerate with | [Shared Protocol](/docs/references/shared) | 8 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | -| [UI Protocol](/docs/references/ui) | 16 | 160 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **198** | **1584** | 14 protocol modules | +| [UI Protocol](/docs/references/ui) | 16 | 161 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | +| **Total** | **198** | **1585** | 14 protocol modules | --- @@ -364,7 +364,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a ## UI Protocol -**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 160 schemas** +**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 161 schemas** Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. @@ -385,7 +385,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay | [`responsive.zod.ts`](/docs/references/ui/responsive) | `BreakpointColumnMap`, `BreakpointName`, `BreakpointOrderMap`, `ResponsiveConfig`, `ResponsiveStyles`, `StyleMap` | | [`sharing.zod.ts`](/docs/references/ui/sharing) | `SharingConfig` | | [`theme.zod.ts`](/docs/references/ui/theme) | `BorderRadius`, `ColorPalette`, `Shadow`, `Theme`, `ThemeMode`, `Typography` | -| [`view.zod.ts`](/docs/references/ui/view) | `AddRecordConfig`, `AppearanceConfig`, `CalendarConfig`, `ColumnPrefix`, `ColumnSummary`, `ColumnSummaryConfig`, `FormButtonConfig`, `FormField`, `FormFieldPublicPicker`, `FormSection`, `FormView`, `GalleryConfig`, `GanttConfig`, `GanttQuickFilter`, `GroupingConfig`, `GroupingField`, `HttpMethodSubset`, `HttpRequest`, `KanbanConfig`, `ListChartConfig`, `ListColumn`, `ListView`, `NavigationConfig`, `NavigationMode`, `ObjectListView`, `ObjectUserFilters`, `PaginationConfig`, `RowColorConfig`, `RowHeight`, `SelectionConfig`, `TimelineConfig`, `TreeConfig`, `UserActionsConfig`, `UserFilterField`, `UserFilters`, `View`, `ViewData`, `ViewFilterRule`, `ViewItem`, `ViewItemName`, `ViewItemWire`, `ViewKind`, `ViewScope`, `ViewSharing`, `ViewTab`, `VisualizationType` | +| [`view.zod.ts`](/docs/references/ui/view) | `AddRecordConfig`, `AppearanceConfig`, `CalendarConfig`, `ColumnPrefix`, `ColumnSummary`, `ColumnSummaryConfig`, `FormButtonConfig`, `FormField`, `FormFieldPublicPicker`, `FormSection`, `FormView`, `GalleryConfig`, `GanttConfig`, `GanttQuickFilter`, `GroupingConfig`, `GroupingField`, `HttpMethodSubset`, `HttpRequest`, `KanbanConfig`, `ListChartConfig`, `ListColumn`, `ListMapConfig`, `ListView`, `NavigationConfig`, `NavigationMode`, `ObjectListView`, `ObjectUserFilters`, `PaginationConfig`, `RowColorConfig`, `RowHeight`, `SelectionConfig`, `TimelineConfig`, `TreeConfig`, `UserActionsConfig`, `UserFilterField`, `UserFilters`, `View`, `ViewData`, `ViewFilterRule`, `ViewItem`, `ViewItemName`, `ViewItemWire`, `ViewKind`, `ViewScope`, `ViewSharing`, `ViewTab`, `VisualizationType` | --- diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 20319bee62..35cf662208 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -15,8 +15,8 @@ Migrated to [shared/http.zod.ts](/docs/references/shared/http). Re-exported here ## TypeScript Usage ```typescript -import { AddRecordConfigSchema, AppearanceConfigSchema, CalendarConfigSchema, ColumnPrefixSchema, ColumnSummarySchema, ColumnSummaryConfigSchema, FormButtonConfigSchema, FormFieldSchema, FormFieldPublicPickerSchema, FormSectionSchema, FormViewSchema, GalleryConfigSchema, GanttConfigSchema, GanttQuickFilterSchema, GroupingConfigSchema, GroupingFieldSchema, HttpMethodSubsetSchema, HttpRequestSchema, KanbanConfigSchema, ListChartConfigSchema, ListColumnSchema, ListViewSchema, NavigationConfigSchema, NavigationModeSchema, ObjectListViewSchema, ObjectUserFiltersSchema, PaginationConfigSchema, RowColorConfigSchema, RowHeightSchema, SelectionConfigSchema, TimelineConfigSchema, TreeConfigSchema, UserActionsConfigSchema, UserFilterFieldSchema, UserFiltersSchema, ViewSchema, ViewDataSchema, ViewFilterRuleSchema, ViewItemSchema, ViewItemNameSchema, ViewItemWireSchema, ViewKindSchema, ViewScopeSchema, ViewSharingSchema, ViewTabSchema, VisualizationTypeSchema } from '@objectstack/spec/ui'; -import type { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnPrefix, ColumnSummary, ColumnSummaryConfig, FormButtonConfig, FormField, FormFieldPublicPicker, FormSection, FormView, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, HttpMethodSubset, HttpRequest, KanbanConfig, ListChartConfig, ListColumn, ListView, NavigationConfig, NavigationMode, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterRule, ViewItem, ViewItemName, ViewItemWire, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; +import { AddRecordConfigSchema, AppearanceConfigSchema, CalendarConfigSchema, ColumnPrefixSchema, ColumnSummarySchema, ColumnSummaryConfigSchema, FormButtonConfigSchema, FormFieldSchema, FormFieldPublicPickerSchema, FormSectionSchema, FormViewSchema, GalleryConfigSchema, GanttConfigSchema, GanttQuickFilterSchema, GroupingConfigSchema, GroupingFieldSchema, HttpMethodSubsetSchema, HttpRequestSchema, KanbanConfigSchema, ListChartConfigSchema, ListColumnSchema, ListMapConfigSchema, ListViewSchema, NavigationConfigSchema, NavigationModeSchema, ObjectListViewSchema, ObjectUserFiltersSchema, PaginationConfigSchema, RowColorConfigSchema, RowHeightSchema, SelectionConfigSchema, TimelineConfigSchema, TreeConfigSchema, UserActionsConfigSchema, UserFilterFieldSchema, UserFiltersSchema, ViewSchema, ViewDataSchema, ViewFilterRuleSchema, ViewItemSchema, ViewItemNameSchema, ViewItemWireSchema, ViewKindSchema, ViewScopeSchema, ViewSharingSchema, ViewTabSchema, VisualizationTypeSchema } from '@objectstack/spec/ui'; +import type { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnPrefix, ColumnSummary, ColumnSummaryConfig, FormButtonConfig, FormField, FormFieldPublicPicker, FormSection, FormView, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, HttpMethodSubset, HttpRequest, KanbanConfig, ListChartConfig, ListColumn, ListMapConfig, ListView, NavigationConfig, NavigationMode, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterRule, ViewItem, ViewItemName, ViewItemWire, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; // Validate data const result = AddRecordConfigSchema.parse(data); @@ -456,6 +456,25 @@ List chart view configuration | **action** | `string` | optional | Registered Action ID to execute when clicked | +--- + +## ListMapConfig + +Map view configuration + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **latitudeField** | `string` | optional | Field providing the marker latitude (used with longitudeField) | +| **longitudeField** | `string` | optional | Field providing the marker longitude (used with latitudeField) | +| **locationField** | `string` | optional | Field providing a combined location — a "lat,lng" string or a `{ lat, lng }` object — as the alternative to the latitudeField/longitudeField pair | +| **titleField** | `string` | optional | Field displayed as the marker title (popup heading, mobile record card, and what the map search box matches on) | +| **descriptionField** | `string` | optional | Field displayed as the marker description | +| **zoom** | `number` | optional | Initial zoom level (1-20). Omit to let the renderer fit the camera to the queried records | +| **center** | `any[]` | optional | Initial camera center as [latitude, longitude]. Omit to let the renderer fit the camera to the queried records | + + --- ## ListView @@ -485,6 +504,7 @@ List chart view configuration | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | +| **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **description** | `string \| Record` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | @@ -573,6 +593,7 @@ List chart view configuration | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | +| **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **description** | `string \| Record` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index bf0341a8a7..4bb479722f 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -21,7 +21,7 @@ regenerate. | Measure | Value | |---|---| | Triaged directories | 5 | -| Object sites in them | 440 | +| Object sites in them | 441 | | Still-open (strip) sites | 123 | | Files carrying at least one | 22 | @@ -44,12 +44,12 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 174 | 163 | 5 | 0 | 6 | +| `ui/` | 175 | 164 | 5 | 0 | 6 | | `data/` | 154 | 72 | 1 | 0 | 81 | | `automation/` | 65 | 42 | 0 | 0 | 23 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **440** | **311** | **6** | **0** | **123** | +| **total** | **441** | **312** | **6** | **0** | **123** | ## File-level triage — site counts @@ -75,9 +75,9 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `responsive.zod.ts` | 4 | | `sharing.zod.ts` | 1 | | `theme.zod.ts` | 6 | -| `view.zod.ts` | 55 | +| `view.zod.ts` | 56 | | `widget.zod.ts` | 1 | -| **total** | **174** | +| **total** | **175** | ### `data/` — sites @@ -156,15 +156,15 @@ over it is here. ### `ui/` — open -**6 strip of 174**, in 4 file(s). +**6 strip of 175**, in 4 file(s). | File | Strip | Sites | |---|---|---| | `action-params.zod.ts` | 1 | 1 | | `app.zod.ts` | 1 | 18 | -| `view.zod.ts` | 3 | 55 | +| `view.zod.ts` | 3 | 56 | | `widget.zod.ts` | 1 | 1 | -| **total** | **6** | **174** | +| **total** | **6** | **175** | | Bucket | Sites | |---|---| diff --git a/examples/app-showcase/src/ui/views/task.view.ts b/examples/app-showcase/src/ui/views/task.view.ts index dc411931ac..660ca568f0 100644 --- a/examples/app-showcase/src/ui/views/task.view.ts +++ b/examples/app-showcase/src/ui/views/task.view.ts @@ -41,10 +41,12 @@ export const TaskViews = defineView({ // and lives as its own named view + dashboard element). appearance: { // The six record-based visualizations the spec + runtime switcher support - // out of the box: the SAME task records re-shaped on demand. (map needs a - // spec MapConfigSchema the ListViewSchema doesn't yet have, and chart - // aggregates a dataset rather than records — both live as their own named - // views below instead of in this switcher.) + // out of the box: the SAME task records re-shaped on demand. (map has a + // spec block since #9340 — `ListMapConfigSchema`, declared on the named + // map view below — but stays out of this switcher until the objectui + // renderer consumes the declared `view.map` surface (its follow-up card); + // chart aggregates a dataset rather than records — both live as their own + // named views below instead of in this switcher.) allowedVisualizations: ['grid', 'kanban', 'gallery', 'calendar', 'timeline', 'gantt'], }, @@ -308,6 +310,12 @@ export const TaskViews = defineView({ type: 'map', data, columns: ['title', 'location', 'assignee'], + // #9340: showcase_task has `title`, not `name` — without this declared + // block the renderer's titleField default of 'name' left every marker + // title undefined (empty popup heading / mobile card, search matched + // nothing). locationField matches the renderer default; declared anyway + // so the binding is explicit rather than coincidental. + map: { titleField: 'title', locationField: 'location' }, }, // 8 ── Chart ──────────────────────────────────────────────────────── diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index e55aadd4dc..bccfee4611 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -209,6 +209,8 @@ "ListColumn (type)", "ListColumnParsed (type)", "ListColumnSchema (const)", + "ListMapConfig (type)", + "ListMapConfigSchema (const)", "ListView (type)", "ListViewParsed (type)", "ListViewSchema (const)", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index b1dd72589f..2e5b65eafa 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -627,6 +627,13 @@ "ui/ListColumn:type", "ui/ListColumn:width", "ui/ListColumn:wrap", + "ui/ListMapConfig:center", + "ui/ListMapConfig:descriptionField", + "ui/ListMapConfig:latitudeField", + "ui/ListMapConfig:locationField", + "ui/ListMapConfig:longitudeField", + "ui/ListMapConfig:titleField", + "ui/ListMapConfig:zoom", "ui/ListView:addRecord", "ui/ListView:allowPrinting", "ui/ListView:appearance", @@ -653,6 +660,7 @@ "ui/ListView:inlineEdit", "ui/ListView:kanban", "ui/ListView:label", + "ui/ListView:map", "ui/ListView:name", "ui/ListView:navigation", "ui/ListView:pagination", @@ -814,6 +822,7 @@ "ui/ObjectListView:inlineEdit", "ui/ObjectListView:kanban", "ui/ObjectListView:label", + "ui/ObjectListView:map", "ui/ObjectListView:name", "ui/ObjectListView:navigation", "ui/ObjectListView:pagination", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 36b599367f..238ec96749 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -209,6 +209,8 @@ "ListColumn": "src/ui/view.zod.ts#ListColumn (type)", "ListColumnParsed": "src/ui/view.zod.ts#ListColumnParsed (type)", "ListColumnSchema": "src/ui/view.zod.ts#ListColumnSchema (const)", + "ListMapConfig": "src/ui/view.zod.ts#ListMapConfig (type)", + "ListMapConfigSchema": "src/ui/view.zod.ts#ListMapConfigSchema (const)", "ListView": "src/ui/view.zod.ts#ListView (type)", "ListViewParsed": "src/ui/view.zod.ts#ListViewParsed (type)", "ListViewSchema": "src/ui/view.zod.ts#ListViewSchema (const)", diff --git a/packages/spec/json-schema.manifest/ui.json b/packages/spec/json-schema.manifest/ui.json index 540cbd5cb2..7614d4352f 100644 --- a/packages/spec/json-schema.manifest/ui.json +++ b/packages/spec/json-schema.manifest/ui.json @@ -83,6 +83,7 @@ "ui/KanbanConfig", "ui/ListChartConfig", "ui/ListColumn", + "ui/ListMapConfig", "ui/ListView", "ui/NavigationArea", "ui/NavigationConfig", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 7204669b5f..d047e2ef45 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -798,7 +798,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | skill | `permissions` REMOVED 2026-07 (#3704); `triggerPhrases` REMOVED 2026-07-30 (#3896 close-out sweep — phrases were never matched; activation is `triggerConditions` + the agent's `skills[]` + /skill-name pinning) | | dataset | `measures.certified` (declared-but-unenforced governance flag) REMOVED in 16.0 (#2377) | | page | fully live + one planned | -| view | list/form drilled via `children` (#2998 Track B); list.{responsive,performance} + form.{defaultSort,aria} REMOVED 2026-07-30 (#3896 close-out sweep — list aria/data stay live); **form.data was that sweep's one CORRECTION** — the removal attempt broke the build (`defineForm` writes `data.provider='schema'` onto every metadata form, `metadata-protocol` serves it), so it stands `live` with re-verified evidence; form.{buttons,defaults} live (framework#1894 / #2998); audit-era DEAD lines superseded by re-verification. **The dead set is six, not the four removals above**: #4534 (the last #4001 batch, batch 6e) declared three CONTAINER-level keys this row had never classified — `name` and `label`, both `dead`, and `object`, `live`. All three are properties of the `views: [...]` *container*, not of a view: `name` is dead as a BODY key because the live one is the `sys_metadata` row column the door supplies, and `label` is container display metadata with no reader. Neither is `authorWarn`'d and both are deliberately KEPT — the platform's own writers send `name` (artifact-shipped containers, the metadata-validation sweep), so tombstoning it would reject shapes we write ourselves. `object` is the container's object binding, and it was *stripped on every parse* until #4534 declared it. Separately, the level-2 dead residue (userActions.buttons, addRecord.mode/formView, tabs[].order) is noted on parents and is **not** in the counts — one drill level only | +| view | list/form drilled via `children` (#2998 Track B); list.{responsive,performance} + form.{defaultSort,aria} REMOVED 2026-07-30 (#3896 close-out sweep — list aria/data stay live); **form.data was that sweep's one CORRECTION** — the removal attempt broke the build (`defineForm` writes `data.provider='schema'` onto every metadata form, `metadata-protocol` serves it), so it stands `live` with re-verified evidence; form.{buttons,defaults} live (framework#1894 / #2998); audit-era DEAD lines superseded by re-verification. **The dead set is six, not the four removals above**: #4534 (the last #4001 batch, batch 6e) declared three CONTAINER-level keys this row had never classified — `name` and `label`, both `dead`, and `object`, `live`. All three are properties of the `views: [...]` *container*, not of a view: `name` is dead as a BODY key because the live one is the `sys_metadata` row column the door supplies, and `label` is container display metadata with no reader. Neither is `authorWarn`'d and both are deliberately KEPT — the platform's own writers send `name` (artifact-shipped containers, the metadata-validation sweep), so tombstoning it would reject shapes we write ourselves. `object` is the container's object binding, and it was *stripped on every parse* until #4534 declared it. Separately, the level-2 dead residue (userActions.buttons, addRecord.mode/formView, tabs[].order) is noted on parents and is **not** in the counts — one drill level only **#9340**: `list.map` declared — the eighth visualization block (`ListMapConfigSchema`), keys mirroring objectui plugin-map's documented read set. `planned`, deliberately not `live`: the component-level reader exists (ObjectMap.tsx getMapConfig reads `schema.map`) but objectui ListView.tsx `case 'map'` still forwards only `options.map`, never the view-level block; flips live when the pre-registered objectui consumer card (Blocked-by #9340) lands the forwarding | | report | dataset-bound (ADR-0021); the aria/performance LEDGER entries were stale — the keys left the schema in the report-liveness close-out; deleted 2026-07-30 as hygiene. Audit-era `chart` DEAD superseded (framework#1890 / #3441) | | dashboard | ADR-0021 dataset widgets (#3251; DashboardWidgetSchema `.strict()`); `aria`/`performance` (and widget `performance` + PerformanceConfigSchema) REMOVED 2026-07-30 (#3896 close-out sweep — no renderer applied any of them); audit-era `globalFilters`/`dateRange` DEAD superseded (framework#2501) **#4956**: `widgets` DRILLED — the row jumps 20 → 41 classified because all 22 widget-level keys enter the count at once. They had never been classified at all: the entry carried one blanket `live` plus a `note` asserting they were classified "in the DashboardWidgetSchema subtree", and no such subtree existed in any of the 28 ledger files. That gap, not any evidence, is what carried `widgets[].responsive` through the #3896 sweep that removed both its sibling `widgets[].performance` and its literal namesake `view.responsive` — `view` is drilled, so `list.responsive` got asked and went out. New dead 6 = `responsive` (retired #4876/#4995, tombstone keeps the row) + `colorVariant` + `actionUrl`/`actionType`/`actionIcon` + `aria`. The action trio is the sharpest: no renderer draws a per-widget action button at all (every `actionUrl` read in DashboardRenderer is scoped to `header.actions[]`), yet `validate-dashboard-action-refs.ts` enforces reference integrity on it and its docblock calls it "the per-widget button" — a lint guarding an affordance that does not exist. `requiresService` is the counter-example worth remembering: dead by every objectui measurement, and LIVE server-side (`filterDashboardForUser`, ADR-0057 D10) — judging a widget key from the renderer repo alone would have retired an enforced gate. `compareTo` is `live` on ONE path only (inline object-provider charts); on the ADR-0021 dataset path the string arms are dropped and `{ offset }` throws in the executor. **#6774** moves the row 33/8 → 34/7: `colorVariant` CORRECTED dead → live 2026-08-09, the enforce leg of #5010 ruling B landing from the renderer side (objectui#3359 / PR objectui#3799, absorbed by pin `09987b68`). Worth reading beside `requiresService` above, because it is the same lesson from the other end — that row warns against judging a widget key from the renderer repo alone, and this one is a `dead` verdict that was correct in this repo AND correct in the renderer repo on the day it was measured, and stopped being either when a cross-repo decision was implemented. A ledger row is a claim with a timestamp; `verifiedAt` is what makes the claim re-askable. It also empties the dashboard warn set, so the author-side lint now says nothing about any widget key — `dashboard` stays in the lint's TYPE_COLLECTIONS all the same (the `webhook`/`email_template` resolved state) | | query | **not a metadata type** — the REQUEST surface (`QuerySchema`: client SDK QueryBuilder output; the `POST /data/:object/query` body), governed via `SPEC_ONLY_SCHEMAS` (#4286). The gate's one-level walk resolves 1 experimental; the 7 marker-experimental search affordances sit one level deeper, below the walk — resolved from `[EXPERIMENTAL — not enforced]` describe markers, not ledger entries (search `fuzzy`/`operator`/`boost`/`minScore`/`language`/`highlight` + `aggregations[].filter` — declared engine affordances no executor receives). The #4286 sweep closed out same-release: `having` ENFORCED 2026-07-31 (engine-side post-aggregation filter, both paths; was finding 1); dead 4 = the tombstoned removals `joins`/`windowFunctions`/`cursor`/`distinct` — REMOVED 2026-07-31 (retiredKey keeps each in the walked shape so the rows stay; protocol-17 semantic migrations; the JoinNode + WindowFunctionNode clusters and the `QueryBuilder.cursor()`/`.distinct()` producers deleted with their keys; `distinct`'s mis-wired REST count suppression deleted too — finding 2). **#6815** adds the 5th dead: `aggregations[].distinct` REMOVED 2026-08-09 (live → dead, `-1` live). It is the one member of this ledger the #4286 sweep could not have caught with the question it asked — that sweep looked for keys NO executor reads, and this one had a reader: the objectql in-memory fallback deduplicated before applying the function while all five other faces (driver-sql, driver-turso, driver-mongodb, driver-memory, service-analytics' `AGGREGATE_SQL`) ignored it, so one query answered two plausible NUMBERS depending on which backend served it. The lesson for the next audit is the question, not the key: a per-key `live` verdict is only as good as the count of faces it was measured across, and this row's 2026-07-31 evidence (`in-memory-aggregation.ts:167,204-206`) was TRUE and still the wrong verdict. `count_distinct` is the surviving spelling (enforce leg, #6409) | diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 68fe83056c..da7c5d9918 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -39,7 +39,7 @@ for both corollaries. | `skill` | 16 | 0 | 1 | 0 | 17 | | `dataset` | 27 | 0 | 0 | 0 | 27 | | `page` | 23 | 0 | 0 | 1 | 24 | -| `view` | 77 | 0 | 9 | 0 | 86 | +| `view` | 77 | 0 | 9 | 1 | 87 | | `report` | 21 | 0 | 0 | 0 | 21 | | `dashboard` | 34 | 0 | 7 | 0 | 41 | | `webhook` | 19 | 0 | 0 | 0 | 19 | @@ -57,4 +57,4 @@ for both corollaries. | `api` | 25 | 0 | 0 | 2 | 27 | | `capability` | 12 | 0 | 0 | 0 | 12 | | `qa` | 4 | 0 | 5 | 0 | 9 | -| **total** | **777** | **6** | **55** | **9** | **847** | +| **total** | **777** | **6** | **55** | **10** | **848** | diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index 52c66651a5..348a856485 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -117,6 +117,12 @@ "status": "live", "note": "objectui: @object-ui/plugin-tree ObjectTree.tsx (spec CHANGELOG: 'Renderer ships in objectui @object-ui/plugin-tree')." }, + "map": { + "status": "planned", + "verifiedAt": "2026-08-17", + "evidenceScope": "cross-repo", + "note": "PLANNED, deliberately not `live`, and the gap is one spread. Declared spec-first by the #9340 cross-repo ruling (transfer of objectui#5001). The renderer's read of this exact key set exists today — objectui plugin-map ObjectMap.tsx getMapConfig validates `schema.map` against a local zod schema with the same seven keys (latitudeField/longitudeField/locationField/titleField/descriptionField/zoom/center) — but objectui ListView.tsx `case 'map'` forwards only `schema.options?.map` and never the view-level `map` block, so authoring this key changes nothing at runtime until the pre-registered objectui consumer card (Blocked-by #9340) lands the view→renderer forwarding. Flip to `live` citing that ListView.tsx read when it lands — measured objectui at the checkout current on 2026-08-17." + }, "description": { "status": "live", "note": "objectui: ListView.tsx:1728 renders it under the toolbar (gated by appearance.showDescription)." diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 1ac0b1e7e6..689c8a536e 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -264,7 +264,7 @@ import type * as M167 from './ui/view.zod.js'; import type * as M170 from './ui/component.zod.js'; // --------------------------------------------------------------------------- -// 837 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 838 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1583,6 +1583,7 @@ export type Iso826 = Assert, z.i export type Iso827 = Assert, z.infer< typeof M167.GanttConfigSchema > >>; export type Iso828 = Assert, z.infer< typeof M167.GanttQuickFilterSchema > >>; export type Iso829 = Assert, z.infer< typeof M167.KanbanConfigSchema > >>; +export type Iso851 = Assert, z.infer< typeof M167.ListMapConfigSchema > >>; export type Iso830 = Assert, z.infer< typeof M167.NavigationModeSchema > >>; export type Iso831 = Assert, z.infer< typeof M167.TreeConfigSchema > >>; export type Iso832 = Assert, z.infer< typeof M167.ViewItemNameSchema > >>; @@ -1654,7 +1655,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 837 isomorphic pins', () => { + it('still declares all 838 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -1886,9 +1887,15 @@ describe('ADR-0122 type-alias convention', () => { // 836 -> 837 is #8993 — partial masking's `FieldMaskingKeepSchema` // ({keepHead, keepTail}, both plain non-negative ints, no defaults, no // transform), pinned as `Iso850`. + // + // 837 -> 838 is #9340 — the map list-view block (`ListMapConfigSchema`), + // deliberately default-free: objectui#5000 ruled "declared camera wins, no + // declaration → fit to the queried records", so a spec-side default + // zoom/center would read as a declared camera and defeat the fit. Pinned + // as `Iso851`. const self = readFileSync(fileURLToPath(import.meta.url), 'utf8'); const pins = self.match(/^export type Iso\d+ = Assert { }); }); +describe('ListMapConfigSchema (#9340 — the eighth visualization block)', () => { + it('accepts the full documented renderer surface — every key plugin-map reads, no extras', () => { + // Mirrors objectui plugin-map's own MapConfigSchema (ObjectMap.tsx): the + // spec block and the renderer's read set are the same seven keys. + const map = { + latitudeField: 'lat', + longitudeField: 'lng', + locationField: 'location', + titleField: 'title', + descriptionField: 'address', + zoom: 12, + center: [37.7749, -122.4194] as [number, number], + }; + + expect(() => ListMapConfigSchema.parse(map)).not.toThrow(); + }); + + it('accepts the showcase task shape — the exact declaration #9340 exists to make legal', () => { + // showcase_task has `title`, not `name`; before this block existed the + // renderer default left every marker title undefined and there was no + // spec-legal way to say otherwise. + const map = { titleField: 'title', locationField: 'location' }; + + expect(() => ListMapConfigSchema.parse(map)).not.toThrow(); + }); + + it('accepts an empty config — every key is optional and none carries a default', () => { + // No defaults on purpose: objectui#5000 ruled "declared camera wins, no + // declaration → fit the camera to the queried records". A spec-side + // default zoom/center would read as a declared camera and defeat the fit. + const parsed = ListMapConfigSchema.parse({}); + expect(parsed).toEqual({}); + }); + + it('refuses an unknown key with the prescription — surface name, offending key, suggestion, history', () => { + const res = ListMapConfigSchema.safeParse({ titleFeld: 'title' }); + expect(res.success).toBe(false); + const unknown = res.error!.issues.find((i) => i.code === 'unrecognized_keys'); + expect(unknown).toBeDefined(); + const m = unknown!.message; + expect(m.startsWith('Unrecognized key(s) on this map configuration: `titleFeld`.')).toBe(true); + expect(m).toContain('`titleField`'); + expect(m.endsWith( + 'Until #4001 closed these shapes an unknown key was dropped silently — the view still ' + + 'rendered, without whatever the key was meant to configure.', + )).toBe(true); + }); + + it('refuses an out-of-range zoom — the documented renderer range is 1-20', () => { + expect(ListMapConfigSchema.safeParse({ zoom: 0 }).success).toBe(false); + expect(ListMapConfigSchema.safeParse({ zoom: 25 }).success).toBe(false); + expect(ListMapConfigSchema.safeParse({ zoom: 1 }).success).toBe(true); + expect(ListMapConfigSchema.safeParse({ zoom: 20 }).success).toBe(true); + }); + + it('refuses a center that is not a [latitude, longitude] pair', () => { + expect(ListMapConfigSchema.safeParse({ center: [1, 2, 3] }).success).toBe(false); + expect(ListMapConfigSchema.safeParse({ center: [1] }).success).toBe(false); + expect(ListMapConfigSchema.safeParse({ center: '37.7,-122.4' }).success).toBe(false); + expect(ListMapConfigSchema.safeParse({ center: [37.7749, -122.4194] }).success).toBe(true); + }); + + it('is declarable on a map list view — the gap #9340 closes', () => { + const view = { + label: 'Work Locations (Map)', + type: 'map' as const, + columns: ['title', 'location', 'assignee'], + map: { titleField: 'title', locationField: 'location' }, + }; + + const parsed = ListViewSchema.parse(view); + expect(parsed.map?.titleField).toBe('title'); + expect(parsed.map?.locationField).toBe('location'); + }); + + it('stays strict when nested in a list view — an unknown key inside `map` is still refused', () => { + const res = ListViewSchema.safeParse({ + type: 'map', + columns: ['title'], + map: { titelField: 'title' }, + }); + expect(res.success).toBe(false); + const unknown = res.error!.issues.find((i) => i.code === 'unrecognized_keys'); + expect(unknown).toBeDefined(); + expect(unknown!.message.startsWith('Unrecognized key(s) on this map configuration: `titelField`.')).toBe(true); + }); +}); + describe('TimelineConfigSchema', () => { it('should accept minimal timeline config', () => { const timeline = { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 34474b72fd..da7e2ccbdd 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1204,6 +1204,45 @@ export const TreeConfigSchema = lazySchema(() => strictObject({ // Forward-compatible: let renderer-ahead config knobs reach plugin-tree. }).passthrough()); +/** + * Map View Settings + * + * Configures the geospatial (`type: 'map'`) list view: which fields provide + * marker coordinates and text, and the initial camera. The key set mirrors the + * map renderer's documented config surface (objectui `plugin-map`, read by + * `getMapConfig` in `ObjectMap.tsx` and documented in + * `content/docs/plugins/plugin-map.mdx`) — every key here has a live read + * there, and no renderer knob is admitted without one. Coordinates come from + * either a `latitudeField`/`longitudeField` pair or a single combined + * `locationField`; when no camera is declared the renderer fits the camera to + * the queried records, which is why `zoom`/`center` carry no defaults — a + * spec-side default would read as a declared camera and defeat that fit. + * + * Named `ListMapConfigSchema`, not `MapConfigSchema`: the `map` FLOW-NODE + * config in `automation/builtin-node-config.zod.ts` already exports that name + * (same collision, same resolution as `ListChartConfigSchema` vs the + * `chart.zod.ts` `ChartConfigSchema`). + * + * Closed (strict), unlike the gantt/tree blocks: their passthrough exists + * because those renderers keep adding config knobs ahead of the spec, while + * the map renderer's read set is itself closed — it validates `schema.map` + * against a local zod schema with exactly these keys, so an extra key here + * would be dropped there. Strict means a misspelling is a loud parse error + * instead of a marker silently falling back to default field names. + */ +export const ListMapConfigSchema = lazySchema(() => strictObject({ + surface: 'this map configuration', + history: VIEW_HISTORY, +}, { + latitudeField: z.string().optional().describe('Field providing the marker latitude (used with longitudeField)'), + longitudeField: z.string().optional().describe('Field providing the marker longitude (used with latitudeField)'), + locationField: z.string().optional().describe('Field providing a combined location — a "lat,lng" string or a { lat, lng } object — as the alternative to the latitudeField/longitudeField pair'), + titleField: z.string().optional().describe('Field displayed as the marker title (popup heading, mobile record card, and what the map search box matches on)'), + descriptionField: z.string().optional().describe('Field displayed as the marker description'), + zoom: z.number().min(1).max(20).optional().describe('Initial zoom level (1-20). Omit to let the renderer fit the camera to the queried records'), + center: z.tuple([z.number(), z.number()]).optional().describe('Initial camera center as [latitude, longitude]. Omit to let the renderer fit the camera to the queried records'), +}).describe('Map view configuration')); + /** * Navigation Mode Enum * Defines how to navigate to the detail view from a list item. @@ -1460,6 +1499,7 @@ export const ListViewSchema = lazySchema(() => strictObject({ gallery: GalleryConfigSchema.optional(), timeline: TimelineConfigSchema.optional(), chart: ListChartConfigSchema.optional(), + map: ListMapConfigSchema.optional().describe('Map configuration — applies when the view renders as a map layout'), tree: TreeConfigSchema.optional().describe('Tree/hierarchy configuration — applies when the view renders as a tree layout'), /** View Metadata (Airtable-style view management) */ @@ -4216,6 +4256,7 @@ export type CalendarConfig = z.input; export type GanttConfig = z.input; export type GanttQuickFilter = z.input; export type KanbanConfig = z.input; +export type ListMapConfig = z.input; export type NavigationMode = z.input; export type TreeConfig = z.input; export type ViewItemName = z.input;