An Ignition 8.3.6 module that adds custom Perspective components, written as React/TypeScript module components. It ships fourteen components: a Date/Time Range Picker, a Calendar / Scheduler, a Resource Timeline (scheduling board), an editable Data Grid, a Pan & Zoom View, a Branching Diagram, a Rich Text Editor, a Code/JSON Editor, a Color Picker, an On-Screen Keyboard, and the complete admin family: Schedule Manager, Roster Manager, User Manager and Holiday Manager.
- Module ID:
com.mustrysolutions.perspective.components - Palette category:
Mustry Solutions
Resource Timeline — hour/day/shift/week scheduling board with grouped resources, status overlays and a live now-line
Requirements: an Ignition 8.3.6+ gateway. Maker Edition gateways are supported: the module declares itself Maker Edition compatible, which Maker requires of every module it loads.
- Download the latest
Mustry-Perspective-Components.modlfrom the Releases page. - Install it on the gateway — in the Gateway web interface, open the Modules section of the config and install/upgrade with the
.modl. Accept the signing certificate and the license when prompted; the module loads immediately (no gateway restart needed). - Use the components — open a Perspective view in the Designer and find them in the palette under the Mustry Solutions category. Drag one in and bind its
data.*props; components are controlled (they fire events likeonCellEdit/onScheduleSaveand never mutate your bound data), and expose read-onlyoutput.*and two-waystate.*props for bindings and scripting.
The module is free (Apache-2.0) — no trial, activation, or fees. Per-component props, events, and examples are documented below; each has a live demo in the bundled ops/verify/ project.
The components are free and open-source under the Apache-2.0 License — use them in any Perspective project, on any number of gateways, at no cost. The module is not license-gated: there's no trial or activation. Bundled open-source components are credited in THIRD-PARTY-NOTICES.md.
Need more than the components? Professional support, custom components, and integration work are available from Mustry Solutions — get in touch at mustrysolutions.com/contact-us or start a Discussion.
Contributions are welcome — see CONTRIBUTING.md, SECURITY.md, and our Code of Conduct.
A Booking.com-style start/end date-time range picker. Component id mustrysolutions.perspective.input.datetimerangepicker.
- Range selection with a hover preview band and a two-click (anchor → endpoint) state machine.
- Responsive layouts —
compact,oneMonth,twoMonths, orauto(size-driven via breakpoints). - Inline or popover display (
config.display) — popover shows a trigger field and floats the calendar in a portal. - Time precision via
config.granularity(day/hour/minute/second). - Selectable-range constraints —
disableDates(past/future/none),dateBounds, andspanDaysmin/max, with tooltips explaining why a day or preset is disabled. - Presets —
rolling(amount × unit from now — looking back by default;disableDates: 'past'flips them forward and relabels "Last …" → "Next …" for booking UIs) andcalendar(today, this/last week/month/year…); conflicting presets are auto-disabled. - Realtime mode (opt-in,
config.realtime) — clicking a rolling preset arms a live window that re-derives from "now" everyrefreshSeconds(default 300; the Alarm-Journal-style "last 8 hours, live"); the armed preset shows a pulsing dot, any manual selection stops it, andoutput.isRealtimereports the state. The armed window lives inselection.rollingAmount/rollingUnit(writable), so a dashboard can open already-live without a click. Each tick rewrites the selection, so bound queries re-run at that rate — lowerrefreshSecondsdeliberately. Designer note: while a window is armed, the ticking selection writes will keep marking an open view as modified in the Designer; disarm (setselection.rollingAmountto 0) or leaveconfig.realtime.enabledoff while designing. - Localisation —
config.timezone,config.locale(dates and the built-in UI-text language: en, fr, de, es, nl, it, pt),config.weekStart, and per-key UI-string overrides viaconfig.labels. - Theming — every colour is a CSS variable defaulting to the active Perspective theme (see Theming).
- Component events —
onRangeChanged,onPresetSelected.
All public props are grouped under config / selection / output (+ standard style). Each prop carries an inline description visible in the Designer; this is the high-level map.
config — behaviour & appearance
| Prop | Notes |
|---|---|
enabled |
When false, display-only and dimmed. |
display |
inline | popover. |
popover |
{ placeholder, closeOnSelect, dateFormat } (popover trigger). dateFormat tokens: YYYY YY MM M DD D; 24h time auto-appended per granularity. |
disableDates |
none (default) | future | past. Also sets the rolling direction: none/future make rolling presets and the realtime window look back from now ("Last 24 hours"); past mutes past dates and flips them forward ("Next 24 hours" — booking-style UIs). |
dateBounds |
{ earliest, latest } (YYYY-MM-DD). |
spanDays |
{ min, max } allowed range length. |
granularity |
day | hour | minute | second. |
durationLabelThresholdHours |
Below this span, the label shows time units instead of days. |
weekStart |
monday | sunday. |
timezone, locale |
Empty = browser/session default. |
layout |
auto | compact | oneMonth | twoMonths. |
breakpoints |
{ compactBelowWidth, compactBelowHeight, twoMonthsAboveWidth } (drive auto). |
showClear, showPresets |
Toggle the Clear button / preset row. |
presets |
[{ label, type, rolling:{amount,unit}, calendar:{period} }]. |
realtime |
{ enabled, refreshSeconds } (default off / 300 s) — rolling presets arm a live window that re-derives from now (see Features; note the Designer caveat there). |
labels |
Override UI text: startTime, endTime, startDate, endDate, clear, selectRange, invalidRange, sameDay, previousMonth, nextMonth. |
selection — two-way; set to pre-select
startDate, endDate (YYYY-MM-DD); startTimeSec, endTimeSec (seconds since midnight); rollingAmount, rollingUnit (the armed live window when config.realtime.enabled; rollingAmount: 0 = not armed — write these to open a view already-live).
output — read-only, derived
startDateTime / endDateTime (ISO 8601 + offset), startEpochMs / endEpochMs (UTC ms), durationDays, durationHours, durationLabel, isValid, isRealtime.
onRangeChanged— fires when the selection or its derived outputs change. Payload mirrorsoutput.*.onPresetSelected— fires on a preset click. Payload:{ label, type, amount, unit, period }.
Colours come from CSS custom properties that default to the active Perspective theme (the --neutral-* scale for text/border/background, --callToAction for the accent), each with a hex fallback. Override them — without rebuilding — via a Perspective style class on the component or the project stylesheet:
.mustry-datetime-range-picker { --dtrp-accent: #2e7d32; --dtrp-range: #c8e6c9; }Variables: --dtrp-accent, --dtrp-accent-text, --dtrp-range, --dtrp-text, --dtrp-muted, --dtrp-border, --dtrp-bg.
A month / week / day / list calendar bound to a list of events. Component id mustrysolutions.perspective.display.calendar. Built from scratch (no FullCalendar / no third-party licence).
- Views — Month, time-based Week & Day (with overlap-packed events), and a List/agenda view; switchable from the toolbar.
- Busy days — month cells auto-fit as many events as the cell height allows, then collapse to "+N more"; clicking it (or the date number) opens a popover listing all that day's events (each clickable to open/edit).
- Data-bound — renders
config.data.events(a JSON array) in a single pass; emits the visible window so bindings fetch only what's shown. - Multi-day events — multi-day all-day events render as continuous spanning bars (month grid + week/day all-day strip), lane-packed so they stack; multi-day timed events show a clamped segment on each day they cross (week/day grid).
- Editable (
config.editable) — drag an event to move it, drag its bottom edge to resize (week/day); in month view, drag a chip or spanning bar onto another day to move it by whole days (time of day kept, drop target highlighted); selectable (config.selectable) — drag empty time to create. - Built-in editor (
config.builtInEditor) — create via a popup form (withselectable), and click an event to edit or delete it (witheditable). - One change event —
onChangefires for every data mutation (create / edit / delete / move / resize) with{ action, event }, so a single script persists the change and triggers any downstream logic. - Categories, icons & legend — define
config.categories({id, label, color, icon}); an event'scategorysupplies its colour and an optional icon (any Ignition icon path, e.g.material/build), shown on every event and in the bottom legend.⚠️ The icon id must exist in the gateway's icon library (browse them in the Designer's icon picker) — the gateway bundles a subset of Material Icons (water_drop,checklist,inventory,restart_altare among the missing), and a missing id renders no icon and logs aReact.cloneElement(...): nullconsole error from Perspective's icon pipeline. The legend is interactive — click an item to filter that category in/out (state.hiddenCategories, two-way: pre-set or bind it to open pre-filtered); hide the whole legend withconfig.showLegend = false. - Event status — an optional
event.status(tentative/cancelled/done) restyles the event (striped/faded, struck-through); unset renders as a normal solid event. - Mini-month navigator — the toolbar title opens a compact month picker to jump anywhere (
config.showMiniNav). - CSV export —
config.showExportadds a toolbar button that downloads the loaded events as a CSV (calendar-events.csv). - Recurrence — events can carry an
rrule(daily / weekly-by-weekday / monthly / yearly), expanded per visible window. The built-in editor creates and edits recurring events: a Repeat control (frequency · every-N · weekly weekday picker · ends never/on-date/after-N), and when editing an occurrence an apply-to choice — This event (a per-occurrence exception viarrule.exdate+ a standalone override) or All events (the whole series). Dragging a single occurrence detaches it the same way.onChangecarriesscope(series/occurrence) +seriesId/occurrenceDateso your write-back can persist the right thing. - Background overlays — events with
display: "background"render as translucent bands (e.g. downtime / availability) behind the time grid. - Localisation & theming —
weekStart,locale(drives all date/weekday/month names and picks the built-in UI-text language: en, fr, de, es, nl, it, pt), andconfig.labelsto override any individual UI string (toolbar views, Today, the editor, "+N more", "all-day"…) for translation or branding; CSS-variable theming that follows the Perspective theme. The bundled translations are pragmatic, not native-reviewed — override per key where wording matters.
The calendar is a controlled, read-from-data component. It never changes config.data.events itself. To show events, you populate that array (statically, or by binding it to a Named Query / dataset). Editing gestures only fire component events — to actually add/move/resize an event you handle the event and write back to your own source:
There are two kinds of events. Intent events (onEventClick, onDateClick, onSelect) say "the user did something" — wire them when you build your own editing UI. The change event (onChange) says "the data should change" — it's the single hook for persistence.
| To… | Do this |
|---|---|
| Show events | Set / bind config.data.events. For DB-backed calendars, fetch only the visible window: bind it to a query scoped by output.visibleStart/visibleEnd (use an overlap predicate: start < :end AND end >= :start), and bind config.data.recurringEvents to a small always-loaded query (WHERE rrule IS NOT NULL) so a windowed query never silently drops a series. Bind config.loading to the query state for a stale-while-revalidate bar. See the recipe + /calendar-db fixture. |
| Persist any change | Handle onChange — it fires for create / edit / delete / move / resize with { action, event }, where event always carries the final start/end. Upsert on every action except delete, where you remove by id. This is the one handler you need. |
| Edit / delete in-place | Set config.editable = true and config.builtInEditor = true; clicking an event opens the editor pre-filled (Save / Delete → onChange). |
| Use your own editor | Leave builtInEditor off; handle onSelect (create) and onEventClick (open your form). Moves/resizes still fire onChange. |
So if you drag on the calendar and "nothing happens", that's expected — the gesture fired onChange (or onSelect); the event appears only once your handler writes it back into config.data.events.
The one-handler recipe (onChange) — upsert-or-delete by id, covering every mutation. (This is the minimal version for non-recurring data; if you use rrule, use the scope-aware version in docs/calendar-manual-test.md, which also honours scope/seriesId/occurrenceDate.)
ev = event.event
row = {"id": ev.id, "title": ev.title, "start": ev.start, "end": ev.end,
"allDay": ev.allDay, "category": ev.category, "color": ev.color,
"status": getattr(ev, "status", ""), "display": getattr(ev, "display", ""),
"description": ev.description}
events, found = [], False
for e in self.props.data.events:
cur = dict(e)
if cur.get("id") == ev.id:
found = True
if event.action == "delete":
continue
cur = row
events.append(cur)
if event.action != "delete" and not found:
events.append(row)
self.props.data.events = eventsconfig | view (month/week/day/list, two-way) · showToolbar · showMiniNav (title opens a mini-month picker) · showExport (toolbar CSV-download button) · editable · selectable · builtInEditor (built-in editor popover — for create with selectable, and edit/delete with editable) · weekStart (monday/sunday) · locale · timezone (IANA zone, e.g. America/Chicago; converts event instants and today/now to that zone, empty = browser-local) · showWeekends · dayStartHour / dayEndHour / scrollToHour (week/day time axis) · slotMinutes (week/day grid resolution + snapping — a divisor of 60: 60/30/15/10/5; finer = sub-hour gridlines, taller scrollable grid) · scrollToNow (centre week/day on the current time when today is in view) · refreshSeconds (re-render every N seconds so the now-indicator ticks live; 0 = off) · loading (bind to your query state → thin loading bar + stale-while-revalidate) · refetchDebounceMs (coalesce rapid navigation into one visibleStart/End write; default 150, 0 = immediate) · showLegend · emptyMessage (subtle header badge + list message when no events are configured; empty string = off) · categories ([{id, label, color, icon}]; icon = Ignition icon path) · labels (override any built-in UI string — defaults follow locale for bundled languages, else English; {n}/{tz} are substituted).
config.data.events — array of event objects:
| Field | Notes |
|---|---|
id |
echoed back in events (use for write-back) |
title |
label |
start |
ISO YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss |
end |
optional; exclusive for all-day multi-day |
allDay |
boolean |
color |
any CSS colour (overrides the category colour) |
category |
category id (see config.categories) — supplies the colour, icon + legend grouping unless color is set |
status |
optional tentative / cancelled / done — restyles the chip (striped/faded, struck-through) |
description |
optional text shown in the hover popover |
display |
"background" for a downtime/availability band |
rrule |
{ freq: daily|weekly|monthly|yearly, interval?, count?, until?, byweekday?[], wkst?, exdate?[] } (byweekday: 0=Sun..6=Sat; wkst: the week's first day for interval > 1, 0=Sun..6=Sat, default 1 = Monday as in RFC 5545; exdate: YYYY-MM-DD occurrences to skip) |
state (two-way) | view (the toolbar writes the user's choice back; setting it switches the view) · followNow (the Live toggle) · hiddenCategories (legend filter — pre-settable/bindable). \n**output** (read-only) | visibleStart, visibleEnd (half-open [start, end) — bind your query: date >= visibleStart AND date < visibleEnd) plus the epoch-ms twins visibleStartMs/visibleEndMs.
Intent (the user did something): onEventClick (full event) · onDateClick ({date}) · onSelect ({start, end, allDay} — dragged-out empty range).
Change (the data should change): onChange ({ action: create|edit|delete|move|resize, event }) — fires for every mutation; the single hook for persistence and triggering downstream logic. event always carries the final start/end, so write-back needs no second lookup.
When the built-in editor is on (
builtInEditor+editable), clicking an event opens the editor instead of firingonEventClick, and create/edit/delete all surface throughonChange.
Override the --cal-* CSS variables via a style class / project stylesheet: --cal-accent, --cal-accent-text, --cal-text, --cal-muted, --cal-border, --cal-bg, --cal-weekend-bg. They default to the active Perspective theme.
Manual test checklist:
docs/calendar-manual-test.md.
A scheduling board: resources (machines, lines, crews) as rows on a zoomable horizontal time axis. Component id mustrysolutions.perspective.display.resourcetimeline.
- Rows & groups —
config.resourcesrenders in array order; consecutive equalgroupvalues share a sticky section header. Click a header to collapse/expand its section (chevron + hidden-row count;state.collapsedGroupsis two-way, so a view can open pre-collapsed or drive it from a binding). The label column, time axis and corner are all sticky, so both scroll directions stay aligned. - Epoch-linear time scale with stepped zoom presets (
state.zoom:millisecond/second/minute/hour/day/shift/week, two-way) — each preset sets density, paging span and gesture snapping. The toolbar offershour/day/shift/weekby default;config.zoomspicks and orders the buttons (e.g.["millisecond", "second", "minute", "hour"]for a cycle-time view —minuteis a 30-minute window ticked per minute,seconda 2-minute window ticked every 5 s,milliseconda 10-second window ticked every 500 ms and labelled to a tenth; event times may carry milliseconds, and emitted instants keep them). Theshiftpreset appears whenconfig.shiftsis set ([{label, start: 'HH:mm'}]): a day-wide window whose lower ticks and gridlines sit on the shift boundaries, labelled with the shift names. Changing zoom keeps the current time in view when it was visible (drilling from a shift into the running cycle lands on the cycle); Today likewise opens the window containing now. DST days render as their real 23/25 hours; tick labels followconfig.timezone+config.locale. - True-width short bars — bars normally floor at 12px so a 5-minute job at week zoom stays grabbable, but on a read-only board at the sub-hour presets that floor would draw a 40 ms phase as a full second, so it drops to a 3px hairline that renders at its real width. Editable boards keep the grabbable floor.
- Three display kinds per event:
bar(default — lane-packed when overlapping),state(full-height contiguous band, e.g. machine states; no end = ongoing, runs to the window edge) andbackground(translucent span behind everything). - Editable (
config.editable) — drag a bar to retime it (ghost preview, snap per zoom preset), drag it onto another row to reassign, drag either edge to resize; selectable (config.selectable) — drag empty track to create. - Built-in editor (
config.builtInEditor) — create/edit/delete via a popup whose time fields follow the event's own precision (an event with seconds or milliseconds keeps them on save; a whole-minute event shows the plain hh:mm control), with a grouped resource dropdown and the same Repeat controls as the calendar (frequency, every-N, weekly weekday picker, ends never/on-date/after-N); editing a recurring occurrence offers the "This event / All events" choice. - One change event —
onChangefires for every mutation (create/edit/delete/move/resize) with the resulting event carrying its finalstart/end/resourceId; a cross-row drag addsfromResourceId. Recurring mutations add the calendar'sscope/seriesId/occurrenceDatecontext. One script persists everything. - Mini month navigator — the toolbar title opens a compact month picker to jump the window anywhere (
config.weekStartsets its first day). - Categories, icons & legend — same contract as the calendar (
config.categories,event.category,event.statusrestyling, interactive legend on two-waystate.hiddenCategories). - Recurrence — events with an
rruleexpand per visible window (bindconfig.data.recurringEventsto an always-loaded query so windowed fetches never drop a series). Occurrences carry a ↻ marker and edit like the calendar's: dragging or editing one detaches it into a standalone override plus anexdateon the series; the editor can target the whole series instead. - Windowed data binding —
output.visibleStart/visibleEndare ISO-8601 UTC instants (half-open); bind your queryts >= :start AND ts < :endandconfig.loadingto its state. See the live recipe at/timeline-dbin the verify project, and the cycle-time fixture at/timeline-cycle. - CSV export (
config.showExport), now-line (config.refreshSeconds), localization (same 7 languages +config.labelsoverrides), CSS-variable theming (--tml-*).
Identical philosophy to the calendar: controlled, read-from-data. The timeline never mutates config.data.events; gestures fire onChange and your handler writes back (upsert-or-delete by id — the event always carries its final resourceId, so a reassign needs no special casing). The demo view at /timeline ships a complete one-handler write-back script, including the recurring branches (scope/seriesId/occurrenceDate → series exdate + standalone override) across both data.events and data.recurringEvents.
Override the --tml-* variables via a style class / project stylesheet: --tml-accent, --tml-accent-text, --tml-text, --tml-muted, --tml-border, --tml-line, --tml-bg, --tml-group-bg, --tml-now.
Manual test checklist:
docs/timeline-manual-test.md.
An editable virtualized data grid. Component id mustrysolutions.perspective.input.datagrid.
- Virtualized rendering — fixed
config.rowHeightmakes row windowing exact; the client-side model is intended for up to ~50k rows (see/grid-stressin the verify project). Sticky header, one scroll container, frozen (pinned) columns. - Columns (
config.columns, rendered in array order) — each readsfieldfrom every row:header,type(text/number/boolean/date/datetime),align,width,pin, typedformat, dropdownoptions, per-columneditable, declarative validation (required/min/max/pattern) and conditional styling (cellStylesrules matched on the value). - Column gestures, two-way — drag a header to reorder, drag its edge handle to resize, hide/show via the toolbar column chooser; the user's adjustments persist in
state.columnLayout. - Read interactions, two-way —
state.sort(header click cycles asc → desc → off),state.quickFilter(case-insensitive contains across configured columns; instant local echo while the prop write round-trips),state.selection(config.rowSelect:none/single/multi, with Ctrl/Cmd-toggle and Shift-range over the visible view). CSV export of the current view (filtered + sorted, configured columns). - Controlled editing (
config.editable+ per-columneditable) — double-click/Enter/F2/typing opens a typed editor (text, decimal, date, datetime-local, dropdown-in-cell; booleans render as live checkboxes). Validation runs before commit; Escape reverts. Number cells are read withconfig.locale's separators (1,234.5inen,1.234,5inde); input whose grouping is malformed or ambiguous —4,5inen,1.234infr— is rejected rather than guessed. The grid never mutatesdata.rows: a commit firesonCellEdit(old/new value + full row) and overlays the value as pending until your write-back rebinds the rows. - Batch mode (
config.editMode: 'batch') — edits accumulate as dirty cells (italic + dot + "{n} unsaved" badge,output.dirtyCount); Save fires oneonBatchSavewith every dirty cell and each changed row, Discard reverts them all. - Excel range paste — paste a TSV range from a spreadsheet onto the focused cell; each landing cell validates individually.
- Aggregate footers — per-column
aggregatesummarised over the current view. - Keyboard model — arrows move the focused cell, Enter/Tab commit + move, Escape reverts.
- Add / delete rows — toolbar buttons (
config.allowAdd/config.allowDelete) fireonRowAdd/onRowsDelete; you create/delete and rebind. - Empty-state badge (
config.emptyMessage), loading bar (config.loading), localization (same 7 languages viaconfig.locale+config.labelsoverrides).
Same philosophy as the calendar/timeline: controlled, read-from-data. Committed edits overlay the display (pending) and clear as soon as any data.rows change arrives — your onCellEdit/onBatchSave script persists and rebinds; the demo at /grid in the verify project ships a complete write-back script for both modes.
Override the --dg-* variables via a style class / project stylesheet: --dg-accent, --dg-text, --dg-muted, --dg-border, --dg-line, --dg-bg, --dg-head-bg, --dg-row-odd.
Manual test checklist:
docs/grid-manual-test.md.
Embeds any Perspective view and navigates it like a map. Component id mustrysolutions.perspective.display.panzoomview.
- Embed any view —
config.viewPath+config.viewParams([{name, value}]); the embedded view stays fully interactive (clicks inside it survive pan/zoom gestures).output.viewStatereportsloading/valid/notFound/error/access-denied. - Map-feel navigation — drag to pan with inertia glide on release, iOS-style rubber-band overpan with spring-back at the bounds, wheel zoom toward the cursor (
config.wheelZoom, proportional for trackpad pinches), double-click zoom (config.doubleClickZoom), pinch zoom on touch (one finger hands over to two and back), and +/−/home/fit controls withconfig.localetooltips (config.showControls). - Scriptable viewport, two-way —
state.zoom/state.center(content coordinates): write either from a script or binding and the viewport animates there overconfig.flyToMs(log-space easing).config.homeis the reset target and initial position; zoom is clamped toconfig.minZoom/maxZoom. - POIs (
data.pois) — named fly-to targets: write a name tostate.targetto fly there (the component clears it back to''), or pick from the "Go to…" dropdown (config.showPoiList). Off-screen POIs show edge indicators; clicking one flies to it. - Minimap (
config.showMinimap) — corner overview with a draggable view rectangle and POI dots; hides itself while the whole content fits. - Auto content size —
config.contentWidth/contentHeightset the coordinate space;0(default) measures the embedded view automatically.
Override the --pz-* variables via a style class / project stylesheet: --pz-accent, --pz-alert, --pz-text, --pz-muted, --pz-border, --pz-bg, --pz-canvas.
Manual test checklist:
docs/panzoom-manual-test.md.Wrapping a Branching Diagram is a common case and has its own notes: Panning and zooming a large tree.
A left-to-right decision-tree / flow-path renderer, migrated from ignition-mustry-ui (see docs/branching-component-migration-plan.md). Component id mustrysolutions.perspective.display.branching.
- Flat data in, tree out —
data.nodesis a flat array (id,name,category,nextId[], colour, icon, tooltip); the root is inferred (the node with outgoing edges nobody references, reported viaoutput.hasRoot). Layered ("Sugiyama-style") layout: a cycle-break pass classifies back-edges, then longest-path layer assignment gives each node its column (one row per category rank). A loop no longer shoves its whole downstream subtree right to keep arrows forward — the layers stay compact and the loop is drawn as a clean backward connector. - SVG connectors with curved row hand-offs; the split point prefers the midpoint of a clear corridor and detours around occupied cells. Backward / loop edges (e.g. a rework edge that points to an earlier column) route cleanly through the column midpoint — the original mis-routed these as a stray diagonal.
- Orientation —
config.orientationlays the treehorizontal(left-to-right, depth along x; default) orvertical(top-to-bottom, depth along y). Category spacing followsconfig.yOffset(widen it in vertical mode so horizontal labels don't collide). - Direction arrows —
config.showArrows(default off) draws an arrowhead at each connector's target, trimmed to the disc edge and auto-oriented, so flow direction (and loops) read at a glance. - Edge labels + styling — optional
data.edgeLabels([{from, to, label?, color?, style?, width?}]) overrides a connector by its endpoint ids: a mid-pointlabel(Yes/No on a decision branch), acolor(default: the source node's colour), astyleofsolid/dashed/dotted, and awidth(defaultconfig.lineWidth) — e.g. a dashed red fallback branch. Edges without an entry use the defaults. - Width-responsive — columns stretch to fill the component and never compress below
config.minXOffset(then it scrolls). Node discs take Ignition icons ({path, color}— the path must exist in the gateway's icon library, see the calendar's icon note) and show a markdown hover info card (react-markdown@4, React-16 compatible) that stays open while hovered. - Selection + events — clicking a node writes
state.selectedNode(two-way, drives a highlight) and firesonNodeClick{id, name, category}. Display-only: the component never mutatesdata.nodes. - Validation feedback — when a dataset won't fully draw, the reason is surfaced instead of a blank canvas: the empty state distinguishes no nodes / cycle (no entry point) / no root, and
output.warningslists machine-readable issues (no edges, cycle, edges to unknown ids, nodes unreachable from the root and silently dropped). Emptyoutput.warnings= clean.
The Branching Diagram has no pan/zoom of its own, by design — wrap it in a Pan & Zoom View instead of duplicating a second gesture stack inside it. Live example: /branching-panzoom in the verify project (BranchingPanZoom → BranchingCanvas).
The pattern is two views:
- An inner "canvas" view holding nothing but the diagram. No help labels, no readouts — Pan & Zoom measures the whole embedded view, so any chrome becomes part of the pannable content and scrolls away with the tree.
- The wrapper, a Pan & Zoom View whose
config.viewPathpoints at that canvas view, withconfig.contentWidth/contentHeightleft at0. The canvas view'sdefaultSizeis then the content coordinate space — the wrapper's fit/home/minimap all work off it.
Three things decide whether this feels right:
- Size the canvas view so the whole tree fits inside it. The diagram fills its container and scrolls internally when the layout doesn't fit (
config.minXOffsetis the floor for column width). Left too small, you get the component's own scrollbars inside the wrapper — two nested ways to move the same picture. Give it room and the wrapper becomes the only navigation. - Don't size it exactly — leave slack. A canvas sized to the diagram's measured extent can still overflow it by a few tens of pixels and raise a scrollbar; give the diagram a margin inside the canvas view rather than tuning the fit to the pixel.
- Give parallel branches different
categoryvalues. Category is the row and the layer is the column, so two nodes sharing a (layer, category) cell are drawn on top of each other. A decision whose branches both sit in the same category renders as one overlapping smudge; the fix is a distinct category per branch, not more spacing.
Selection survives the wrapper: clicking a node inside the embedded view still writes state.selectedNode and fires onNodeClick as usual, and pan/zoom gestures don't swallow the click.
Sizing that canvas view is manual today — the diagram doesn't measure itself to its content, so you pick a
defaultSizethat fits the tree you expect. Native auto-size-to-content is tracked in #37.
Override the --brn-* variables via a style class / project stylesheet: --brn-text, --brn-muted, --brn-line, --brn-node-bg, --brn-accent — plus config.backgroundColor for the label halo, matching the original.
True WYSIWYG editing — and safe read-only display — of rich text: operator instructions, SOPs, shift notes, work orders. Component id mustrysolutions.perspective.input.richtexteditor. Built on TipTap core (vanilla, no React binding).
- Two modes, one component —
config.mode: 'edit'is the full editor (toolbar, dirty badge, Save);'display'renders the same document read-only with clickable links: the safe way to show rich content anywhere (native Markdown can't render arbitrary HTML safely). - Controlled write-back —
data.content(HTML) is the bound truth. Edits stay a local draft (dirty badge, grid batch semantics) until Save firesonSave{content, plainText, wordCount}; your script persists and rebinds, and the round-trip clears the dirty state. Discard returns to the bound value; external changes while dirty keep the draft. - Sanitization is the schema — only allowlisted node/mark types can exist in the document: unknown markup (scripts, event handlers) is dropped on parse, link hrefs pass a protocol allowlist (
http/https/mailto/tel;javascript:/data:rejected), image sources additionally allowdata:image/*. - Formatting allowlist (
config.features) — bold/italic/underline/strike, H1–H3, bullet/numbered lists, links, tables (insert 3×3 with header; contextual add-row/add-column/delete while inside one), images (by URL, or pasted as data URIs capped byconfig.maxImageKb), checklists. A disabled feature disappears from the toolbar and the schema. - Interactive checklists in display mode — operators check off steps of the displayed procedure; each toggle fires
onTaskToggle(same payload asonSave) so one write-back script keeps the state. - Undo/redo — toolbar buttons (touch-friendly) plus Ctrl+Z/Ctrl+Y; bound-content arrivals are history-exempt, so undo can never blank the document.
- Image library picker — bind
data.imageLibrary([{label, src}]) to offer a dropdown of known images; gateway Image Management paths (/system/images/...) work directly and stay session-authenticated. - Font allowlist (
config.fonts, default off) — list the families operators may apply (e.g. a monospace for part numbers); display mode always renders saved fonts. config.charLimit(0 = unlimited) enforced while typing;config.placeholder; localization (same 7 languages +config.labelsoverrides); print stylesheet (toolbar and chrome drop out).- Outputs:
output.isDirty,output.plainText(for DB search/indexing),output.wordCount,output.charCount(same measure ascharLimit: user-perceived characters, block line breaks not counted) — updated on save/rebind, not per keystroke.
Same philosophy as every component in this module: controlled, read-from-data. The editor never mutates data.content; onSave/onTaskToggle fire and your handler writes back. The demo at /rte in the verify project ships both handlers (three lines each) and a display instance bound to the same value.
Override the --rte-* variables via a style class / project stylesheet: --rte-accent, --rte-accent-text, --rte-text, --rte-muted, --rte-border, --rte-bg, --rte-toolbar-bg.
A CodeMirror-6-based code editor — and read-only viewer — for structured text: JSON config blobs, SQL, Python snippets, XML. Component id mustrysolutions.perspective.input.codeeditor.
- Languages (
config.language) —json/python/sql/xml/text, with syntax highlighting driven by CSS variables so every Perspective theme restyles it. - Live JSON validation — parse errors mark the gutter, an "Invalid JSON" badge appears while the draft is broken, and
output.isValid/output.errorMessagedescribe the bound document — gate your commit button onoutput.isValidand config-driven apps stop accepting broken configs. - Controlled write-back — the same model as every editor in this module:
data.codeis the bound truth, edits are a local draft (dirty badge) until Save firesonSave{code, isValid, errorMessage}; the round-trip clears dirty, Discard reverts, external changes while dirty keep the draft. - Editor comforts — line numbers + code folding, bracket matching, auto-indent, search (Ctrl+F), selection-match highlighting, undo/redo buttons (bound-content arrivals are history-exempt), Format JSON (pretty-print at
config.tabSize). mode: 'display'— a read-only structured-data viewer with folding and search;config.lineWrapping,config.placeholder,output.lineCount; labels in the same 7 languages.
Override the --code-* variables via a style class / project stylesheet: chrome (--code-accent, --code-text, --code-muted, --code-border, --code-bg, --code-toolbar-bg, --code-gutter-bg, --code-active-line, --code-error) and the syntax palette (--code-property, --code-string, --code-number, --code-keyword, --code-comment, --code-function, --code-type).
A colour input for the runtime — the piece Perspective has only at design time (the property-editor colour selector). Component id mustrysolutions.perspective.input.colorpicker.
- HSV selection — a saturation/value area plus hue and (optional) alpha bars, dragged continuously; the working colour keeps its hue while passing through greys and black.
- Formats (
config.format) —hex/rgb/hsl, switchable at runtime from a segmented toggle. Parses any#hex(3/4/6/8-digit),rgb()/rgba()orhsl()/hsla()string typed into the field;config.showAlphaadds the alpha channel (#RRGGBBAA/rgba()). - Three presentations —
config.mode: inlinegives the full panel in place;config.mode: popoverwithconfig.showInput: trueis a swatch + hex/rgb/hsl field that opens the panel;config.mode: popoverwithconfig.showInput: falseis a compact icon button. Popover triggers carry an eyedropper glyph (contrast-aware over the current colour) so they clearly read as a control; the panel is portalled, flips on overflow, and closes on outside-click / Escape.config.popoverScrim(off by default) dims the page behind an open popover so it stands out over busy content. - Swatches & recent — a bound palette (
data.swatches) of quick picks plus a per-session recent-colours row;config.showSwatches/config.showRecent. - Eyedropper (
config.showEyedropper) — sample any on-screen pixel where the browser supports the EyeDropper API (Chromium); hidden otherwise. - Controlled write-back —
value.coloris the bound truth; a pick firesonChange{value, hex, rgb, hsl, alpha}and the author's script persists it (same model as the editors). Read-onlyoutput.*mirror the bound colour:output.hex,output.rgb,output.hsl,output.alpha,output.isValid. Labels in the same 7 languages.
Override the --cp-* variables via a style class / project stylesheet: --cp-accent, --cp-text, --cp-muted, --cp-border, --cp-bg, --cp-field-bg, --cp-error, and the alpha-checkerboard tiles --cp-check-a / --cp-check-b.
A touch keyboard for the runtime — the piece Perspective leaves to the OS keyboard (Windows TabTip, Linux Squeekboard), which fails in Perspective Browser/mobile and causes the "double-keyboard" problem. Component id mustrysolutions.perspective.input.keyboard.
- No OS keyboard — the value display is a
<div>, not an<input>, so tapping it never summons the operating system's on-screen keyboard. This is the core edge over the Exchange keypad views, whose documented workaround is "don't use real input fields." - Numeric keypad (
config.layout: numpad) — editsvalue.value(number):config.min/maxwithenforceRangeclamping + an out-of-range badge,config.decimals,config.unitssuffix,allowNegative. Setpoint-style entry (the first key starts fresh). - QWERTY keyboard (
config.layout: text/email/url) — editsvalue.text(string): one-shot shift, a?123symbols/numbers layer,config.maxLength; email/url add@,.com,/convenience keys. - Inline or popover (
config.mode) — the keyboard in place, or a field trigger (with a keyboard glyph +config.placeholder) that opens it in a portalled panel; Enter commits and closes, outside-click / Escape discards. - Controlled write-back — Enter fires
onCommit{value, text, isValid}(valueis a number for numpad, a string for text) and writesvalue.value/value.text; liveonChange{draft, value}on every key (config.liveUpdatealso writes live). Read-onlyoutput.*:value,text,isValid,length,draft. 7-language labels. All editing rules are pure + node-tested.
Override the --kbd-* variables via a style class / project stylesheet: --kbd-accent / --kbd-accent-text, --kbd-text, --kbd-muted, --kbd-border, --kbd-bg, --kbd-key-bg, --kbd-key-active, --kbd-error.
A runtime UI over the gateway's user schedules — Vision's Schedule Management component, which Perspective lacks (the Ideas-portal "Admin Components" request has been open since 2019; only copy-in Exchange view templates fill the gap). First of the planned admin family (see docs/admin-components-plan.md). Component id mustrysolutions.perspective.admin.schedulemanager.
- Master-detail — a schedule list (live active-now dots) plus a 7-day week grid where availability is painted as blocks; a red now-line marks the current time in today's column.
config.dayStartHour/dayEndHourclip the axis,config.firstDayOfWeekorders it. - Paint editing (
config.editable) — drag empty grid space to add an availability range (snapped toconfig.snapMinutes), drag a block's top/bottom edge to resize, click a block to remove. Name (rename), description and the All days / Observes holidays flags edit inline;+ New schedule(config.allowCreate) starts a blank draft; Delete (config.allowDelete) asks twice. - Draft discipline — edits are draft-only with the shared Save/Discard tail; a polling binding never clobbers an in-progress draft; name validation (required/unique) blocks Save and surfaces in
output.validationErrors. - Preview strip — answers "active now? until when?": Active now — until Fri 17:00 / Inactive — next Mon 8:00, re-evaluated every 30s (also exposed as
output.isActiveNow). Midnight-touching ranges count as continuous; weekly wrap-around is handled. - Controlled write-back —
data.schedulesis a flat mirror of Ignition'sBasicScheduleModel(per-day enabled flags + 24h range strings), typically bound via a script transform oversystem.user.getSchedules(). Save firesonScheduleSave{schedule, isNew, oldName?}and Delete firesonScheduleDelete{name}; the author's script persists viasystem.user.addSchedule/editSchedule/removeScheduleand refreshes the binding (the/scheduledemo ships reference scripts, including the rename add-then-remove dance). Read-onlyoutput.*:count,isDirty,isActiveNow,validationErrors. Labels in the same 7 languages. - Deliberate limits (pre-1.0) — alternating A/B schedules render week A and show a badge but aren't editable (the A/B bean layout is unverified; flipping it blind could corrupt saves); composite schedules and holiday calendars render as plain read-only entries.
Override the --adm-* variables via a style class / project stylesheet (shared by the whole admin family): --adm-accent / --adm-accent-soft, --adm-text, --adm-muted, --adm-border, --adm-bg, --adm-panel-bg, --adm-active, --adm-danger.
A runtime UI over the gateway's alarm-notification rosters — Vision's Roster Management, which Perspective lacks. Second of the admin family (see docs/admin-components-plan.md). Component id mustrysolutions.perspective.admin.rostermanager.
- Order is the point — a roster is the escalation sequence alarm pipelines walk, so rows carry Contact 1 / Contact 2 / … ordinals and reorder by dragging a row's grip.
- Typeahead directory picker (
+ Add user) over the bounddata.availableUsersdirectory; rows resolve display names and contact points from it, and warn when a user has no contact info — the failure mode roster admins are actually hunting. - Create / delete (
config.allowCreate/allowDelete), draft-only edits with the shared Save/Discard tail, name validation,output.count/isDirty/validationErrors, two-waystate.selectedRoster. Labels in the same 7 languages,--adm-*family theming. - Controlled write-back —
system.rosteris append-only (no reorder primitive), so Save firesonRosterSave{name, users, isNew}with the FULL desired ordered list and the author's script reconciles:createRosterwhen new,removeUsers(current), thenaddUsers(users)in order. Delete firesonRosterDelete{name}. The/rosterdemo ships the reconcile script and seeds a demo directory.
The shared admin-family --adm-* variables (see Schedule Manager).
A runtime UI over a gateway user source — Vision's User Management, which Perspective lacks. Third and final component of the admin family (see docs/admin-components-plan.md). Component id mustrysolutions.perspective.admin.usermanager.
- Master-detail — a filterable user rail (client-side typeahead over username/name) and a detail form editing first/last name, schedule (dropdown from
data.availableSchedules), language, notes, role chips (fromdata.availableRoles) and contact-info rows (email/sms/phone type + value, add/remove). - Role-catalog management, opt-in —
config.allowRoleManagement(default off) adds a manage mode to the Roles section: add, inline-rename and two-step-delete roles, firingonRoleSave{name, oldName?}/onRoleDelete{name}immediately (persist viasystem.user.addRole/editRole/removeRole). Renames keep user assignments — the source stores role ids — but security policies reference roles by name, which the UI warns about. - Passwords are opt-in and payload-only —
config.allowPasswordChange(default off) reveals a staged-password field; the value travels ONLY in theonUserSavepayload, never through props, state oroutput.*. Put the component behind Perspective security levels and TLS before enabling. - Create / delete (
config.allowCreate/allowDelete) with username validation; draft-only edits with the shared Save/Discard tail;output.count/isDirty/validationErrors; two-waystate.selectedUser. Labels in the same 7 languages,--adm-*family theming. - Availability adjustments — per-user schedule overrides (vacation, extra on-call cover) edited as rows (from/until instants, available toggle, note) inside the detail form; partially filled or inverted rows block Save (
'adjustmentInvalid'inoutput.validationErrors); persisted wholesale viasystem.user.createScheduleAdjustmentin the reference script. - Read-only degrade — AD/LDAP-backed sources can't be written through
system.user; setconfig.editable: falseand the component becomes a directory viewer (it cannot detect writability itself). - Controlled write-back —
data.usersmirrors PyUser (bind viasystem.user.getUsers()); Save firesonUserSave{user, isNew, password?}and Delete firesonUserDelete{username}; the author's script persists viasystem.user.addUser/editUser/removeUser. The/usersdemo ships reference scripts (including the roles/contacts wholesale rebuild and a guard that refuses to deleteadmin) — note the user source's password complexity policy applies to staged passwords.
The shared admin-family --adm-* variables (see Schedule Manager).
A runtime UI over the gateway's holiday list — the missing quarter of the schedule story: schedules can observe holidays (they're inactive on those dates), but nothing in the runtime showed or edited which dates those are. Fourth component of the admin family. Component id mustrysolutions.perspective.admin.holidaymanager.
- Master-detail — a rail sorted by next occurrence (annual repeats compute their next date, Feb-29 repeats observe Feb 28 off-leap-years, past one-offs sink and dim with a past badge) and a small detail form: name (rename via
oldName), date, repeat-annually. - Strict date validation — calendar-checked
YYYY-MM-DD(no silent Date-object rollover of Feb 31 into March); an invalid or missing date blocks Save and surfaces inoutput.validationErrors. - Create / delete (
config.allowCreate/allowDelete), draft-only edits with the shared Save/Discard tail, two-waystate.selectedHoliday,output.count/isDirty/validationErrors. Labels in the same 7 languages,--adm-*family theming. - Controlled write-back —
data.holidaysmirrors Ignition'sHolidayModel(bind viasystem.user.getHolidays()); Save firesonHolidaySave{holiday, isNew, oldName?}and Delete firesonHolidayDelete{name}; the author's script persists viasystem.user.addHoliday/editHoliday/removeHoliday. The/holidaysdemo ships the reference scripts; the Admin Console gains a fourth tab.
The shared admin-family --adm-* variables (see Schedule Manager).
Each rail row has a ⋯ menu — hover-revealed, and always visible on the selected row so touch users reach it with one tap. It offers Duplicate (prefills the create flow from the source; Save fires the usual isNew: true event) and Delete (two-step confirm in the menu, per-row). Gated by allowCreate / allowDelete.
The three admin components are deliberately separate — tabs and page routing are the platform's job, and page-level security levels are the robust boundary between "can edit shift schedules" and "can edit users". To get a single admin panel, compose them in a native Tab Container and use each component's capability flags (editable, allowCreate, allowDelete, allowPasswordChange, allowRoleManagement) to dial each tab. The committed AdminConsole view (route /admin in the verify project) is the reference: all four components live in tabs, sharing one refresh tick so a save in one tab refreshes the others.
| Path | Scope |
|---|---|
common/ |
Component descriptors (one Components.ALL registry) + the props/event JSON schemas (src/main/resources). |
gateway/ |
Gateway hook (registers components, mounts web resources). |
designer/ |
Designer hook (registers components in the Designer). |
web/ |
React/TypeScript front-end + styles, built by webpack (production bundle by default). |
e2e/ |
Playwright smoke suite rendering every component in a live session — run via ops/e2e.sh. |
ops/ |
Local dev gateway (Docker) + scripts — see ops/README.md. |
ops/verify/ |
Committed Perspective "verify" project (demo views per component) — see ops/verify/README.md. |
docs/ |
Manual-test checklists (deep gesture/touch flows the e2e suite doesn't automate). |
Requires JDK 17 (JAVA_HOME). Node 18.20.4 is downloaded automatically by the build.
# Build the signed-or-unsigned .modl (web bundle + Java).
# The web bundle is a production webpack build (minified, no source maps);
# add -PwebDev for an unminified development bundle with source maps.
./gradlew build
# Run the TypeScript unit tests (also part of `gradlew check` / `build`)
./gradlew :web:jestTest # or: cd web && npm test
# Local dev gateway + deploy (see ops/README.md)
ops/setup.sh # first-time: signed gateway on http://localhost:9088
ops/deploy.sh # rebuild + redeploy after code changesSigning is conditional: a self-signed keystore is generated by the ops scripts; ./gradlew build without signing properties produces an unsigned module.
Releases are tag-driven (push vX.Y.Z → CI builds, signs, and publishes a GitHub Release with the .modl). Contributions go through PRs into main with required CI. See RELEASING.md.
Unit tests use Jest + ts-jest (web/jest.config.js, web/tsconfig.test.json) and run in a plain node environment — all the non-trivial logic lives in pure, DOM-free modules. The suites cover: shared date/timezone math incl. DST resolution (dateUtils), recurrence expansion, label packs, the CSV serialiser (quoting + injection guard); picker logic + prop mapping; calendar grid/packing/gesture/editor logic (incl. recurring detach & series scope) + prop mapping; and timeline scale/tick/layout/gesture/editor logic + prop mapping, with a dedicated DST regression suite pinned on the 2026 US transitions.
Rendering is covered by the Playwright e2e smoke suite (e2e/, run via ops/e2e.sh): each spec opens a route of the committed verify project in a real Perspective session, asserts the component mounts and behaves (preset write-back, view switch, group collapse, quick filter, 50k-row virtualization, embedded-view interactivity, fly-to), and fails on any console error. CI runs it on every push against a freshly bootstrapped gateway (ops/e2e.sh --fresh). The manual checklists in docs/*-manual-test.md remain for the deep gesture/editor flows that need a human hand.
After changing a component, render it in a real Perspective session rather than trusting a gateway 200 — use ops/verify/ (or the /verify-component skill). See ops/verify/README.md.
Every interactive surface is keyboard-reachable: toolbar/legend/navigator controls are real buttons, and events (calendar chips and time blocks, timeline bars and state bands, group headers) are focusable with a visible accent focus ring — Enter/Space activates them like a click (opens the editor / fires the event; dragging remains pointer-only). The built-in editors are role="dialog" (aria-modal), auto-focus their first field, and close on Escape. Full grid arrow-key navigation and keyboard drag are not implemented.
Next components to build (ranked by validated demand): see docs/component-ideas.md.
Status: not started — deliberately deferred until the component is in real use.
Perspective serializes each instance's configured prop values into the view's JSON, not a live link to props.json. So when the schema changes across a module upgrade (a renamed or re-nested prop), old saved values are orphaned and the affected settings silently reset to defaults. During development that's a non-issue — the only instances are the few in ops/verify/ and we just re-create them — but once real views are built on this component, the prop schema becomes a contract that can't be freely broken.
Do this before a v1.0 release / first real deployment:
- Freeze the schema and stop renaming/re-nesting published props.
- Additive-only policy thereafter — new props are optional with defaults (non-breaking); renames/moves require a converter.
- Versioned converters — confirm the exact Perspective 8.3 SDK hook (component descriptor version + prop converter; verify via
javap) and register migrations that rewrite old prop trees forward. As a cheaper interim, the reducer can read legacy paths as fallbacks. - Document the policy here and in
CLAUDE.md. (Optional) a CI guard that flags a removed/renamed key inDone:props.jsonversus the previous commit.ops/schema-guard.sh, wired into CI.
Until the component is actively used, breaking schema changes remain acceptable.




