aster.UI / Docs
Aster products
Showcase ↗

Aster UI / Guides

Customize and manage workspaces

Use these patterns when a screen needs configurable selection, reusable variants or components that load on demand. Start with Compose a workspace for the model, component and layout concepts used here.

The examples extend the device workspace. Its model owns the data and commands; its named children present them.

Share data and manage cleanup

Suppose your application creates a data context and passes it to a device workspace. The workspace creates a collection, and its table and tiles use that collection. Switching between those views keeps the collection alive. When the workspace closes, it releases the collection; the application can keep using its data context elsewhere.

The model factory registers that cleanup as soon as it creates the collection:

function createDeviceList({ ctx }, lifetime) {
  const collection = ctx.collection({ set: "Devices" });
  lifetime.defer(() => ctx.release(collection));
  return { values: { collection } };
}

Here, the workspace owns the collection because its factory created it. It borrows ctx because the application supplied it. The lifetime argument is the workspace’s cleanup list: defer registers an action to run when the workspace is disposed. Aster calls these actions in reverse registration order. Views borrowing the collection leave its cleanup to the model.

Before calling workspace.dispose(), the application settles any unsaved-work confirmation. Disposal itself is unconditional; switching presentation does not dispose the workspace or its model.

In the full device workspace, the collection takes a computed query directly. Changing the name input changes that signal; the data core refetches the collection without a handler pushing filters from one view to another.

Ownership across child workspaces

A child receives its own lifetime and the owner’s PageScope, which makes named state available to its rules. Nested Composite instances receive parentScope explicitly and keep their own publication namespace. The ctx option supplies the scope’s service/schema context for typed navigation rules; nested scopes inherit it.

Declare selection policy and each view's participation

The shared model declares how selection works once for the workspace:

model:
  type: device-workspace
  inputs:
    ctx: { ref: inputs.ctx }
    selection:
      multiple: true
      ordered: false
      fallback: false

The model passes that configuration to new Selection(...). Optional ordered selection can set ordered: true and a positive maxOrdered. Both presentations use the same model so their pick policy cannot diverge accidentally. The contract rejects incompatible settings: ordered selection requires multiple selection, and maxOrdered requires ordered selection.

The table separately declares whether it allows users to change selection:

table:
  type: Grid
  props:
    selectable: true
    collection: { ref: model.collection }
    selection: { ref: model.selection }

With selectable: false, clicking, touch and selection keyboard actions do not change the borrowed selection. Selection made in another view remains visible. Navigation, opening and editing are separate capabilities. This is a view policy, not a server permission. A reactive bind can drive it from model/rule state too.

The live example's Display-only table variant demonstrates selecting in Tiles and reflecting those picks in a table which cannot change them. Single selection changes the shared model policy; Compact inspector replaces a public child.

Customize public children without copying the workspace

The definition exposes specific customization points:

customize:
  modelInputs: [selection]
  children:
    views: { optional: true }
    table: { props: [label, selectable, view] }
    tiles: { props: [label, selectable] }
    details: { replace: [CompactDetails] }

A JavaScript caller derives a reusable variant, then supplies instance settings:

const compact = deriveComposite(definition, {
  name: "CompactDeviceWorkspace",
  children: { details: { type: "CompactDetails" } },
});

const workspace = new Composite(compact, registry, {
  inputs: { ctx },
  ctx,
  overrides: {
    children: { table: { props: { label: "My managed devices" } } },
  },
}).mount(container);

Precedence is base definition → derived definition → instance overrides. Every definition is detached and deeply frozen. Props merge shallowly; arrays and nested configuration values such as view replace as complete values. Unknown/private child properties, disallowed replacements and attempts to change commands or publications fail. A replacement must accept the resulting property contract; properties are never silently dropped. The original child type remains available when deriving another variant.

Only children declared optional: true allow static enabled: false suppression. A suppressed child has status suppressed, does not load its module and rejects ensure(). Lifecycle and complete layout changes require explicit public lifecycle: true and layout: true permissions. Suppression differs from a retained hidden child, which still exists with its model and content.

Use one contract for runtime validation and TypeScript inference

The example uses reusable compositeTypes checks instead of repeating validators for every string, boolean, instance or signal. defineCompositeModel infers factory inputs and checks exported values from those same contracts:

const deviceModel = defineCompositeModel({
  inputs: {
    ctx: compositeTypes.instance(DataContext),
    multiple: compositeTypes.boolean,
  },
  exports: {
    collection: compositeTypes.instance(Collection),
    selection: compositeTypes.instance(Selection),
  },
  create: ({ ctx, multiple }, lifetime) => {
    const collection = ctx.collection({ set: "Devices" });
    lifetime.defer(() => ctx.release(collection));
    const selection = new Selection(ctx.store, collection, { multiple });
    return { values: { collection, selection } };
  },
});

Inside the factory, ctx and multiple have their inferred TypeScript types. deviceModel.read(instance.model) validates and narrows exports for host code without a cast. describeCompositeContract(deviceModel.inputs) exposes field descriptions, optionality and schema metadata for tooling. compositeTypes.refine adds cross-field checks and corresponding schema constraints, as used for the selection policy. Identity and signal contracts still need runtime checks; this metadata also feeds the registry authoring tools, which check references and generate editor schemas. Live PageRules and custom predicates still require runtime validation.

Named children expose status/error signals and ensure() for explicit demand. Deferred children load on first activation; retained children stay mounted and hidden/inert between visits. With retain: false, deactivation releases the child and a later visit creates a fresh instance. Registry module loads are shared; instance creation and ownership are separate. Late factory results are released after their owner closes. Custom asynchronous factories must register partial acquisitions with their lifetime and honour its abort signal.

The current YAML profile rejects aliases, anchors, tags, duplicate/unsafe keys, multiple documents, non-finite/unsafe numbers and oversized/deep definitions. The parser is a build-time development dependency. Use the compiled JSON format when loading a definition dynamically.

Declare public commands and publications

Model exports already have typed contracts. Add contracts for the commands and publications a definition may reference. This factory excerpt illustrates the contracts for a small editing-state model. It is separate from the device model; it demonstrates state changes, not persistence:

import {
  Command, command, compositeTypes as t, defineCompositeModel,
  signal, valuesPublication,
} from "../lib/aster-ui.js";

const editing = defineCompositeModel({
  inputs: {},
  exports: { dirty: t.signal(t.boolean) },
  commands: { markDirty: t.instance(Command), save: t.instance(Command) },
  publications: { editor: t.publication({ dirty: t.boolean }) },
  create: (_inputs, lifetime) => {
    const dirty = signal(false);
    return {
      values: { dirty },
      commands: {
        markDirty: lifetime.own(command({
          label: "Change draft", run: () => { dirty.value = true; },
        })),
        save: lifetime.own(command({
          label: "Save", enabled: () => dirty.value,
          run: () => { dirty.value = false; },
        })),
      },
      publications: { editor: valuesPublication({ dirty }) },
    };
  },
});

Persistence belongs in your Save function. The device model shows the actual editor save operation and its error handling. Publication contracts describe the named signal values needed by the screen; a publication may expose additional values. Declared command and publication contracts are checked when the model is created. Existing models without these declarations continue to work with the runtime, but the authoring tools cannot discover undeclared commands or publications by executing a factory.

Resolve command aliases by name

Aliases do not depend on their declaration order:

commands:
  submit: { use: commands.save }
  save: { use: model.commands.save }

The runtime resolves dependencies before creating the aliases. Public command maps keep declaration order for iteration. Cycles such as submit → save → submit produce a diagnostic with the reference chain. Completion and schema export use the same dependency resolver and omit targets that would introduce a cycle.

Place existing children in semantic markup

With a typed registry named authoring and its checked definition, use place() to put the declared layout inside your own markup without listing its children again:

import { Composite, html } from "../lib/aster-ui.js";

const placement = authoring.placement(definition, place => html`
  <section aria-label="Device status">
    <header><h2>Current draft</h2></header>
    ${place()}
  </section>
`);
const workspace = new Composite(definition, authoring.registry, {
  inputs: { ctx }, ctx, placement,
}).mount(container);

This callback runs once when the workspace is constructed. place() builds the declared layout around the existing children. If you need different regions, use named placements instead, such as place("toolbar") and place("editor"). Each child must be covered exactly once; mixing root and named placements or omitting a child fails.

Activation is planned independently from markup. With named placements, Aster does not build a default layout tree and discard it. The definition’s deck/outlet state remains the authority for activation, visibility and lazy creation. Models, commands, inherited PageScope and disposal keep their existing ownership. Use CSS for responsive geometry and component properties for reactive content; this is not a second template lifecycle.

The device example’s Semantic layout variant demonstrates custom header, view and inspector regions around the same children and editing session.

Keep registry contracts stable

defineCompositeRegistry returns a sealed runtime registry. To add a capability, update the declaration and create a new registry. Contracts and metadata are snapshotted so changes to caller-owned maps cannot make validation disagree with the runtime. The catalog property is the metadata snapshot used by tooling; registry is the runtime lookup passed to Composite.

The registry's host inputs contract is checked before model factories or child loaders run. Required inputs must be supplied; optional inputs may be omitted. Separate registries can reuse component/model registrations with different host input contracts.

Strict object contracts reject known extra source keys, including objects nested inside arrays and signals. Identity checks and custom predicates still need runtime validation. See Check workspace connections for the JavaScript, YAML and editor workflows.