v0.2.0
Code / JSON Editor (new component)
Seventh component: mustrysolutions.input.codeeditor — CodeMirror-6
editing and read-only viewing of JSON / Python / SQL / XML / plain text
for config-driven apps (an open gap: only a read-only JSON viewer
existed third-party; nothing native through 8.3.8). Live JSON parse
validation (lint gutter + toolbar badge + output.isValid/errorMessage
to gate commit buttons on), Format JSON, folding, search, bracket
matching, undo/redo (bound content history-exempt), the module's
standard controlled save/dirty model (onSave), display mode,
7-language labels, --code-* theming incl. a CSS-variable syntax
palette. Demo at /code; 5 e2e tests + pure-logic jest suite.
Bundle 656K→1.25M minified (~350K gz) — CodeMirror + 4 grammars;
lazy-loading language chunks is the noted follow-up trim.
Rich Text Editor (new component)
Sixth component: mustrysolutions.input.richtexteditor — true WYSIWYG
editing and safe read-only display of operator instructions/SOPs/notes
(the top remaining validated Perspective gap; research July 2026). Built
on TipTap core (vanilla — Perspective pins React 16, so the editor
mounts imperatively, gesture-controller style). Controlled write-back
(onSave), schema-constrained sanitization with URL allowlists,
feature-flagged formatting (headings/lists/links/tables/images/
checklists), interactive display-mode checklists (onTaskToggle),
char limit, plainText/wordCount/charCount outputs, 7-language labels,
--rte-* theming, print stylesheet. Undo/redo toolbar
buttons (bound content is history-exempt — Ctrl-Z can never blank the
document), an image-library picker fed by bound data.imageLibrary
(gateway Image Management paths like /system/images/... work
directly), and a config.fonts allowlist font picker (display mode
always renders saved fonts). Demo at /rte incl. theme toggle; 10 e2e
tests + pure logic jest suites. Bundle grows 245K→656K minified (~180K gz) — first
runtime dependency, trim pass noted for later.
Build & CI
- The web bundle is now a production webpack build by default: minified JS
- CSS, no source maps (JS 508K → 240K, CSS 68K → 55K,
.modl~391K → ~110K).
./gradlew build -PwebDevrestores the unminified dev bundle with source maps.
- CSS, no source maps (JS 508K → 240K, CSS 68K → 55K,
- New Playwright e2e smoke suite (
e2e/,ops/e2e.sh): renders every
component route of the verify project in a real Perspective session — mount,
key interactions (preset write-back, view switch, group collapse, quick
filter, 50k-row virtualization, embedded-view click, fly-to), zero console
errors. Runs in CI on every push/PR. - Unattended gateway bring-up (
ops/e2e.sh --fresh): headless
commissioning + module-cert pre-acceptance by merging the dev cert's SHA-1
intodata/modules.json— no browser wizard. Used by CI; locally it's also
the quickest fix for an expired Perspective trial.
Internal
- CSS classes namespaced: every
.cal-/.tml-/.dg-/.pz-/.dtrp-
class is nowmustry--prefixed (845 renames), eliminating collision risk
with other third-party modules' global styles on a shared Perspective page.
The documented theming CSS variables (--cal-*,--tml-*, …) are
unchanged. Class names were never a documented API; anyone who targeted
them in a project stylesheet must add themustry-prefix. - Build modernized: Gradle 7.6 → 8.14.3 (zero deprecations),
io.ia.sdk.modl0.4.0 → 0.5.0, node-gradle plugin 3.5.1 → 7.1.0,
settings.gradle→ Kotlin DSL (dropping the meaningless root include),
deprecated$buildDir→layout.buildDirectory, and the:webcross-project
task mutation removed. The web bundle's double scope declaration resolved:
modlImplementation(project(":web"))in the gateway is the load-bearing one
(verified:projectScopesalone drops the jar from module.xml), so the
redundantprojectScopesentry is gone. - TypeScript full
strict(adds strictFunctionTypes/BindCallApply/
PropertyInitialization/noImplicitThis over the previous partial flags), plus
noImplicitReturnsand target es6 → es2019. Only five errors surfaced — all
the PerspectivePComponent/PlainObjectboundary variance, now explicit
casts atgetViewComponent()with the props reducer as the runtime guarantee. - One Java component registry (
comp/Components.ALL): both hooks iterate a
single list for register/remove, and descriptor assembly moved into one
factory — the per-scope hand-maintained lists (and their drift) are gone. - Uniform props-schema strictness: every schema root and section now
rejects unknown keys (additionalProperties: false), matching the picker's
standard; the resourcetimeline also gained thestylesection the other
components already declared. - God components decomposed following the calendar's pattern: picker
(1120→972 + CalendarPane/Presets/Inputs/Trigger), timeline (1019→863 +
Toolbar/Legend/Track), grid (843→741 + Toolbar/Cells);labelPacks.ts
(789) became a barrel over per-component modules inshared/labels/. - Shared drag-gesture lifecycle (
shared/dragGestureController.ts): the
calendar and timeline gesture controllers now extend one base owning pointer
capture, document listeners, the click-vs-drag threshold (incl. the larger
touch threshold), cancel semantics and commit dispatch — previously
copy-pasted per component and already drifting (the timeline had re-inlined
the movement threshold). Controllers keep only their geometry. No behavior
change; covered by new drag/click e2e tests. Pan & zoom deliberately stays
separate — its continuous multi-pointer model shares no lifecycle with the
preview/commit gestures.
Cross-component parity pass: each component's best ideas ported to the others
where they add real value.
BREAKING — prop sections harmonized (sanctioned pre-1.0, no deployments)
Both display components now follow one sections convention (the picker's
selection already embodied it): config = set-and-forget configuration,
data = bound content, state = two-way runtime state (the component
writes user interactions back; everything pre-settable/bindable), output
= read-only derived values. Moved keys (names unchanged):
- Calendar:
config.view→state.view,config.followNow→
state.followNow,output.hiddenCategories→state.hiddenCategories. - Timeline:
config.zoom→state.zoom,config.followNow→
state.followNow,config.collapsedGroups→state.collapsedGroups,
output.hiddenCategories→state.hiddenCategories. hiddenCategoriesis now genuinely two-way on both: pre-set or bind it
to open with categories pre-filtered (it was a read-only output mirror).
Calendar / Scheduler
- CSV export now includes
data.recurringEvents(series definitions, deduped
by id) — previously recurring series were silently missing from exports. - Recurring occurrences carry the same ↻ marker as the timeline (month chips,
week/day chips and all-day bars, list view, day popover). - Both-edge resize in week/day views (timeline parity): a top handle moves the
start, the bottom handle the end; multi-day segments get their one legal
handle; handles suppressed on chips too short to also grab-move. - Fixed: resize had no noop guard — a snapped-back resize fired a phantom
onChange. Both edges are now guarded (moves already were). - New
config.shifts([{label, start: 'HH:mm'}]): dashed shift-boundary
lines with gutter labels in week/day views; shared parser in
shared/shifts.ts(timeline can adopt it later). Themeable via
--cal-shift-line. onChangeevent schema now documents the recurrence context
(scope/seriesId/occurrenceDate) the payload already carried.- Follow-now live mode (timeline parity): two-way
config.followNow+
toolbar "Live" toggle with pulsing dot; re-anchors on today every
refreshSeconds(else 60s); paused mid-edit/drag; paging or a mini-nav pick
disarms, Today and view switches do not. output.visibleStartMs/visibleEndMs: the window as DST-correct epoch
instants (zone-local midnights), for bindingt_stampqueries directly.- Hover popover status badge and "All day" text are now localized via the
label packs (labels.statusTentative/statusCancelled/statusDone) — they
were hardcoded English. - Fixed (both display components): the
emptyMessagedefault ("No
events") is now treated as "unset" and follows the locale packs
(labels.noEvents) — previously a French calendar showed an English badge.
Any other value overrides;''still hides the badge.
Resource Timeline
- Empty-state badge with context-aware how-to tooltip (calendar parity) +
config.emptyMessage. - Follow-now live mode (ported from the picker's realtime mode): two-way
config.followNow+ toolbar "Live" toggle with pulsing dot; re-anchors the
window so the now-line stays in view (tick =refreshSeconds, else 60s);
paused while editing/dragging; manual paging or a mini-nav pick disarms,
Today does not. Same Designer-dirty caveat as the picker's realtime mode. - Resource
color/icon(already in the schema) are now rendered on row
labels (icon-else-dot, matching the legend). onChangeevent schema documentsscope/seriesId/occurrenceDate.- Fixed: the toolbar CSV-export icon rendered at zero size (the SVG was
never given dimensions — calendar parity); surfaced by the first fixture to
enableshowExporton a timeline. output.visibleStartMs/visibleEndMs: the window as raw epoch ms, for
bindingt_stampqueries directly.- Hover popover shows a localized status badge (tentative/cancelled/done),
calendar parity — newlabels.status*keys in all 7 languages. - Enter animation on newly-appearing bars/state bands (calendar parity, shared
EnterTracker); never animates mid-drag; respectsprefers-reduced-motion. config.snapMinutes: one snap override for drag-move, both resizes,
drag-create and click-to-create at every zoom (0 = per-zoom default).- Internal: shift parsing now uses the shared
shared/shifts.tsmodule
(calendar parity, duplicate deleted). - Fixed: with Live armed, a zoom (or timezone) change didn't re-anchor
until the next tick — at Hour zoom the window could sit up torefreshSeconds
without the now-line, making Live look broken. Zoom/timezone changes now
re-anchor immediately (calendar gets the same immediate re-anchor on a
timezone change). config.showMiniNav(calendar parity):falserenders a plain title with
no mini month navigator.- Fixed (the real "Live looks dead" bug): the board scrolls horizontally
within the window, and arming Live never scrolled the now-line into view —
a correct window could still show 00:00–14:00 at 20:00. Follow ticks now
scroll the now-line to ~60% of the viewport whenever it drifts off screen
(and only then, so manual scrolling isn't fought while it's visible). The
calendar equivalently re-centres its week/day grid on the now indicator
while armed whenscrollToNowis on.
Date/Time Range Picker
- Popover is now a real dialog:
role="dialog",aria-modal, localized
aria-label(newlabels.dialogLabel, all 7 languages), focus moves in on
open, Tab is trapped, and focus returns to the trigger on close (except
after an outside click, which keeps focus where the user clicked).
Data Grid (new component, M0 — mustrysolutions.input.datagrid)
- First cut of the fourth component (custom build, no library — see
docs/data-grid-plan.md): a read-only virtualized grid. Columns from
config.columns({field, header, width, pinned, align}); fixed
config.rowHeight(the virtualization contract); frozen columns +
sticky header via the timeline's one-scroll-container layout; zebra
rows, hover, per-column alignment, ellipsis + title tooltips; loading
bar + localized empty badge (7-languagelabels.noRows);--dg-*
theming verified light + dark. ~24 DOM rows regardless of data size
(2,500-row demo at/grid). - M1 core interactions, all two-way via the grid's
statesection:
header-click sorting (asc/desc/off; type-aware compare, empties last,
stable), a toolbar quick filter (contains across all columns, match
count, local-draft typing so gateway round-trips never eat keystrokes),
row selection (config.rowSelectnone/single/multi with Ctrl-toggle +
Shift-range over the visible order, ids viaconfig.idField, count badge),
and CSV export of the current view (filtered + sorted, BOM +
injection-guarded). - M1 complete — column layout as two-way
state.columnLayout: drag a
header to reorder, drag its edge handle to resize, hide/show via
the toolbar column chooser (config.columns stays the authoring truth;
the state layers on top, pre-settable/bindable). Typed columns
(number with locale grouping + fixed decimals, date/datetime localized,
boolean check/dash) — the quick filter and CSV match the displayed text.
Conditional cell styling (per-column rules: equals / gt / lt /
contains -> color/background, first match wins). - M2 — the editing core (the reason this component exists; controlled
like the calendar/timeline — the grid never mutates its own data):- Typed cell editors: text, number, date, datetime, and
dropdown-in-cell (column.options) — the native Table's most
hacked-around gap. Boolean columns render a live checkbox. - Declarative validation before commit (
required/min/max/
pattern/options): invalid drafts show a red editor + localized
message and never fire; Escape reverts; blur commits-or-reverts. onCellEdit{rowId, field, oldValue, newValue, row}after
validation; the value overlays optimistically (italic + dot) until
the author's write-back rebindsdata.rows.config.editable
master switch + per-columneditable.onRowAdd/onRowsDeletewith toolbar buttons
(config.allowAdd/allowDelete; delete acts on the selection).- Excel keyboard model: arrows/PageUp/Down/Home/End move the
focused cell (auto-scrolled into view), Enter/F2/type-to-edit open
the editor, Enter/Tab commit + move (Shift-Tab left), all localized
(8 new label keys x 7 languages).
- Typed cell editors: text, number, date, datetime, and
- M3 core:
config.editMode: 'batch'— commits accumulate (italic +
dot, "{n} unsaved" badge, read-onlyoutput.dirtyCount), Save fires
ONEonBatchSave{edits, rows}, Discard reverts; overlays reconcile
away when the write-back rebinds matching values (identity checks are
useless — the reducer rebuilds rows on every prop write). Excel range
paste onto the focused cell (TSV matrix over visible columns, skipping
non-editable/invalid cells; accumulates in batch, fires per-cell in cell
mode). Aggregate footers (column.aggregate: sum/avg/min/max/count)
over the current view, sticky + locale-formatted; empty cells are absent,
not zero. Manual checklist: docs/grid-manual-test.md. - 50k-row claim validated (
/grid-stress, generated fixture — 5.6 KB
committed): sort ~140 ms, quick filter ≤ 55 ms/keystroke, selection ~2 ms,
exact virtualization at any depth. The honest constraint is the initial
~6 MB props sync (25–40 s; background tabs can drop the session websocket)
— guidance documented: prefer query-side filtering above ~10k rows.
Fixed (found by the stress test): filtering while scrolled deep left a
blank grid — the virtualization window now clamps both ends into the
dataset and renders the tail.
Pan & Zoom View (new component, M0/M1 — mustrysolutions.display.panzoomview)
Fifth component: embeds ANY Perspective view (config.viewPath +
config.viewParams) inside a clipped viewport and navigates it like a map.
Custom build on perspective-client's publicly exported View component (no
third-party libs); plan: docs/panzoom-view-plan.md.
- Navigation: drag to pan (house threshold + click-suppression pattern, so
buttons/inputs INSIDE the embedded view keep working — verified live with a
click-counter inside the transformed view), wheel zoom toward the cursor
(native non-passive listener; React's delegatedonWheelis passive and
can'tpreventDefault), double-click zoom, +/−/home/fit control buttons,
live zoom badge. - Two-way
state.zoom/state.center(content coordinates): bind or
script them to fly the viewport ("center on Pump 3 on alarm") — verified
live from a demo button script. Gestures write back debounced with the
house draft/echo-reconciliation pattern; zoom0= unset (resolves to
home). config.home{x, y, zoom}is the reset/initial target (zoom0 =
fit content,x/y-1 = content center); pan clamped so ≥25% of the
viewport always shows content (smaller-than-viewport content stays
centered); zoom clamped toconfig.minZoom/maxZoom.output.viewStatesurfaces the embedded view's ViewStateType so
authors can react to a badviewPath.- All geometry in pure
panZoomLogic.ts(fit/clamp/resolve/transform/
zoom-at-point/pan) with Jest coverage;--pz-*theming; palette icon. - Known quirk (documented in the plan): popups/portals inside the embedded
view portal to the document and escape the transform (unscaled).
M2 polish (same cut):
- Pinch zoom: two fingers zoom by the distance ratio anchored at the
moving midpoint (purepinchViewport, unit-tested; synthetic two-pointer
events verified exact — real-hardware pass joins the standing tablet
item); lifting one finger hands over to a pan; a pinch never fires a
click into the embedded view. - Fly-to smoothing: an external
state.zoom/centerwrite animates the
viewport over newconfig.flyToMs(default 350 ms, 0 = snap; zoom eases
in log space via pureflyStep). Visual-only (no state writes), snaps in
hidden tabs (rAF doesn't run there) with a safety-net timer, cancels the
moment a gesture takes over, and the component's own write-echo never
re-animates. - Localized control tooltips via new
config.locale(en/fr/de/es/nl/it/pt). - Gesture internals reworked for multi-pointer: up/cancel listen on window so
a release outside the viewport can't leak a tracked pointer. - Manual checklist: docs/panzoom-manual-test.md; dark-mode pass done
(--pz-*chrome verified against the dark session theme).
M3 navigation aids (same cut):
- POIs (
data.pois,[{name, x, y, zoom, flagged}]): named fly-to
targets. Write a name to new two-waystate.targetto fly there from
any script/binding — no coordinates needed; the component clears the
target back to''so the same name re-triggers.zoom0 keeps the
current zoom. A localized "Go to…" list over the viewport
(config.showPoiList) offers them to the operator. - Minimap (
config.showMinimap): corner overview with the content box,
POI dots and the current view rectangle — click to jump, drag to pan
(zoom unchanged). Auto-hides while the whole content is visible. - Flagged POIs (
flagged: true— bind it to alarm state): a pulse ring
marks the POI while visible (click-transparent), and when it's off-screen
a clickable edge indicator chip (name + arrow toward the real
location) flies to it — the viewport tells the operator where to look. pinchViewport/edgeIndicator/minimap geometry all pure + unit-tested
(387 tests); demo gained POIs, astate.targetfly button and an alarm
toggle.
M4 feel + robustness polish (same cut):
- Proportional wheel zoom: the zoom factor now scales with the wheel
delta (wheelZoomFactor) — a mouse tick is still exactly one
config.zoomStep, but trackpad pinches (many small deltas) zoom smoothly
instead of notching. - Inertia panning: releasing a flick glides the view out with
exponential friction (dragVelocity/glideFrame, τ = 325 ms); hitting a
pan bound stops that axis; the rest position is written once at the end;
grabbing mid-glide (or mid-fly) freezes the view where it is. - Rubber-band overpan: live drags stretch past the pan bounds with
iOS-style hyperbolic resistance (rubberBandCenter) and spring back to
the hard clamp on release; the written state is always the clamped
position. prefers-reduced-motion: fly/glide/spring animations snap and the
POI pulse ring holds still (the marker itself stays visible).- Auto content size:
config.contentWidth/Heightdefault to 0 =
auto — the component adopts the size the embedded view reports, so the
most error-prone config is now optional. Explicit sizes still win. - Fixed: fly-to painted the DESTINATION for one frame before the
flight began (the props write lands synchronously before the first
animation frame) — the draft now pins the starting position through the
write, so flights start from where the view actually is.
Verify harness
- The three demo views are now evergreen:
now(0)expression bindings with
script transforms seed the data relative to today on every view load
(in-session edits still stick), so the demos never go stale. Main(/) is a picker showcase: a twoMonths instance with rolling +
calendar presets and realtime enabled, live output readouts
(startDateTime/endDateTime/durationLabel/isRealtime), plus the
labelled oneMonth / compact / popover gallery./calendar(calendar demo, was/demo) gained shifts, a Quality category, recurring series in
data.recurringEvents, and the dual-list write-back script (events +
recurringEvents, matching the timeline's)./timelinegainedshowExport, resource icons/colors, and the same
evergreen treatment; new/timeline-emptyfixture (empty-state badge).- Removed the 1.4 MB
CalendarStress/TimelineStressfixtures (P2 perf pass
is signed off; restore from git history if ever needed). - Routes renamed component-first:
/picker(alias of/),/calendar,
/calendar-db,/calendar-empty,/timeline,/timeline-db,
/timeline-empty(docs + skill updated). - New
/panzoomroute (PanZoomDemo): hosts the Pan & Zoom View embedding
SynopticDemo— a 2400×1500 plant-floor coordinate view with an interactive
click-counter proving embedded interactivity survives the transform — plus
"Fly to Pump 3" / "Fly home (fit)" buttons scripting the two-way state. - Each demo has a session-theme dropdown (light/dark + warm/cool variants)
that writessession.props.theme— all three components verified rendering
correctly in dark. Note: a full page reload starts a fresh Perspective
session, which resets the theme to the project default.