Get started
Build a studio

Studio surfaces

Studio surfaces

@mountaya/studio-react publishes every surface the assembled editor places. This page says which surface does which job, and the Props tab beside it is the reference for each one.

Hover a row and that leg lights on the route. Neither list is wired to the map: the provider holds one, and that is the whole integration.

Mount a surface

Every surface sits under a StudioProvider. It holds the tool bus their actions dispatch through and the store their rows read.

import type { ItineraryViewSource } from "@mountaya/studio";
import { SegmentList } from "@mountaya/studio-react";
import { StudioProvider } from "@mountaya/studio-react/headless";

const Chain = ({ segments }: { segments: ItineraryViewSource["segments"] }) => (
  <StudioProvider data={{ bus: studioBus }}>
    <SegmentList itineraryId={itineraryId} label="Segments" mode="linestring" segments={segments} />
  </StudioProvider>
);

These surfaces take their data as props and report every change through the bus. A list never writes to the store it reads.

Edit segments and waypoints

SegmentList is the chain as two-line editor rows. SegmentList.Seam is the hover gutter between two rows, with the pills that insert a segment or merge the pair. SegmentList.AddButton is the foot and EditorListSection the frame. ReadOnlySegmentList drops the affordances a viewer cannot use. WaypointList is the saved waypoints in position order, plus whatever is in flight.

All of those take their rows as data. ItinerarySegmentList and ItineraryWaypointList read the studio's store instead: the active itinerary, the edit session, the peers holding parts of it, the agreement's limits. PersistedSegmentRow is one saved segment and DraftSegmentRow one the server has never seen, which can be discarded rather than deleted.

WaypointIconCircle recolours a place. Adding a place and editing its details (timing, contact, amenities, media and description) are surfaces the assembled studio places for itself.

A sidenav panel has one sticky footer. EditorSaveFooter owns it while a geometry edit is armed, and StagingCreateFooter commits an import or suggest draft otherwise.

Name, publish and file an itinerary

ItineraryForm is the create-or-edit form. It pairs the activity and the name, which are chosen together, and it enforces the agreement's itinerary limit first. The activity is fixed at creation. ItineraryHeaderTitle, ItineraryHeaderIdentity and ItineraryHeaderMeta are the detail panel's three header lines. ItinerarySubHeader is the band under them, which answers what the public sees and offers Publish. PublicationNotice clears a stale publication, and UnpublishedPanel is the dead end a preview shows.

ItineraryActionsMenu is the three-dots menu an itinerary wears in a list. Two items are yours: onRename, and editItem, which has to be a real link to your own route. Embed and export as video appear only when organizationId names a real organization, since a personal itinerary has neither. Download and Embed appear only for a viewer granted publish, Embed only for one granted embed as well, and export as video only for a viewer granted video-export. SegmentGroupModal is the group dialog a segment row raises.

Publishing, the draft download and the ways into an embed belong to Map Publishing, which the publish entitlement answers. A viewer without it is offered no Publish, on an itinerary's band, notice or dead end, or on a zone's band, and each band still says what the public sees. studio.itinerary.publish, studio.zone.publish and studio.embed.open refuse that viewer with upgrade-required before anything is sent, so a control of your own meets the rule the surfaces show. Publishing still asks a role that writes: a reader who may not edit is offered no act either.

Read the elevation back

ItineraryProfilePanel is the docked profile: the chart, the segment picker over it, the characteristics strip and the collapse affordance. On a narrow viewport it lives in a bottom sheet. The strip becomes a readout of the hovered point while the chart is hovered, and the waypoints stand on the distance axis.

Start from a file or a set of constraints

ImportItineraryContent is the import step: the activity chooser, the primer for what matching does, and the dropzone the chooser gates. The format comes from the file's extension. ImportPreviewLegend keys the preview's colours, DraftProvenanceBanner says where an unsaved draft came from, and ImportRoutingGuideProvider owns the guide ImportRoutingGuideLink opens.

SuggestForm generates from constraints instead: distance, ascent, speed, and the route-type switch beside the points it governs. Its fields hold wire units while displaying the reader's, so a preference change redraws the numbers alone.

Both are input-only. Once a preview resolves into a draft, the create panel owns the name, the scope, the visibility and the Create.

Draw and publish a zone

ZoneForm is the zone editor, and the surface to place rather than its blocks: the method chooser decides which blocks below it render at all. A saved zone's form also carries its public and private choice, a PrivacyControl as an itinerary's is, which ZoneForm places itself. A flip changes what every collection embed draws, so it asks for an acknowledgement first, then lands at once through studio.zone.update, apart from the form's Save, whether the zone is published or not. A new zone is private, and its visibility is chosen once it exists.

ZoneSubHeader is the zone's twin of ItinerarySubHeader: the band under the zone panel's header, which answers what the public sees of the zone and offers Publish. It takes no props, reads the open zone, and renders nothing while no saved zone is bound. A personal zone shows the Personal pill, since the personal space publishes nothing, and a zone with nothing left to publish shows Up to date. Publish stands down, with its reason on hover, while a collaborator holds the zone, while the co-pilot drives, or while an edit is open that publishing would freeze half-finished. A hand-drawn zone opens with its reshape armed, and that alone stands nothing down: a shape changed and not saved does.

ZoneActionsMenu is the zone's own menu. Pass published from the zone's status, and a published zone offers Unpublish in a band of its own above Delete, behind the confirmation studio.zone.unpublish declares. Left out, the item is not rendered.

Frame the map

StudioControls is the control stack over the map. The search bar, the alert a tool raises on the map and the context menus are the assembled studio's own. Every search round goes through studio.search.run, so a search is gated and reported like any other verb.

The floating panels (base maps, overlays and enrichments, then units and language) render one at a time, as the UI store names them, and a closed panel is not mounted. They anchor against --mtya-control-size and --mtya-control-gap, so resizing your controls moves the panels with them. A control you place over the studio's map keeps the studio's edge inset with var(--mtya-edge-inset), the token every control of the studio is placed by.

The points-of-interest menu rides the map's left edge rather than a control stack, so theme .mtya-poi-trigger alongside .mtya-controls.

Run the co-pilot

The assembled studio places the co-pilot's panel itself. A studio you lay out around MountayaStudioHost places CopilotPanel from @mountaya/studio-react/copilot: it takes no props, docks over the left edge of its nearest positioned ancestor, and opens when a co-pilot tab is active over an open itinerary or zone. useStudioCopilotInset() from the same entry reports how much of that edge it covers, so your own left-anchored chrome moves past it. AgentThreadsProvider above it opens a run and mirrors its lock into the UI store. That lock is what makes every other control go inert while the agent drives. Give it a threadsUrl seam to keep a conversation across a reload. Whether the co-pilot is available to a viewer is your answer, handed to StudioCopilotProvider of ./headless. Where the viewer's role, rather than your agreement, withholds the co-pilot, answer CopilotNoAccessCard as the seam's withheldNotice: it says the co-pilot is for an owner, an admin or a contributor.

The assembled studio also asks before a navigation leaves an open geometry edit, and refuses one outright while a run is driving. It reaches your router through StudioNavigationBlockerProvider.

Build the embed snippet

EmbedWizard reads the resource its target names ({ kind, id }, an itinerary or a collection), resolves the organization that owns it, refuses what cannot be embedded, then renders the configuration form beside the live iframe and the copyable snippet, built from one configuration. EmbedFieldControl is one row of that form.

The embed wizard is offered in Mountaya's web application only. Beneath a client carrying a publishable key, an Embedded Studio, the host withholds embed: no menu offers Embed, studio.embed.open refuses, and a mounted EmbedWizard says embeds are configured in the Mountaya web app, because its live preview and camera capture read over that application's own session.

The wizard chooses which publishable key the snippet authenticates with through two hooks you pass, called over the organization it discovers. useOrganization gives the saved defaults and the rules an embed is clamped to. useKeys gives the keys it may offer. The wizard calls both in a component body as hooks, so each keeps one identity for the wizard's life: pass a hook declared at module scope, never a closure written inline.

Build a studio form

PrivacyControl is the private and public choice with the explanation its resource needs. StudioToggleRow is one full-width on/off line whose label is the switch's, so the row is the hit target.

LanguageSelect draws renderMark beside each name, and leaving it out draws the names alone. AllowlistField caps a catalogue with an explicit All, None and Specific segment. Its variant fixes what an empty list means. A cap field enforces its list, and a display field narrows one somebody else set.

OrganizationScopeOptionRow is one row of an organization chooser: the scope's name, the reader's standing in it and the glyph for its kind. RadioOptionCard is the shape a small fixed choice takes.

Every field here is live and nothing is saved, because these controls report a change and never store one.

Dialogs, panels and page chrome

AppModal is the shell every standard dialog composes, ModalFooter its action row, and ConfirmModal the one confirm the studio raises. A dialog whose body opens on a heading of its own sets titleHidden, so the name is announced and not shown twice. FullscreenModal and FullscreenHeader assemble a surface that fills the studio: the window for a studio that fills it, and the studio's own region for one embedded in a page. Every dialog of the studio is bounded the same way, so a centred one dims the studio rather than the page.

StudioHeader is the brand lockup. SidenavContentHeader, SidenavContentBody and SidenavFormActions are the chrome a sidenav panel is built from. ActivityIcon and DialogSubject are the small marks a dialog or a row leads with.

Open either one to see the header contract and the footer rule the shells enforce.

A panel is a header, a scrolling body and one sticky footer, and these are the boxes for each.

Show loading, empty and failed

ListSkeleton holds the space while a read is in flight. EmptyState is the "nothing here" block, and its size selects a density and an orientation: md fills a panel, compact tightens for a narrow sidenav, inline costs one row of height. ErrorAlert takes a title and content. FormErrorAlert takes one line above a form's actions, and WarningAlert reports something that has not gone wrong.

StudioSectionBoundary covers the state those three do not: a surface that failed while rendering. Wrap each part you place in one. A render error then degrades to a retry card in that box, while its siblings stay live.

The last panel throws for real, so the retry card below it is the one the boundary draws.

Next steps