diff --git a/.changeset/5903-objectgantt-declared-keys.md b/.changeset/5903-objectgantt-declared-keys.md new file mode 100644 index 0000000000..de0c6da6f5 --- /dev/null +++ b/.changeset/5903-objectgantt-declared-keys.md @@ -0,0 +1,38 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-gantt': minor +--- + +`ObjectGanttSchema` declares the ten gantt keys `ObjectGantt` actually reads +(objectui#5903, triage 2026-08-24). Every one is a real, working, documented +feature — `readOnly`, `mobileReadOnly`, `markers`, `navigation`, `skipWeekends`, +`holidays`, `criticalPath`, `showBaselines`, `persistLayout`, `viewName` — and +none of them was discoverable from the published type, because all ten were read +as `(schema as any).K`. The cast was the load-bearing part: it kept the read +invisible to `tsc`, to the zod mirror and to the designer's registry `inputs`. + +Both halves move together. The TS declaration (`packages/types/src/objectql.ts`) +and its zod mirror (`src/zod/objectql.zod.ts`) gain the same ten keys at the same +requiredness — all optional — so the `zod-mirror-parity` ratchet stays at zero +drift for this pair and no `KnownDrift` entry is added. `navigation` is taken +from `@objectstack/spec`'s `NavigationConfigSchema` by reference rather than +restated, matching `ObjectGridSchema.navigation`. + +`ObjectGanttProps.schema` is retyped from `ObjectGridSchema` to +`ObjectGanttSchema`. That is what makes the declaration load-bearing: the ten +keys are not grid keys, so with the old prop type, dropping the casts would have +left the reads landing on `BaseSchema`'s index signature — the same invisibility +in different syntax. The grid-style `{ gantt: { … } }` block is unaffected; +`getGanttConfig` reads it through that index signature exactly as before, and the +registered renderer passes `schema: any`, so no runtime shape is turned away. + +Accept-set change, stated plainly: a **declared** key is now type-validated, so +`readOnly: 'yes'` is refused where it used to parse green — the same narrowing +objectui#5074 landed for `viewMode`. An **undeclared** key is still accepted: +`BaseSchema` is `.passthrough()` and carries an index signature (objectui#5155's +structural ceiling), so declaring these ten did not buy rejection of a +misspelling. `packages/types/src/__tests__/gantt-declared-keys.test.ts` pins both +halves so neither can be misread. + +The eleventh reported key, `label`, needed no declaration — `BaseSchema` already +carries it — so only its cast was dropped. diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index aff222d09a..a71e9e6f6e 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -24,7 +24,7 @@ import React, { useContext, useEffect, useState, useMemo, useCallback, useRef } from 'react'; import { toast } from 'sonner'; -import type { ObjectGridSchema, DataSource, ViewData, GanttConfig } from '@object-ui/types'; +import type { ObjectGanttSchema, ObjectGridSchema, DataSource, ViewData, GanttConfig } from '@object-ui/types'; import { GanttConfigSchema } from '@objectstack/spec/ui'; import { useNavigationOverlay, SchemaRendererContext } from '@object-ui/react'; import { useLocalization, useDisplayLocale, resolveFieldCurrency } from '@object-ui/i18n'; @@ -274,7 +274,22 @@ export function normalizeDependencies(raw: unknown): GanttDependency[] { } export interface ObjectGanttProps { - schema: ObjectGridSchema; + /** + * The gantt node. Typed as {@link ObjectGanttSchema} (objectui#5903) — the + * declaration this component's schema reads actually resolve against. + * + * It used to be `ObjectGridSchema`, and that is why ten genuine reads had to + * be spelled `(schema as any).K`: the keys are not grid keys, so the only + * thing that admitted them was `BaseSchema`'s index signature, under a cast + * that hid even that. Removing the casts without moving the type would have + * changed nothing — the reads would still land on the index signature. + * + * The grid-style `{ gantt: { … } }` block keeps working exactly as before: + * `getGanttConfig` reads it through the same index signature, and the + * registered renderer (`index.tsx`) passes `schema: any`, so no runtime shape + * is turned away. + */ + schema: ObjectGanttSchema; dataSource?: DataSource; className?: string; onTaskClick?: (record: any) => void; @@ -296,7 +311,7 @@ export interface ObjectGanttProps { /** * Helper to get data configuration from schema */ -function getDataConfig(schema: ObjectGridSchema): ViewData | null { +function getDataConfig(schema: ObjectGanttSchema): ViewData | null { if (schema.data) { return schema.data; } @@ -888,8 +903,8 @@ export const ObjectGantt: React.FC = ({ // holiday list, duration/reschedule math is measured in working days. The // holidays array (ISO yyyy-mm-dd strings) becomes a Set for O(1) lookups. const workingCalendar = useMemo(() => { - const sw = (schema as any).skipWeekends; - const hol = (schema as any).holidays as string[] | undefined; + const sw = schema.skipWeekends; + const hol = schema.holidays; if (!sw && (!hol || hol.length === 0)) return undefined; return { skipWeekends: !!sw, @@ -1020,9 +1035,9 @@ export const ObjectGantt: React.FC = ({ // snapshot under persistLayoutKey and fires onLayoutChange; the chips live up // here, so they get a sibling localStorage key and restore on mount. const persistLayoutKey = - (schema as any).persistLayout === false + schema.persistLayout === false ? undefined - : `${schema.objectName || (dataConfig?.provider === 'object' ? dataConfig.object : '') || 'gantt'}:${(schema as any).viewName || 'default'}`; + : `${schema.objectName || (dataConfig?.provider === 'object' ? dataConfig.object : '') || 'gantt'}:${schema.viewName || 'default'}`; const filtersStorageKey = persistLayoutKey ? `gantt-layout:${persistLayoutKey}:filters` : null; const [filterValues, setFilterValues] = useState>(() => { if (!filtersStorageKey || typeof window === 'undefined') return {}; @@ -1129,7 +1144,7 @@ export const ObjectGantt: React.FC = ({ // providing its own `navigation` config (e.g., page mode). // detail panel inline (no full-page navigation). Schema can override by // providing its own `navigation` config (e.g., page mode). - const navConfig = (schema as any).navigation ?? { mode: 'drawer', width: 'min(960px, 60vw)' }; + const navConfig = schema.navigation ?? { mode: 'drawer', width: 'min(960px, 60vw)' }; const navIsOverlay = navConfig.mode === 'drawer' || navConfig.mode === 'modal' || navConfig.mode === 'split' || navConfig.mode === 'popover'; const navigation = useNavigationOverlay({ navigation: navConfig, @@ -1468,15 +1483,15 @@ export const ObjectGantt: React.FC = ({ onTaskDelete={requestDelete} onDependencyCreate={ganttConfig?.dependenciesField ? handleDependencyCreate : undefined} onDependencyDelete={ganttConfig?.dependenciesField ? handleDependencyDelete : undefined} - markers={(schema as any).markers} + markers={schema.markers} autoSchedule={!!ganttConfig?.dependenciesField} rescheduleOnConflict={!!ganttConfig?.dependenciesField} - criticalPathDefault={!!(schema as any).criticalPath} + criticalPathDefault={!!schema.criticalPath} workingCalendar={workingCalendar} shiftSegments={shiftSegments} - showBaselines={(schema as any).showBaselines !== false} - readOnly={!!(schema as any).readOnly} - mobileReadOnly={(schema as any).mobileReadOnly !== false} + showBaselines={schema.showBaselines !== false} + readOnly={!!schema.readOnly} + mobileReadOnly={schema.mobileReadOnly !== false} persistLayoutKey={persistLayoutKey} onLayoutChange={filtersStorageKey ? persistFilters : undefined} groupBy={groupByAccessor} @@ -1491,7 +1506,7 @@ export const ObjectGantt: React.FC = ({ // `label` off the schema it hands us — then the bound object's // label, then its API name. String( - ganttConfig?.exportFileName ?? (schema as any).label ?? objectSchema?.label ?? schema.objectName ?? '' + ganttConfig?.exportFileName ?? schema.label ?? objectSchema?.label ?? schema.objectName ?? '' ) || undefined } inlineEdit @@ -1520,7 +1535,7 @@ export const ObjectGantt: React.FC = ({ // Row-level lock (lockField) and global readOnly must also lock the // drawer: omitting onFieldSave/onDelete renders it strictly read-only. const recLocked = - !!(schema as any).readOnly || + !!schema.readOnly || (ganttConfig?.lockField ? !!rec[ganttConfig.lockField] : false); // #2473: prefer the fetched business record + schema over the raw row // payload (see the drawerFetch effect above for why they can differ). diff --git a/packages/types/src/__tests__/gantt-declared-keys.test.ts b/packages/types/src/__tests__/gantt-declared-keys.test.ts new file mode 100644 index 0000000000..6b598c6e08 --- /dev/null +++ b/packages/types/src/__tests__/gantt-declared-keys.test.ts @@ -0,0 +1,200 @@ +/** + * 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. + */ + +/** + * Declaration pin — the ten gantt keys `ObjectGantt` reads that + * `ObjectGanttSchema` did not declare (objectui#5903). + * + * ## What was wrong + * + * All ten were read as `(schema as any).K` in + * `plugin-gantt/src/ObjectGantt.tsx`. Every one is a real, working, documented + * feature (they are named in the package README), but nothing connected the + * read to a declaration: not `tsc`, not the registry `inputs`, not this + * package's zod mirror. An author following the published type could not + * discover any of them. + * + * Eleven keys were reported. `label` is the eleventh and it needed no + * declaration — `BaseSchema` already declares it — so only its cast was + * dropped. `label` is pinned below anyway, because "already declared" is the + * claim that would silently stop being true. + * + * ## What the pin has teeth against, and what it does not + * + * `BaseSchema` is `.passthrough()` on the zod side and carries + * `[key: string]: any` on the TS side (objectui#5155 records that ceiling), so: + * + * - an UNDECLARED key is still accepted, by both halves. Declaring these ten + * did NOT buy rejection of a misspelling, and the test below pins that + * plainly rather than leaving it to be assumed; + * - a DECLARED key IS validated. `readOnly: 'yes'` parsed green before this + * card and is refused now — that is the accept-set narrowing landed here, + * the same one objectui#5074 landed for `viewMode`; + * - on the TS side the index signature means a read site can never be the + * detector: `schema.readOnly` type-checks as `any` whether or not the key + * is declared. So the compile-time pin is the `@ts-expect-error` block at + * the bottom — remove a declaration and its member resolves to `any`, the + * wrong-typed assignment starts succeeding, and the now-unused directive + * fails the build (TS2578) NAMING the key. `tsconfig.test.json` compiles + * this file, so that is real enforcement and not decoration (#3009). + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectGanttSchema } from '../zod/objectql.zod.js'; +import type { ObjectGanttSchema as ObjectGanttSchemaTS } from '../objectql.js'; + +const MINIMAL = { + type: 'object-gantt', + objectName: 'task', + startDateField: 'start', + endDateField: 'end', +} as const; + +/** The ten keys this card declared, each with a value its declared type refuses. */ +const DECLARED: ReadonlyArray = [ + ['skipWeekends', 'yes'], + ['holidays', [1]], + ['persistLayout', 'no'], + ['viewName', 1], + ['navigation', 'drawer'], + ['markers', [{ date: 5 }]], + ['criticalPath', 'on'], + ['showBaselines', 'off'], + ['readOnly', 'yes'], + ['mobileReadOnly', 'yes'], +]; + +describe('ObjectGanttSchema — the ten cast-read keys are declared (objectui#5903)', () => { + it('the mirror declares every one of them', () => { + const shape = Object.keys(ObjectGanttSchema.shape); + for (const [key] of DECLARED) expect(shape, `mirror is missing ${key}`).toContain(key); + }); + + it('declares them all OPTIONAL — none of the ten may become required', () => { + // Requiredness is the half the zod-mirror-parity ratchet compares against + // `../objectql.ts`, where all ten are `?:`. A mirror that required one would + // reject every gantt already published. + for (const [key] of DECLARED) { + const result = ObjectGanttSchema.safeParse(MINIMAL); + expect(result.success, `omitting ${key} must stay legal`).toBe(true); + } + }); + + it('materialises NO defaults — an omitted key stays absent after parse', () => { + // `showBaselines` and `mobileReadOnly` default ON *in the renderer*, which + // reads `!== false`. A `.default(true)` here would arrive downstream as an + // explicit author choice; the two spellings are not interchangeable. + const result = ObjectGanttSchema.safeParse(MINIMAL); + expect(result.success).toBe(true); + if (!result.success) return; + for (const [key] of DECLARED) expect(key in result.data, `${key} must stay absent`).toBe(false); + }); + + it('refuses a wrong-typed value on each declared key (declared-key validation under passthrough)', () => { + for (const [key, bad] of DECLARED) { + const result = ObjectGanttSchema.safeParse({ ...MINIMAL, [key]: bad }); + expect(result.success, `${key} accepted ${JSON.stringify(bad)}`).toBe(false); + if (result.success) continue; + const issue = result.error.issues.find((i) => i.path[0] === key); + expect(issue, `${key} failed, but not on the ${key} path`).toBeTruthy(); + } + }); + + it('accepts a well-typed value on each declared key', () => { + // Counter-probe for the assertion above: it must be the VALUE being refused, + // not the key. A pin that only ever sees red proves nothing. + const good = { + ...MINIMAL, + skipWeekends: true, + holidays: ['2024-06-05'], + persistLayout: false, + viewName: 'shift-plan', + navigation: { mode: 'page' as const, view: 'task_detail', openNewTab: false }, + markers: [{ date: '2024-06-05', label: 'Release', color: '#ef4444' }], + criticalPath: true, + showBaselines: false, + readOnly: true, + mobileReadOnly: false, + }; + const result = ObjectGanttSchema.safeParse(good); + expect(result.success ? null : result.error.issues).toBe(null); + }); + + it('`label` needs no declaration here — BaseSchema already carries it', () => { + // The eleventh reported key. It was cast-read too, but the cast was the only + // defect: dropping it is the whole fix. Pinned so that "already declared" + // cannot quietly stop being true. + const inherited = ObjectGanttSchema.safeParse({ ...MINIMAL, label: 'Shift Plan' }); + expect(inherited.success).toBe(true); + expect(ObjectGanttSchema.safeParse({ ...MINIMAL, label: 5 }).success).toBe(false); + }); + + it('does NOT reject an undeclared key — objectui#5155’s ceiling, measured not assumed', () => { + // Declaring the ten bought validation of DECLARED keys, not rejection of + // undeclared ones: `BaseSchema` is `.passthrough()`. Anyone reading this + // card as "misspellings now fail" is reading it wrong, and this pin says so + // in the one place that cannot rot. + const misspelled = ObjectGanttSchema.safeParse({ ...MINIMAL, readonly: true, skipWeekend: true }); + expect(misspelled.success).toBe(true); + }); +}); + +describe('ObjectGanttSchema (TS) — compile-time pin on the same ten keys', () => { + it('refuses a wrong-typed value on every declared key', () => { + // Each directive below fails the build (TS2578, "unused '@ts-expect-error'") + // the moment its key stops being declared, because the member then resolves + // to `any` through `BaseSchema`'s index signature and the assignment starts + // succeeding. That failure is the signal this card exists to create. + + // @ts-expect-error — `skipWeekends` is declared `boolean | undefined`. + const skipWeekends: ObjectGanttSchemaTS['skipWeekends'] = 'yes'; + // @ts-expect-error — `holidays` is declared `string[] | undefined`. + const holidays: ObjectGanttSchemaTS['holidays'] = [1]; + // @ts-expect-error — `persistLayout` is declared `boolean | undefined`. + const persistLayout: ObjectGanttSchemaTS['persistLayout'] = 'no'; + // @ts-expect-error — `viewName` is declared `string | undefined`. + const viewName: ObjectGanttSchemaTS['viewName'] = 1; + // @ts-expect-error — `navigation` is declared `ViewNavigationConfig | undefined`, an object. + const navigation: ObjectGanttSchemaTS['navigation'] = 'drawer'; + // @ts-expect-error — `markers[].date` is declared `string` (schemas are JSON). + const markers: ObjectGanttSchemaTS['markers'] = [{ date: 5 }]; + // @ts-expect-error — `criticalPath` is declared `boolean | undefined`. + const criticalPath: ObjectGanttSchemaTS['criticalPath'] = 'on'; + // @ts-expect-error — `showBaselines` is declared `boolean | undefined`. + const showBaselines: ObjectGanttSchemaTS['showBaselines'] = 'off'; + // @ts-expect-error — `readOnly` is declared `boolean | undefined`. + const readOnly: ObjectGanttSchemaTS['readOnly'] = 'yes'; + // @ts-expect-error — `mobileReadOnly` is declared `boolean | undefined`. + const mobileReadOnly: ObjectGanttSchemaTS['mobileReadOnly'] = 'yes'; + + expect([ + skipWeekends, holidays, persistLayout, viewName, navigation, + markers, criticalPath, showBaselines, readOnly, mobileReadOnly, + ]).toHaveLength(10); + }); + + it('accepts the well-typed value on every declared key', () => { + // Counter-probe for the directives above: without this, a declaration + // narrowed to `never` would satisfy all ten of them. + const ok: ObjectGanttSchemaTS = { + type: 'object-gantt', + objectName: 'task', + skipWeekends: true, + holidays: ['2024-06-05'], + persistLayout: false, + viewName: 'shift-plan', + navigation: { mode: 'page', view: 'task_detail', openNewTab: false }, + markers: [{ date: '2024-06-05', label: 'Release', color: '#ef4444' }], + criticalPath: true, + showBaselines: false, + readOnly: true, + mobileReadOnly: false, + }; + expect(ok.markers?.[0].date).toBe('2024-06-05'); + }); +}); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index a2b715c575..39d95a26b4 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -1974,6 +1974,104 @@ export interface ObjectGanttSchema extends BaseSchema { * author choice and defeat that seeding. */ viewMode?: SpecGanttConfig['viewMode']; + /** + * Skip weekends in duration / auto-schedule math (objectui#5903). + * + * When true (or when `holidays` is non-empty) `ObjectGantt` builds a + * `WorkingCalendar` and `GanttView` measures durations, cascades and the + * critical path in WORKING days — weekends are stepped over rather than + * consumed. Read at `plugin-gantt/src/ObjectGantt.tsx` (`workingCalendar`). + * + * Declared here rather than derived: the spec's `GanttConfigSchema` models no + * working-calendar member, so this is objectui's own display extension — the + * same standing `timeSegments` has on {@link GanttConfig}. + */ + skipWeekends?: boolean; + /** + * Non-working dates for the same working calendar as {@link skipWeekends} — + * ISO `yyyy-mm-dd` (UTC) keys, e.g. `['2024-06-05']`. Non-empty enables the + * working calendar on its own. Read at `ObjectGantt.tsx` (`workingCalendar`). + */ + holidays?: string[]; + /** + * Opt OUT of layout persistence. `false` disables it; any other value (and + * omission) keeps it on, which is why this is not spelled as an enable flag. + * + * `GanttView` persists its column/zoom snapshot and `ObjectGantt` persists the + * quick-filter chips under a sibling localStorage key, both derived from + * `persistLayoutKey`. Read at `ObjectGantt.tsx` (`persistLayoutKey`). + */ + persistLayout?: boolean; + /** + * Layout-persistence scope. Distinguishes two gantts bound to the SAME object + * so they keep separate saved layouts; the storage key is + * `objectName:viewName` and defaults to `objectName:default`. Read at + * `ObjectGantt.tsx` (`persistLayoutKey`). + */ + viewName?: string; + /** + * Record navigation behaviour when a bar is clicked (drawer / dialog / page). + * Defaults to an inline right-side drawer; set `{ mode: 'page' }` to route to + * the standalone detail page instead. Read at `ObjectGantt.tsx` + * (`navConfig`). + * + * The spec owns the member list — `mode`, `view`, `preventNavigation`, + * `openNewTab`, `size`, `width` — and its schema REFUSES anything else. In + * particular there is no `basePath`: the package README shows one, and no read + * site in this repo consumes it (filed separately). Do not restate the + * vocabulary here; that is the drift this derivation exists to prevent. + * + * Same spec type as {@link ObjectGridSchema.navigation} and + * {@link ObjectViewSchema.navigation} — aligned with `@objectstack/spec` + * `ListView.navigation` rather than restated, so the vocabulary cannot fork. + */ + navigation?: ViewNavigationConfig; + /** + * Extra vertical reference lines drawn like the Today marker (deadline, + * sprint boundary, release…). Forwarded to `GanttView`'s `markers` prop and + * read at `ObjectGantt.tsx`. + * + * `date` is declared as a STRING here, not `Date | string` like the runtime + * `GanttMarker` this feeds: a schema is serialisable authored metadata and a + * `Date` instance cannot survive JSON. The renderer keeps accepting both, + * because a narrower authoring surface is assignable to the wider prop. + */ + markers?: Array<{ + /** Marker position, ISO date or datetime string (e.g. `'2024-06-05'`). */ + date: string; + /** Text drawn against the line. */ + label?: string; + /** Line colour — any CSS colour. */ + color?: string; + }>; + /** + * Start with the critical-path highlight enabled. The toolbar toggle stays + * available either way — this only seeds its initial state. Read at + * `ObjectGantt.tsx` (`criticalPathDefault`). + */ + criticalPath?: boolean; + /** + * Render planned-vs-actual baseline bars when tasks carry baseline dates. + * Defaults to ON — only an explicit `false` turns them off, which is why the + * read site compares against `false` rather than coercing. Read at + * `ObjectGantt.tsx`. + */ + showBaselines?: boolean; + /** + * Read-only mode. Disables every write path — bar drag / resize / progress + * handle, inline edit, delete, dependency-link drag, row reorder, + * auto-schedule and the Undo/Redo buttons — and locks the record drawer. + * Clicking a task and switching granularity still work. Read at + * `ObjectGantt.tsx` (`readOnly`, and the drawer's `recLocked`). + */ + readOnly?: boolean; + /** + * Auto-enter read-only mode on narrow viewports (< 640px) so touch users get + * a scrollable thumbnail instead of error-prone drag editing. Defaults to ON; + * only an explicit `false` turns it off. Independent of (and OR-combined + * with) {@link readOnly}. Read at `ObjectGantt.tsx`. + */ + mobileReadOnly?: boolean; } /** diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 0e0002f71e..539c08a23a 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -613,6 +613,35 @@ export const ObjectGanttSchema = BaseSchema.extend({ viewMode: SpecGanttConfigSchema.shape.viewMode.describe( 'Initial timeline granularity, honoured by both renderer branches; when omitted, a persisted layout may seed it' ), + // objectui#5903 — ten keys `ObjectGantt` reads and this mirror did not + // declare. They were reachable only through `(schema as any).K`, so nothing + // connected the read to a declaration. Mirrored here at the SAME requiredness + // as `../objectql.ts` (all optional) so the zod-mirror-parity ratchet stays at + // zero drift for this pair. `label` is NOT among them: `BaseSchema` already + // declares it, so that read only needed its cast dropped. + // + // What declaring buys under `.passthrough()`: an undeclared key is still waved + // through (objectui#5155's structural ceiling), but a DECLARED key is now + // type-validated — `readOnly: 'yes'` is refused where it used to parse green. + skipWeekends: z.boolean().optional().describe('Skip weekends in duration / auto-schedule math (working calendar)'), + holidays: z.array(z.string()).optional().describe("Non-working dates for the working calendar, ISO 'yyyy-mm-dd' (UTC)"), + persistLayout: z.boolean().optional().describe('Opt OUT of layout persistence — only an explicit false disables it'), + viewName: z.string().optional().describe("Layout-persistence scope; storage key is `objectName:viewName` (default 'default')"), + navigation: SpecNavigationConfigSchema.optional().describe('Record navigation behaviour on task click (drawer/dialog/page)'), + markers: z + .array( + z.object({ + date: z.string().describe('Marker position, ISO date or datetime string'), + label: z.string().optional().describe('Text drawn against the line'), + color: z.string().optional().describe('Line colour — any CSS colour'), + }) + ) + .optional() + .describe('Extra vertical reference lines drawn like the Today marker'), + criticalPath: z.boolean().optional().describe('Seed the critical-path highlight ON (toolbar toggle stays available)'), + showBaselines: z.boolean().optional().describe('Render planned-vs-actual baseline bars — defaults ON, only an explicit false disables'), + readOnly: z.boolean().optional().describe('Disable every write path and lock the record drawer'), + mobileReadOnly: z.boolean().optional().describe('Auto read-only on narrow viewports — defaults ON, only an explicit false disables'), }); /**