Get started
Build a studio

Use your own design system

Use your own design system

@mountaya/studio-react/headless is the studio's behaviour without its appearance: the store, the tool bus seam and the hooks every editor surface is driven by. Reach for it when you want Mountaya's editing rules under a design system of your own.

Renders nothing

That list is a plain <ul> with plain <input>s, driven by useSegmentList and useSegmentRow.

Mount StudioProvider

StudioProvider supplies the studio seams to everything under it. It composes with MountayaElementsProvider rather than replacing it, so mount both and set the icons, glyphs and copy once.

import { StudioProvider, useCapability } from "@mountaya/studio-react/headless";

const SaveButton = () => {
  const canEdit = useCapability("editor");

  return <button disabled={!canEdit} type="button">Save</button>;
};

<StudioProvider data={{ bus: studioBus }}>
  <SaveButton />
</StudioProvider>;

Its props arrive in four groups. data is the bus, the context, the store and who is reading. assets is where the pictures come from. behavior is the seams, the router and the peer names. i18n is the language, the locale and both bundles, which Customize labels and icons covers.

The hooks a surface reads

GroupHooks
ContextuseStudioContext, useStudioToolContext, useCapability, useStudioDispatch, useStudioDispatchUnsafe, useStudioObserve, useStudioStore, useUiStore
Host seamsuseStudioNotify, useStudioOrganizations, useCollectionScope, useStudioLanguageMark, useStudioActivityIconUrl, useStudioLayerThumbnail
ListsuseSegmentList, useSegmentRow, useSegmentRowActions, useWaypointRow, useWaypointRowActions
EditinguseEditActions, useBeginSegmentEdit, useBeginWaypointEdit, useInlineRename
CollaborationuseCollabResources, useLiveAwareSegments, usePeerLockHolderId
InteractionuseMapHoveredRow, useEscapeKey, useDeferredClick, useStreetViewCorridor
AgentuseAgentDriving

State moves one way through them. A hook reports what is true and returns the actions that may change it. Every change goes through the bus rather than the store.

useStudioDispatch is the one to reach for. It is bound to the 253 published contracts, so a misspelled verb or a missing field fails to compile. It answers the verb's own data: read a member of it after ok, with no cast, and a refusal is the ok: false arm under every verb. A stand-in in a suite is held to the same answers: type a spy by the dispatch, vi.fn<StudioDispatchFn>(), or mount StudioProvider over studioToolBus() from @mountaya/studio/testing and take the hook as it is. Pass { context } as a third argument to layer one context over the studio's own. useStudioDispatchUnsafe is its untyped sibling.

useStudioObserve reports every dispatch while your component is mounted, so one observer sees a click and an agent run alike. useCapability is the gate the surfaces read: a capability the session lacks becomes a withheld action with a reason attached.

useStudioToolContext is the raw seam bag, and it is the one hook here that throws outside a provider: a null context threaded into an agent session does nothing and looks like a studio that stopped responding.

Send notifications

Three tones and nothing else. A toast reports an outcome that already happened, where the surface that caused it may be gone from the screen. A form reports its own failure where the reader typed.

import { StudioNotifyProvider, StudioProvider } from "@mountaya/studio-react/headless";
import { notifications } from "@mantine/notifications";

const notify = {
  error: (message: string) => notifications.show({ color: "red", message }),
  success: (message: string) => notifications.show({ color: "green", message }),
  warning: (message: string) => notifications.show({ color: "orange", message }),
};

<StudioNotifyProvider notify={notify}>
  <StudioProvider data={{ bus: studioBus }}>{ourSubtree}</StudioProvider>
</StudioNotifyProvider>;

Messages arrive resolved, in the language the copy bundle is on. With no provider the studio is silent, which loses a report and never an action.

The strip is this page's own markup, and the third button dispatches a verb no handler answers.

Offer the right organizations

StudioOrganizationsProvider takes the roster as data, already filtered: deciding who may write where reads role fields outside the public contract, so your own rule decides what survives.

It has two halves. readable answers "whose folders may I look at", and a viewer-role member belongs in it. writable answers "where may I create things", and the same member does not. Set isPending on the readable half while the read is in flight. "You belong to no organization" and "we do not know yet" are opposite states. Only the first should take the scope switcher off the screen.

isPersonalScope is a predicate rather than an id, because the personal scope has three spellings: an absent id, a null one, and a shared root organization's own id.

writeRefusal says why a scope refuses a write of a collection, an itinerary or a zone: personal where your reader's own space holds no such resource, agreement where the organization's agreement does not include what the write needs. The handlers ask it before they write anything, and every picker that offers a scope for a write reads it through useStudioWritableOrganizations, so nobody fills in a form the API would refuse. Answer it from what each organization's agreement includes, which the public contract does not carry. Left out, every scope takes every write. An option your rule keeps visible but not writable carries unavailableReason, a resolved sentence the switcher shows beside it.

Answer billing in your own words

StudioBillingGateProvider takes a function of an organization id, not a value: a create form's target is whatever its picker currently holds. Surfaces call it unconditionally in a component body, exactly like a hook, so memoize what you pass.

The studio decides where and when a prompt shows, and you decide what it says. limitPrompt shows beside a create once the reader has reached the ceiling of their agreement. publicationPrompt shows where a personal itinerary cannot be published, since it has no organization to bill. Both are nodes in your words, with any action you attach to them, and the studio links nowhere. Whether a create is held before it is attempted is the studio's own reading of the viewer, so the gate holds nothing back.

handleRefusal runs after a create comes back refused. Return true when you have opened your own resolution and will replay the retry, false to report the failure inline. subject names what was being made, in a closed vocabulary you translate.

With no provider no prompt shows, and a refusal is reported inline like any other failure.

Serve activity and layer artwork

Two seams answer "what does this look like" for surfaces that cannot go through a glyph table. activityIconUrl gives an activity's silhouette as an image, since the video recorder and the map marker composite one. layerThumbnail gives a layer's picture to the cards that pick a base map.

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

<StudioProvider
  assets={{
    activityIconUrl: (slug: string) => `/activities/${slug}.svg`,
    layerThumbnail: (kind: string, slug: string) => `/${kind}s/${slug}.webp`,
  }}
  data={{ bus: studioBus }}
>
  {ourSubtree}
</StudioProvider>;

Both may answer undefined, and both absences are a working studio: a route with no activity mark, cards with names and no pictures. layerThumbnail takes the kind as well as the slug, because base maps and overlays share one slug namespace.

Bind the router, the co-pilot and plugins

StudioNavigationBlockerProvider binds your router so the guards can hold a navigation away from unsaved work. Without it nothing is held in app, though the close-tab prompt still fires: that half needs no host.

StudioCopilotProvider answers whether the co-pilot may run for an organization. It also gives the model line, the notice shown where the co-pilot is withheld, and the setup control. Without it every availability flag reads false.

StudioPluginsProvider hands the plugin registry and its error reporter to the menus, rails and slots that read them.

Next steps