Authoring UI
2026-10-03. DigitalHeaven Studio is being rebuilt on Halcyon. The new app keeps the “Studio” name and has its own host, built on the desktop-window shape of the Halcyon XR Overlay, and it never starts through the engine host. mltn chose extract first: before that app is built, the level editor’s asset browser, inspector and preview drawer move into one shared library that both the editor and the new Studio compose, so the two cannot drift into two spellings of the same pane. This note is the plan for that move.
mltn settled the plan’s six questions the same day:
- The library gets a new top-level
Authoring/group. - There is one inspector, every card included. Cards are generated from component descriptions, and only a few are hand-made. Each host supplies a read/write adapter, and nothing card-shaped stays engine-side.
- The library uses
Engine.Audiodirectly. - Studio’s previews come from a sidecar engine process, the pattern the Minecraft and Project Zomboid mods use.
- The build states merge into one list with
Failedadded, and their colors move into Halcyon’s theme. - The
Editor*types are renamed in one final step: they lose the prefix.
What makes this tractable
Section titled “What makes this tractable”The editor’s UI already reads nothing from the live world. Engine/DigitalHeaven.Editor (24.0 KLOC) is a leaf that only the host and the tests reference. It contains no preference reads at all: every number and every verb reaches it through one record, EditorToolbarContext (Engine.Client/Editor/EditorToolbar.cs:1767-2012, 245 members), built in exactly one place, HalcyonEditorScreen.cs:476, which the windowed client and the offscreen capture share. Apart from Halcyon, the leaf names only three things:
- the record types under
Engine.Client/Editor(9.2 KLOC, mostly plain records overCore.AssetsandSystem.Numerics); - five small helpers under
Engine.Client/Ui:UiIcons,UiCenteredLabel,UiThumbnail,AssetNamingandHalcyonScrollbarTheme; MaterialPreviewView(Engine.Client/MaterialPreviewView.cs), which itself uses onlySystem.Numericsand Halcyon.
The world-bound work (picking, selection, the inspector fold, pending edits, history, saving) lives in Engine.Client.Edit (11.9 KLOC), and it reaches the UI only by filling that record. So most of the extraction is moving types and splitting one record, not untangling logic from a world.
1. Inventory
Section titled “1. Inventory”Line counts are whole files. “Context” means the piece reads only EditorToolbarContext members and the records they carry.
The asset browser
Section titled “The asset browser”| Piece | Where | Lines | Depends on |
|---|---|---|---|
| Browser pane | Editor/EditorBrowserPane.cs: state :23, pane :140, Build :576, tree :1067-1384, listing :1464-1782 (tile :1578, picture :1623, row :1699), verbs and menus :755, :1859-2106 | 2,123 | Halcyon, Halcyon.Fonts (FontLadder), Core.Assets, context, UiIcons, UiCenteredLabel, UiThumbnail |
| Browser window and footer | Editor/EditorBrowserWindow.cs :18-188 | 188 | context |
| Browse crumbs | Editor/EditorBrowseChrome.cs | 95 | context |
| Material list and picker | Editor/EditorMaterialList.cs, EditorMaterialPickerWindow.cs | 360 | context |
| Browser model | Engine.Client/Editor/EditorAssetBrowser.cs: EditorAssetKind :10, EditorAssetBuildState :50, EditorAssetRow :89, EditorAssetPallet :107, EditorAssetKinds :118 | 320 | Core.Assets only |
| Pane layout records | EditorToolbar.cs: EditorBrowserReveal :272, EditorBrowserSplit :293, EditorBrowserZoom :369 | ~180 | Halcyon only |
| Model producer | Engine.Client.Edit/EditorAssetCatalog.cs :27 | 455 | Content.Maps.IPalletCatalog, the source root, and one preference (EditorPreferences.BrowserSearchDepth, read at :55) |
| Debounce and minting | Engine.Client.Edit/EditorAssetSource.cs :18, :60 | 420 | Core.Configuration, and Engine.Materials.MaterialSourceWriter (497 lines, which uses Engine.Scene) to mint a blank material |
No live world, no Universe, no renderer. The thumbnails it shows arrive as texture ids through the context.
The inspector
Section titled “The inspector”| Piece | Where | Lines | Depends on |
|---|---|---|---|
| Inspector body | Editor/EditorInspectorWindow.cs: entry :47, BuildBody :1756, body widget :1905, state :1942, Build :1983-2726 | 6,232 | Halcyon, Core.Assets, context, UiIcons (22 uses) |
| Shell | FieldRow :2996, Card :3017, SectionShell :3068, MenuButton :3158, rows :4714-5096, clipboard :1575-1712 | ~1,100 | context |
| Asset cards | BuildFolder :5137, BuildAsset :5190, BuildMaterial :5259 to MaterialFootButton :6220 | ~1,100 | context, EditorMaterialTarget |
| World cards | IdentityCard :3352, RestoreLedgerCard :3521, WiringCard :3596, TransformCard :3935, LightCard :4050, skinning :3214, camera preview :2930, their menus :4288-4667 | ~2,700 | context, but every one describes a node in a live map |
| Per-pane pin | Editor/EditorInspectorPane.cs | 42 | context |
| Card models and actions | Editor/EditorCardModels.cs, EditorCardActions.cs | 726 | context |
| Field primitives | EditorSectionHeading 119, EditorDisclosure 158, EditorFieldStyle 43, EditorFieldMenu 145, EditorTextField 182, EditorSlider 174, EditorChoiceField 161, EditorSwitchRow 47, EditorSpreadRow 26, EditorMaterialField 541, EditorMaterialNameRow 310, EditorPendingGlow 133, EditorHoverTooltip 170 | 2,189 | Halcyon, context palette records, UiIcons, UiThumbnail |
| Target model | Engine.Client/Editor/EditorInspection.cs: EditorInspectorTarget :599, EditorMaterialSlot :805, EditorBlendshape :827, EditorComponentCard :851, EditorWiring :900 | 1,096 | Core.Assets, System.Numerics |
| Multi-select | EditorInspectorMixed (EditorInspection.cs:966) says which fields disagree across the selection, EditorInspectorPresence (:1068) which cards every member has; frame editorInspectorMulti | in the above | records only |
| Card kinds and patches | EditorCardKinds.cs 312, EditorPatchCards.cs 133, EditorStagedInspection.cs 81, EditorInspectorField.cs 57 | 583 | Core.Assets, Content.Objects |
| Material target | EditorMaterialTarget.cs 223 | 223 | Core.Assets |
| Material description | EditorMaterialDescribe.cs 234, EditorMaterialSlots.cs 243, EditorLogicCards.cs 251 | 728 | Engine.Materials, Engine.Logging, Engine.Client.Rendering, Engine.Client.Logic: these produce the records and stay |
| Fold, writes, pending edits, history | Engine.Client.Edit: EditorInspectorFold 302, EditorInspectorWrites 233, EditorPendingEdits 2,023, EditorSelection 263, EditorHistory 563 | 3,384 | the live world, the network edit commands, undo |
Edits flow back as delegates on the context (SetInspectorAxis, SetMaterialField, SaveInspectorFields, EditInspectorWiring and so on). The inspector never holds a world reference.
The preview drawer and thumbnails
Section titled “The preview drawer and thumbnails”| Piece | Where | Lines | Depends on |
|---|---|---|---|
| Drawer | Editor/EditorMaterialDrawer.cs: :35, ClampHeight :99, Snap :117, Build :166, Header :247, Picture :364, Notice :404 | 426 | context, MaterialPreviewView, AssetNaming |
| Preview window | Editor/EditorMaterialPreviewWindow.cs, BuildBody :218 | 326 | context, MaterialPreviewView; a using Halcyon.Vulkan that nothing in the file needs |
| Image viewer | Editor/EditorImageViewerWindow.cs | 410 | context; a using DigitalHeaven.Engine.Imaging that nothing in the file needs |
| Picture records | EditorThumbnail and EditorTexturePlate (Engine.Client/Editor/EditorThumbnail.cs:16, :31), EditorMaterialPreview (EditorMaterialPreview.cs), UiMapThumbnail (Ui/MapThumbnailLibrary.cs:10), UiThumbnail (Ui/UiThumbnail.cs, 102) | ~190 | Halcyon only |
| Orchestration | Editor/EditorToolbarShell.cs:1271-1433: one preview per inspector pane, begun, shown and ended through the context | in 3,688 | context |
| The pictures themselves | the renderer: BeginMaterialPreview, ShowMaterialPreview, EndMaterialPreview, MaterialPreviewUv, Thumbnail, TexturePlate, MapThumbnail, ThumbnailEpoch | n/a | the GPU, in-process |
The drawer already speaks to its pictures only through eight context members. That set is the picture source; it just has no name yet.
Name plates, labels and pallet state colors
Section titled “Name plates, labels and pallet state colors”| Piece | Where | Lines | Depends on |
|---|---|---|---|
| Name plate | Editor/EditorNamePlate.cs :23, :69 | 148 | context |
| Name link | Editor/EditorNameLink.cs | 55 | context |
| Asset label (the quiet-prefix barcode) | Editor/EditorAssetLabel.cs :25 | 130 | AssetNaming |
| Crumb | Editor/EditorCrumb.cs :26, with its model EditorAssetCrumb (Engine.Client/Editor/EditorAssetCrumb.cs:201) and EditorCrumbStyle (:143) | 285 + 277 | Content, Core.Assets |
| Chip, spawned mark | Editor/EditorChip.cs, EditorSpawnedMark.cs | 159 | UiIcons |
| Pallet inks | EditorPalletInks (EditorAssetCrumb.cs:41): Built, Stale, NotBuilt and the owner inks Map, Workspace, Core; EditorPalletOwner (:11) | ~130 | Map borrows EditorSelectionColors.DefaultLinked from Engine.Client/Ui |
Shared chrome the new Studio also needs
Section titled “Shared chrome the new Studio also needs”| Piece | Where | Lines | Depends on |
|---|---|---|---|
| Window and dock chrome | Editor/EditorWindowChrome.cs, with EditorDockChrome at :23 | 546 | context, HalcyonScrollbarTheme |
| Palette and style records | EditorToolbarPalette (EditorToolbar.cs:111), EditorHierarchyStyle (:188), EditorInspectorStyle (:240), EditorBarStyle (:451) | ~400 | Halcyon only |
What stays in the editor whatever happens: the toolbar shell and view (EditorToolbarShell.cs, EditorToolbarView.cs, 5.7 KLOC), the hierarchy, lighting, toolbox, history, options, open and new map windows, the map readout, the console badge, and all of Engine.Client.Edit.
2. The cut
Section titled “2. The cut”Moves as it is. These pieces are pure view over records and need only a namespace change:
- the field primitives, the name plate, name link, asset label, crumb, chip and spawned mark;
- the window and dock chrome, and the palette and style records;
- the browser pane, browser window, browse crumbs, material list and picker;
- the drawer, preview window and image viewer;
- the whole inspector: its shell, field rows, menus, the folder, asset and material cards, and the world cards (identity, transform, light, camera, wiring, spawn, skinning, restore ledger, probes), which become generated or hand-made cards over component descriptions (see below);
- the records they read: the browser model, the crumb model, the picture records,
MaterialPreviewView, the material target, the patch cards, the card kinds and the inspector target.
The helpers UiCenteredLabel, UiThumbnail and AssetNaming go with them. HalcyonScrollbarTheme does not: it reads the live accent, so its style becomes a palette member the host fills. UiIcons splits: its icon names are DigitalHeaven vocabulary over Core.Assets and move, while loading the atlas (UiIconFiles) stays host-side.
Needs a seam first.
-
The context.
EditorToolbarContextis one record of 245 members, most of them about the toolbar, the hierarchy and baking. The library cannot name it. Three records in the library carry the part the moved panes read:BrowserContext: pallets, folder listing, catalog epoch, reveal, refresh, mint, reveal-in-OS, can-spawn, split, zoom, crumb style and the material drag;InspectorContext: theIInspectorAdapterdescribed below, plus the inspector’s style and tuning;PictureSource, described below.
EditorToolbarContextgains one member of each and loses the flat members they replace, so it is still filled in one place. The tuning defaults those members carry (DefaultMaterialDrawerSnap,DefaultInspectorLabelFractionand the like) move with them. The library reads no preference store, the same rule Halcyon keeps: the editor fills them from itsPreference<T>s and Studio from its settings file.Preference<T>lives inEngine/DigitalHeaven.Engineand can never be named by the library. -
Inspector cards. The inspector’s
Build(:1983-2726) decides inline which cards to show, and each card is hand-written C#. That becomes data: a target lists its components, each component has a description, and the shell draws a generated card from it unless a hand-made card is registered for that component. See cards from component descriptions. -
Reads and writes. Today the inspector reads
EditorInspectorTargetand writes through about forty setters on the context. Both become one adapter per host. See the inspector adapter. -
The browser’s model producer.
EditorAssetCatalogmoves with its search depth passed in rather than read, andEditorAssetSourcewith it. The blank-material stub they mint, a free file name and the.dh-matextension moved down intoContent.SourcesasMaterialSourceFile, whichMaterialSourceWriterbuilds on.
Stays engine-side. Only the adapter and the rendering:
Engine.Client.Edit, which becomes the editor’s inspector adapter: selection, the fold, pending edits, history, and the server write path;- the record producers that read materials, rendering or logic (
EditorMaterialDescribe,EditorMaterialSlots), which feed the adapter; - the renderer behind the picture source;
HalcyonEditorScreen, the toolbar, and every window listed above as staying.
EditorLogicCards does not stay. Its hand-written switch over entity kinds is exactly what component descriptions replace.
Cards from component descriptions
Section titled “Cards from component descriptions”mltn expects “a billion more components later, and custom components”, so a card per component written in C# does not scale. A component is described as data, and the inspector draws it.
A component description names:
- the component’s type id and title;
- its fields, each with a key, a type (number, integer, boolean, text, vector, color, enum, asset reference, list, map, record), a default, an optional range and step, a unit and a line of help;
- its signals, the inputs and outputs it takes part in for wiring.
The library’s generated card turns each field into the row the inspector already draws for that type. That covers the scrubbed number, the vector with its axis letters, the switch, the choice, the material field and the thumbnail. Each row keeps the inspector’s existing marks: pending, mixed across a multi-selection, and changed from the source. A hand-made card is registered by type id only where a generated one cannot say enough: the transform with its handle space, the light with its color and cone, the camera with its live preview, wiring, materials with the drawer, skinning and the restore ledger. Hand-made cards still read and write through the same adapter. Stage 3 narrows that list to five; see card levels.
Card levels
Section titled “Card levels”Every component card is generated. Five cards are hand-made because they are not component cards at all: identity, transform, wiring, the restore ledger, and the humanoid bone map of dh.skeleton. A component grows its card at three levels, each in retained-mode Halcyon: a card builds when the adapter’s Revision moves or the pane resizes, never once a frame, a button is a widget with a callback, and every widget carries a stable key, so a group’s open state or a half-typed number lives in the pane’s retained tree and never in an editor class.
Level 1, attributes. No editor code at all. The component’s attributes become the card’s structure:
[ComponentType("dh.light", WireId = 7), Icon(UiIcons.CardLight)][Button("Bake this light", Verb = "light.bake")] // a host verbpublic sealed partial class LightComponent : Component{ [Field("On at start")] public bool StartOn { get; set; } = true;
[Header("Emission")] [Field("Color", As = ComponentFieldTypes.Color)] public float[] Color { get; set; } = [1f, 1f, 1f]; [Field("Intensity", Unit = "lm", Min = 0f), Live] public float Intensity { get; set; } = 800f;
[Group("Spot", Collapsed = true), When("kind", MapLightKinds.Spot)] [Field("Inner cone", Unit = "°", Min = 0f, Max = 179f)] public float InnerConeDegrees { get; set; } = 30f;
[Button("Match cones")] // a data method public void MatchCones() => InnerConeDegrees = OuterConeDegrees;}[Header]draws a labeled category over the run of fields it starts.[Group]gathers every field naming it into a folding group, drawn where its first field stands; its open state is the pane’s, kept undereditorInspectorComponent:<title>:group:<name>, andCollapsedsays how it starts.[Icon]heads the card and gives the component a hierarchy chip of its own; a component with no icon shares the one Components chip.[When]leaves a row out unless another field holds the named value.- A data method button runs the method on a copy of the component built from what the host read, and every field it changed is written back as one gesture; a method that changed one field writes it as a plain set. It needs a registered component type and a host that writes several fields as one gesture, and draws disabled with the reason otherwise. A verb button asks for the host’s verb of that name; one the host has not registered draws disabled, its reason on the hover plate, the way an unwritable field draws read-only.
Field editors. One table in the library, keyed by field type (Unity’s PropertyDrawer): ComponentFieldEditors. Each editor builds from the inspector’s own controls, which InspectorRows lends to editors and custom cards, so nothing is forked. [Editor("name")] picks a registered editor by name, and a name nothing registered falls back to the type’s.
| Type | Writable | Read-only |
|---|---|---|
| number, integer | the scrub cell, clamped to the range; an integer rounds | the value in the text box |
| boolean | the window’s check box | the check box, disabled |
| text | the text box | the text box, read-only |
| vector | three cells with axis letters | the value in the text box |
| color | the swatch over three channel cells | the value in the text box |
| enum | the choice field | the word in the text box |
| asset | dh.material: the material disc and its name; any other: the reference, typed | the reference in the text box |
| list | a group with a row per item, a remove button on each and Add at the foot, written back whole | the items in the text box |
| map | a group with a row per entry by name, a remove button on each and an Add row that takes a name | a group with a row per entry |
| record | a sub-group of its own fields, each by its own editor | the same, read-only |
The read-only face of every single value is the text box, the diminished state the window draws any value a person may only read in. That is also what keeps every generated card that existed before this step byte-identical: the editor wires no component write yet. A row keeps every mark the generated card had: the deviation gutter for a pending edit or a value that differs from the layer underneath, the inherited value ghosted beside it, and the mixed dash across a selection.
Reads of collections. A record is read through its fields (housing.depth). A map, or a list of records, reads at its own key as the words naming its entries, and each entry at key.name (ComponentField.EntryKey). InspectorComponent.Of(component) reads a live component that way. A voxel volume’s delta.grids is now described as what it is, a map of switches.
Level 2, append. An extension keeps the generated card whole and adds to it; it never draws a field.
public abstract class ComponentCardExtension{ public abstract string TypeId { get; } public virtual Widget? Top(ComponentCardContext card) => null; // under the header public virtual Widget? AfterGroup(ComponentCardContext card, string group) => null; // after a header's run or a group public virtual Widget? Bottom(ComponentCardContext card) => null; // under the last row}Level 3, full override. A full card builds its own tree under the card’s header and calls back for generated rows:
public abstract class ComponentCard{ public abstract string TypeId { get; } public abstract Widget Build(ComponentCardContext card);}
sealed class SkeletonCard : ComponentCard{ public override string TypeId => "dh.skeleton"; public override Widget Build(ComponentCardContext card) => Ui.Column( [ card.Rows("rig", "viewPosition"), // generated rows, every mark kept card.Read<bool>("humanoid") ? BoneMap(card) // a helper, not an inline loop (iOS AOT) : card.Note("This rig maps no humanoid bones."), card.Group("Advanced") ?? card.Note("Nothing advanced."), card.Button("Recenter view", card.CanWrite ? () => card.Write("viewPosition", ComponentValue.FromNumbers([0f, 1.6f, 0f])) : null), ], key: card.Key + ":skeleton");}ComponentCardContext offers Rows(params keys) and Group(name) (generated rows and groups), Read<T>(key) and Value(key), Write(key, value, gesture) and Revert(key) through the adapter, Button(label, onPressed), Note(text), Controls (the inspector’s row controls), Palette, Style, Target, Adapter, Description, TypeId, Key and CanWrite. A full card is an ordinary Halcyon build, so it keeps the iOS AOT rule: no nested foreach or using inside one large build.
Registration. ComponentCardTable holds extensions and full cards by component type id. ComponentCardTable.Shared is the one a host registers into at startup, and the one InspectorContext.Cards reads unless handed another. A type takes one extension or one full card, never both or two: registering the second throws.
Writes. A single value goes through InspectorWrites.SetComponentField. Several fields as one gesture, a revert, or a map entry added or removed go through WriteComponentFields(type, fields, gesture), where a null value reverts a field to the layer underneath and WriteGesture.Drag marks a step of a gesture still in flight. Verb buttons read ComponentVerbs. The editor wires none of the three yet: the server’s set-component-field edit wires the first, and the other two follow it.
Still hand-made today. The light, camera, light probe volume, reflection probe, spawn, renderer, collider, rig, armature link and spring bone cards are built-in cards over fields that are not described components yet, so they stay as they are until their component conversion. The plan’s two level-2 decorations, the camera’s live preview and the probe volume’s probe count, cannot be extensions until then: their cards scrub every field through per-field verbs, while a generated card over a field no host writes draws the text box, so the DrawList could not match.
Where descriptions come from today. There is no one place. Pieces exist, and none of them is complete:
| Source | Where | What it gives | What it lacks |
|---|---|---|---|
| Logic signals | Core/Assets/LogicSchema.cs (260): per-kind inputs with a parameter type, and outputs | the signal half of a description, already shared by validation and runtime dispatch | fields |
| Entity kinds | Core/Assets/LogicEntities.cs and MapDefinition.cs: one sealed C# class per kind (DoorEntity :39), with a $type id, [JsonPropertyName] keys and default initializers | field keys, types and defaults, by reflection | help (XML doc comments only, so not available at run time), ranges (clamped in engine install code instead), units, titles |
| Eye settings | Core/Assets/SetEyesFields.cs (276): every field with a path, help, minimum and maximum, a getter and setter, and its default | the one complete field-description table in the tree | it is specific to dh.setEyes and written as C# lambdas, not data |
| Camera fields | Core/Assets/CameraFields.cs (244), CameraSettings :130: field names shared with the console command | names | everything else |
| Object components | Core/Assets/PhysicsPropComponent.cs, ObjectComponents.cs: a component is a patch on a dh.object | storage on objects | a description |
| Inspector card kinds | Engine.Client/Editor/EditorCardKinds.cs (312): a closed EditorCardKind enum that the hierarchy’s chips and the inspector agree on | the list of cards | openness: a new component means a new enum member |
| Logic cards | Engine.Client/Editor/EditorLogicCards.cs (251): a switch from kind to read-only rows | the cards as drawn today | editing, and any kind it does not know |
| Studio’s patch table | Studio/.../PatchEditor/PatchTypeInfo.cs (142): which patch types have a form | the Avalonia app’s list | sharing; it duplicates knowledge in Core |
The plan is to put ComponentDescription in Core beside LogicSchema, so the compiler, the runtime and both inspectors read one table:
- the built-in kinds get their descriptions there, written once, with their signals folded in from
LogicSchema; SetEyesFieldsbecomes the first description converted, because it already has everything;EditorCardKindbecomes an open key, the component’s type id, so a hierarchy chip and a card still come from the same list.
What custom components still need. Each item is its own step after this extraction, not part of it:
- Descriptions as data. A pallet declares a component, its fields and its signals in a source file the compiler validates.
Corereads that file into the sameComponentDescriptionthe built-ins use. - Storage for a kind the build does not know. Today
MapObject.UnknownMemberscatches members no kind defines, and the compiler rejects them by name, deliberately. A custom component needs a generic, typed bag keyed by component type id that both the map and the object formats carry. - A generic write. The editor writes through per-field verbs (
SetInspectorAxis,SetInspectorLight(name, value),SetMaterialField(barcode, field, value)). The two name-and-value setters already have the right shape. A genericset component fieldedit command, with its history entry, replaces the per-kind verbs on the server path. - Behavior. What a custom component does at run time (scripting, or a game-module hook) is outside this note. Without it, a custom component is data the inspector edits and the map carries, which is still useful for platform exports.
The inspector adapter
Section titled “The inspector adapter”The inspector reads and writes only through one interface that each host implements:
public interface IInspectorAdapter{ int Revision { get; } // bumps when anything shown changed InspectorSelection Selection { get; } // members, the active one, what kind of thing each is IReadOnlyList<ComponentView> Components(InspectorTargetId target); InspectorValue Read(InspectorTargetId target, FieldPath field); // value, mixed, pending, changed-from-source bool CanWrite(InspectorTargetId target, FieldPath field, out string? reason); void Write(IReadOnlyList<InspectorTargetId> targets, FieldPath field, InspectorValue value, WriteGesture gesture); void Revert(IReadOnlyList<InspectorTargetId> targets, FieldPath field);}WriteGesture says whether a write is a drag in progress or the end of one, so a scrub is one history entry, as today.
- The editor’s adapter lives in
Engine.Client.Edit. It reads the live map through the fold, pending edits and the staged object. It writes through the existing server path with undo, so authority, the save guard, “one gesture is one entry” and the multi-select fan-out all keep working unchanged. ItsCanWritereason is today’sAuthorityReason. - Studio’s adapter reads and writes definition files (
dh.object,dh.map, materials, patches) throughCoreandContent.Sources, holds its own undo stack, and saves through the same atomic write the Avalonia app uses. Its targets are files and the entries inside them, never live nodes. - A host’s own records (
EditorInspectorTarget,EditorInspectorMixed,EditorInspectorPresence) become what the editor’s adapter computes, not what the inspector reads.
The picture source
Section titled “The picture source”Everything the drawer, the browser tiles and the inspector’s thumbnails show is a texture id, a UV rectangle and a size, plus a monotonic epoch that says “something new arrived, repaint”. The library names that seam:
public interface IPictureSource{ int Epoch { get; } EditorThumbnail? Thumbnail(string barcode, int edge); // null: not ready, draw the kind icon EditorTexturePlate? Plate(string barcode); // a texture at full size, for the image viewer UiMapThumbnail MapThumbnail(string barcode); // None where the map ships none EditorMaterialPreview? OpenPreview(string key); // one live preview per inspector pane or window void ShowPreview(int handle, MaterialPreviewView view); Vector2 PreviewUv(int handle); void ClosePreview(int handle);}EditorThumbnail is a texture id, a UV rectangle and a size; EditorMaterialPreview is a handle and the texture slot it draws into; MaterialPreviewView is the body, the camera and the surface. The interface replaced eight context members one for one, and every one of them was already read with ?.Invoke, so a source that answers “nothing” and a missing delegate draw the same. That is why the in-process DelegatePictureSource (a record over the host’s delegates, null members answering null) left every pixel where it was. IMaterialDescriber is the same shape for the two questions a pane asks of a material reference without loading it: is it water, and what does it inherit.
- The editor implements it in-process over the renderer it already drives: the existing delegates, wrapped. Nothing about how a preview is rendered, framed or quantized changes.
- Studio implements it over a sidecar engine process, the pattern the avatar service already ships for Minecraft. Studio starts the engine headless in a preview mode (
--previewService, a sibling of--avatarService) and sends it one render request per line over a pipe. As built (2026-10-05) the engine answers each with a PNG in a directory both read, named by a content fingerprint, rather than with frames in a shared mapping: Studio decodes the PNG into its own Halcyon texture and bumpsEpoch, the on-disk directory is the cache, and the live orbit is not built. See Halcyon Studio. - With no sidecar running, every call answers null and the panes draw the kind icon, which is exactly what the editor shows before a thumbnail lands. Studio stays fully usable without a picture.
The engine stays out of Studio’s process, and the library never learns which of the two is behind the interface.
The shared item browser
Section titled “The shared item browser”ItemBrowser.cs holds the one body every list of things is browsed in: the list, the details table at the ladder’s lowest rung, the tiles from its grid edge, the favorite star and the size slider. The engine’s ItemPickerPane (the map and server pickers) keeps its rail, search and slots and mounts it as its middle; Studio’s Import page browses a game’s maps with it. What an item is reaches it as ItemPickerRows (an id, the row and tile builders, a sort value, and an optional Paint that tints the whole row or tile by state). The records ItemPickerKeys, ItemPickerRow, ItemPickerTable and ItemPickerLayout moved here from Engine.Client with their names. The host’s theme arrives as an ItemBrowserLook (row paint, star inks, scrollbar, slider colors, density), so the engine paints it from HalcyonSettingsTheme and Studio from its role palette. ItemBrowserView keeps the rung, the sort and the column widths for its host, which hands it its own SetState. Virtualize (Studio’s) builds only the rows on screen and lays tiles in whole lines from ListWidth, and FillWidth shares the width a line leaves over among its tiles; the engine leaves both off and draws byte-identically. A row’s Group puts a heading, with the group’s count, ahead of each run of rows in the list and the tiles (the tiles wrap inside a group, the details table does not group); ItemBrowser.Heading is the one heading a list beside the browser draws its own groups with. The asset browser’s BrowserPane still has a list and grid body of its own and is the next to fold into this one.
3. Dependencies
Section titled “3. Dependencies”Name: DigitalHeaven.Authoring.Ui, as the proposal suggests, with DigitalHeaven.Authoring.Ui.Tests beside it.
Place: a new top-level group, Authoring/. The other places each break a rule:
- Under
Halcyon/is forbidden: Halcyon is unbranded and never referencesContent/orToolchain/. - Under
Apps/is forbidden for the same reason. - Under
Studio/would put a project the engine references inside the group that “edits pallets”. The arrow would point fromEngine/intoStudio/, which reads backwards. - Under
Content/would make the content layer carry widgets, whilePlatforms.*bridges referenceContentand must stay UI-free.
Authoring/ sits above Halcyon/ and Content/ and below Engine/ and Studio/, the same way Content/ sits below Toolchain, Engine and Studio.
Allowed references:
| Reference | Why |
|---|---|
Halcyon | widgets, layout, DrawList, Studio Black, docking |
Halcyon.Fonts | FontLadder rungs for row text |
Content/DigitalHeaven.Content | asset naming, the pallet catalog, crumb resolution |
Toolchain/DigitalHeaven.Core | Core.Assets kinds and extensions, workspace paths |
Engine/DigitalHeaven.Engine.Audio | clip playback and waveforms; it references no other project, and Studio already uses it |
Never allowed: any other Engine/* project, Toolchain/DigitalHeaven.Compiler, Toolchain/DigitalHeaven.Imaging, Halcyon.Vulkan or any GPU binding. Pictures are texture ids, so the library draws them without a device.
How the editor references it. Engine.Client references the library, because the records EditorToolbarContext carries now live there. DigitalHeaven.Editor gets it through Engine.Client and keeps its one reference. Engine.Client.Edit sees it the same way. DigitalHeaven.Engine itself does not reference it: the preferences already keep their own twins of the context defaults (EditorPreferences.cs:1627 says so), and that does not change here.
How Studio references it. The Halcyon Studio becomes Studio/DigitalHeaven.Studio, referencing the library, Content, Core, Compiler (build and validation) and the Halcyon host libraries. It does not reference Apps/HalcyonXrOverlay. The overlay’s window shell (SdlSettingsWindow, LiveRedraw, WindowStateStore under Apps/HalcyonXrOverlay/Window/) moves into a Halcyon library first, so Studio and the overlay share it rather than fork it. That move belongs to the Studio app’s own plan, not this one.
CONTRIBUTING’s dependency rules gain the Authoring/ lines when the project is created.
The build-state inks move into Halcyon. One state list replaces both EditorAssetBuildState (Unknown, NotBuilt, Stale, Built, CompiledOnly) and Studio’s PalletBuildStatus (NotBuilt, Built, Stale, Building, Queued), with Failed added. The list is DigitalHeaven vocabulary and lives in the library. Its colors are generic ok, warn and bad tones, and those go into StudioBlack beside Danger, so Halcyon stays unbranded. The pallet owner inks (map, workspace, core) stay in the library.
4. Inspector parity with the Avalonia Studio
Section titled “4. Inspector parity with the Avalonia Studio”The editor’s inspector has none of these today. Its asset card (BuildAsset, :5190) is a summary for anything that is not a material or a folder, and EditorAssetKind has no audio kind. Each feature below is built once in the library, as a generated card where a description says enough and a hand-made card where it does not, so the editor gets it the day Studio does.
| Feature | Avalonia Studio today | Where it lives in the library | What it needs |
|---|---|---|---|
| Audio ▶ and waveform | Services/ClipPlayer.cs (97, Engine.Audio over miniaudio, a 100 ms Avalonia timer), ViewModels/AudioClipViewModel.cs (137), Controls/WaveformStrip.cs (52), Views/PlayButton | Inspector/Audio: a hand-made audio card, a play chip and a waveform strip drawn from peaks | Engine.Audio directly (AudioSystem, AudioFileProbe, AudioWaveform), a frame-driven poll in place of the Avalonia timer, and an Audio asset kind in the browser model |
| Reactions with clip playback | ViewModels/ReactionsViewModel.cs (150) over AvatarReactionsView.Build from Compiler.Processing | Inspector/Reactions, a card provider for avatars, reusing the audio card’s play chip on each clip | the reactions view as data; it lives in the Compiler today, so it either moves down to Core or a host hands it in |
| Eyes | PatchEditor/SetEyesPatchViewModel.cs (161) over Core (SetEyesFields, EyeLookSettings) | a generated card: SetEyesFields is the first description converted | Core only |
| Proportions | ViewModels/ProportionsViewModel.cs (177) over Toolchain/DigitalHeaven.Proportions, a PNG from RgbaImage.ToPng | Inspector/Proportions, a card showing the figure panels | the panels as a picture through IPictureSource, not as an image object in the library |
| Patch Visual editor | PatchEditor/* (1,948), PatchEditorView.axaml (801), Services/PatchTextSplicer.cs (592, no Avalonia) | Patches/: each patch type is a component description, so its form is the generated card; the splicer moves as it is and backs Studio’s adapter | no second set of rows: a patch form and an inspector card are the same card |
| Raw edit | RawEditViewModel.cs (335) over AvaloniaEdit and TextMate | RawEdit/, a pane in the library | a multi-line code editor widget, which Halcyon does not have yet |
| Diagnostics | StatusBarViewModel.cs (188) over Core.Diagnostics.Diagnostic | Diagnostics/, a pane in the library | Core only |
Build and Auto build stay with each host. The editor builds through its toolbar and Studio through the Compiler’s BuildService shape. Only the build-state list they report is shared.
5. Steps
Section titled “5. Steps”Every step is mergeable on its own. It keeps the editor’s UI capture frames byte-identical and every test green. “Byte-identical” is checked two ways:
- The before-and-after gate. Render the
--renderUitimeline on main and on the step, then byte-compare every PNG, all 120 editor frames included. - The determinism script.
Engine/scripts/verify-render-determinism.batproves a run equals itself. It cannot see a change that is stable, which is why the first gate exists.
Each step also needs a clean build before any test result counts.
The GC rule. Moving a type into the library moves it across an assembly line, which is exactly the shape behind the 2026-09-09 iPad join crash. A struct with references, held by value in a class whose instances may exist before the struct is laid out, can lose those references under Mono full-AOT (see CONTRIBUTING’s iOS garbage-collector section). The GcStructAudit target on DigitalHeaven.Engine.Tests counts those sites and fails the build above a pinned baseline. So every step keeps that gate green, and a model that carries references and crosses into Engine.Client, which iOS runs, is a class rather than a struct. That is why the contexts of step 4 are classes. Reference-free records (colors, sizes, enums) move freely. A crossing that only the desktop-only editor leaf holds is tolerated while a later step will put both sides in one assembly. The last one was EditorBrowserPane.ListedRow.Row (an EditorAssetRow), which step 7 removed.
| Step | Scope | Size |
|---|---|---|
| 0. The gate | Engine/scripts/ui-capture.bat: capture <name> renders --renderUi into .tmp\ui-capture\<name>, and compare <left> <right> compares two captures by name and bytes. The PNG comparison is shared with the determinism script through _compare-png-sets.bat. Take main’s baseline. | a script |
| 1. The project | Create Authoring/DigitalHeaven.Authoring.Ui and its tests. Reference it from Engine.Client, and add both to the solution and DigitalHeaven.Dev.slnf. Update the CONTRIBUTING rules and the Halcyon README’s “nothing references back” wording. Prove the wiring with the first move: UiCenteredLabel and the picture records (EditorThumbnail, EditorTexturePlate, EditorMaterialPreview). | ~0.2 KLOC moved |
| 2. Records and helpers | Moved: the browser model (EditorAssetKind, EditorAssetBuildState, EditorAssetRow, EditorAssetPallet, EditorAssetKinds), AssetNaming, UiMapThumbnail, UiThumbnail and the reference-free style records (EditorToolbarPalette, EditorHierarchyStyle, EditorInspectorStyle, EditorBarStyle). Deferred, each to the step that gives it the right shape: EditorBrowserReveal, EditorBrowserSplit and EditorBrowserZoom carry references and are held by value by HalcyonLayer, which iOS runs (see the GC rule below), so they go to step 4 inside a class-shaped BrowserContext; MaterialPreviewView leans on MaterialPreviewFraming and the shape table, which read the renderer, so it goes to step 6; the crumb model and EditorPalletInks derive the map ink from the engine’s theme seed, so they go to step 5e; the names half of UiIcons goes to step 5b. HalcyonScrollbarTheme reads the live accent, so its style becomes a palette member the host fills rather than a move. | ~0.6 KLOC moved |
| 3. Primitives and dress | Moved: the toolbar’s shared dressing as EditorDress (the hairline, the house radius, the label rung, the menu panel, the anchored context menu and the floating plate, out of EditorToolbarView), and the primitives that need nothing else: EditorChevron, EditorDisclosure, EditorSlider, EditorSwitchRow, EditorSpreadRow, EditorPendingGlow, EditorNameLink, EditorSpawnedMark, EditorFieldStyle, EditorFieldMenu, EditorHoverTooltip, EditorNamePlate and EditorAssetLabel. The engine tests now find a type by name in the module or the library (EditorToolbarTests.ModuleType). Deferred: the section heading and the window and dock chrome take EditorToolbarContext, so they follow in step 5c; the text field, choice field, chip, material field and material name row read the inspector’s own metrics, the icon atlas or the crumb, so they move in step 5e, the crumb with them. | ~1.9 KLOC moved |
| 4. The browser context | Done: EditorBrowserContext, a class-shaped record in the library, carries the browser’s whole seam: the pallets, the catalog epoch, the reveal request, the divider, the tile size and the verbs (list a folder, list a pallet’s names, refresh, regenerate icons, mint a material, reveal in the OS). EditorBrowserReveal, EditorBrowserSplit and EditorBrowserZoom moved in beside it, because they now only ever cross into Engine.Client inside that class (the GC rule). EditorToolbarContext folds eleven flat members into one BrowserContext, read through Browser; HalcyonLayer, UiLayer and HalcyonEditorScreen hold one context instead of five values, EditorHost loses its six browser delegates, and ClientHost builds the context once a frame. The picture source moved to step 6, where the live material previews it must also cover become movable, so the interface is designed once rather than in two halves. InspectorContext arrives with the adapter in step 9. | ~0.2 KLOC new, ~0.3 KLOC removed |
| 5a. Metrics | Done: the shared measures move out of EditorInspectorWindow and EditorToolbarShellState into EditorMetrics (body and small type sizes, a field’s insets, the row and label gaps, the marker width, the material thumbnail’s edges and gap, the dock divider’s thickness), with no value changed. The editor’s own files read them through using static, and the tests read them as constants rather than by reflection. | ~0.1 KLOC moved |
| 5b. Icons | Done: UiIcons moves whole (the names, the surface marks and the glyph lookup), with EditorHierarchyIcons and the EditorHierarchyRowKind enum its catalog names. Install now takes the sheet’s slices by name rather than the engine’s internal UiIconSheet, so the host still loads the atlas, registers the texture and installs it, as before. | ~0.4 KLOC moved |
| 5c. Chrome | Done: EditorDockChrome (pane, strip, drop indicator, drag ghost, dividers, scroll view) takes the palette and plain values rather than the whole EditorToolbarContext, and moves. The palette gains the host’s live scrollbar style (EditorToolbarPalette.Scrollbar, filled from HalcyonScrollbarTheme), which is the palette member the step-2 note promised. The toolbox plate joins EditorToolboxWindow, the one window it dresses. The section heading reads the inspector’s resolved style, so it moves with the inspector in step 8. | ~0.5 KLOC moved |
| 5d. Drag records | Done: EditorMaterialDrag, EditorDragKind, EditorMaterialDropKind and EditorMaterialSlot move. EditorMaterialDrag carries delegates and rides on EditorToolbarContext, so it became a class-shaped record (EditorMaterialDrag.None stands for an empty hand); the context keeps reading it as MaterialDrag, through a non-null property over the new Drag member, the way Browser reads BrowserContext. The GC audit fell from 23 authored sites to 22, because the browser pane’s own Lift.Drag crossing went with it. | ~0.2 KLOC moved |
| 5e. Inspector widgets and the crumb | Done: the chip, choice field, text field and material field move, with the crumb, its model EditorAssetCrumb, EditorCrumbStyle and EditorPalletInks. The default seed is one constant, StudioBlack.DefaultAccentRgb: StudioBlack.DefaultAccent, the engine’s UiThemeColors (its default and its orchid entry) and the library’s map ink all derive from it, and EditorToolbarPalette.ToChannels is the one color-to-channels rounding the engine’s selection colors now share. The crumb’s context-taking overload stays in the editor as EditorContextCrumb, because EditorToolbarContext cannot cross. The material name row reads thumbnails through the context, so it moves with the picture source in step 6. | ~1.3 KLOC moved |
| 5f. Preview shapes | Done: MaterialPreviewShape and MaterialPreviewLighting move with their vocabulary (MaterialPreviewShapes: the stored words, the water and dry bodies, the icon body; MaterialPreviewLightings). The renderer keeps what it measures in a new MaterialPreviewBodies: the cube and plane extents, each body’s bounding radius, and the icon word a material definition asks for. | ~0.2 KLOC moved |
| 6a. The picture source | Done: IPictureSource with its in-process DelegatePictureSource, and IMaterialDescriber with DelegateMaterialDescriber; EditorToolbarContext folds ten flat members into PictureSource and MaterialDescriber, read through Pictures and Materials. MaterialPreviewView moves with its aspect bounds (MinAspect, MaxAspect, ClampAspect, which the renderer’s framing now reads), and MaterialPreviewObjects and EditorAssetReferences move. The transparency checker’s cell and two grays become StudioBlack.CheckerCell, CheckerLight and CheckerDark, read by the engine’s thumbnail compositor and the viewer alike. ContentPadding joins EditorMetrics. The GC audit’s ceiling falls from 28 to 22, the count since step 5d. | ~0.5 KLOC new, ~0.3 KLOC moved |
| 6b. The drawer | Done: the drawer, the preview window, the image viewer and the material name row move. They take EditorPaneContext, a class-shaped library record that EditorToolbarContext.Panes builds: the palette, the bar and crumb styles, the clipboard, the crumb resolver as a delegate (it reads the browser’s pallets), the material drag, Pictures, Materials, Qualify against the map’s barcode, the tuning numbers (the group gap, the checker cell, the texture preview size), and the inspector’s marker ink and the Open and inherits labels. The browser and the inspector take it in steps 7 and 8. The crumb’s pane overload joins EditorCrumb, so EditorContextCrumb is gone, and the browser’s row type size and band gap join EditorMetrics, which the image viewer’s bar shares. | ~1.5 KLOC moved |
| 7. The browser | Done: the browser pane, the browser window, the browse crumbs and the material picker move, taking EditorBrowserContext and EditorPaneContext in place of EditorToolbarContext. The pane context gains the verbs more than one pane hands back (inspect a material, open its preview, open a prefab, view a texture, open a map, and the bind and spawn permissions), so the preview window reads its two from there too. Whether a row opens as a prefab, and the word for it, move from EditorPrefabTab to EditorAssetKinds (OpensAsPrefab, OpenAsPrefabLabel), which the inspector shares. The material list reads the inspector’s height and group dress, so it moves with the inspector in step 8. The GC audit falls from 22 authored sites to 21, because EditorBrowserPane.ListedRow.Row no longer crosses, and the ceiling falls with it. | ~2.5 KLOC moved |
| 8. The inspector, whole | Done: the entire inspector moves into the library as it was, every card included: the window and its body, the shell and rows, the folder, asset, material and world cards, the section heading, EditorMaterialList, the card models, actions and kinds, the patch cards, the per-pane pin (EditorInspectorPane), the target model (EditorInspection.cs), EditorMaterialTarget, the wiring edits and the staged fields. It takes InspectorContext, a class-shaped record in the library: the pane context for what it already carries (palette, bar and crumb dress, drag, pictures, qualification, inspect, preview, open, view, clipboard), the inspector’s tuning (Style, with the DefaultInspector* numbers and their resolved readings, which left EditorToolbarContext), the target, the mixed and presence records, and the setters. EditorToolbarContext.Inspection builds it on each read, the way Panes builds the pane context. Pieces that had to follow: MaterialPreview (an open material body, once nested in the shell state) and EditorInspectorWindow.Cell (the scrub cell the lighting window also draws). The hierarchy row stays in Engine.Client, and the probe card reads the map’s authored spacing as a plain number (AuthoredProbeSpacing) so EditorGiReadout need not cross. The GC audit falls from 21 authored sites to 20, because EditorInspectorBody now holds a class rather than the toolbar context by value, and the ceiling falls with it. | ~7.3 KLOC moved |
| 9. The adapter | Done: IInspectorAdapter joins the library. It carries the reads (the target, the inspected material, the mixed and presence records, the revision, the selection count, the map’s authored probe spacing and source path, the camera card’s live picture) and Writes, an InspectorWrites table with one verb per thing a card edits, null where the host cannot write it, which is how a card knows to draw read-only. InspectorContext shrinks to the pane context, the style, the scrub seam and Adapter; the inspector reads and writes only through them, and PinnedInspectorAdapter stands a locked pane’s snapshot in front of the host’s. The flat inspector members leave EditorToolbarContext, which gains InspectorAdapter (read through Inspector) and keeps the style and the scrub seam, which are host dress rather than data; GetClipboard joins SetClipboard on the pane context. The editor’s adapter, EditorInspectorAdapter, is built once a frame from the host’s pushed values and EditorHost.InspectorWrites(), after EditorAuthority has withdrawn what the session may not do. It sits beside EditorHost in Engine.Client rather than in Engine.Client.Edit, because the offscreen capture builds one too and Engine.Client cannot reference the assembly above it; moving the host’s SetEditorTarget* bodies down into Engine.Client.Edit is what would let it live there. The verbs are typed per field today; the generic read and write by field path arrives behind the same interface with the descriptions of steps 10 and 11. | ~0.5 KLOC new |
| 10. Descriptions | Done: ComponentDescription joins Core (Assets/ComponentDescription.cs): a type id, a title, its fields and its signals. A ComponentField has a key (a dotted path for a field inside a block), a type from ComponentFieldTypes (number, integer, boolean, text, vector, color, enum, asset, list), a label and a unit kept apart, a line of help, a default, an optional range and step, its choices and a list’s item type. A default is a ComponentValue, a class holding numbers, a switch, a word or words, which reads and writes as the plain JSON value; input parameters write as words. Every built-in logic kind is described in ComponentDescriptions, in the order they were declared, and the signal tables moved onto those descriptions: LogicSchema answers every wiring question from them, so a kind is described once. A kind’s fields are its own: the box and the material are the renderer’s, the pose the transform’s. EditorCardKind is now a class keyed by an open string id, compared by id, with the built-in cards as named instances (the light, camera, probe and reflection cards keyed by their entity type ids) and EditorCardKind.Of minting a key for any other component; EditorCardKinds.Label names an unknown key by its description’s title. Nothing renders differently. | ~0.8 KLOC new |
| 11. Generated cards | Done: the inspector draws a described component as a generated card: one row per field the host has a value for, in the description’s order, captioned with the label and its unit in brackets, the value spelled by ComponentValueText (invariant, millimeter-fine, a list comma-separated), and a value that differs from the layer underneath marked with the inherited value ghosted beside it. EditorInspectorTarget.Components holds InspectorComponents, a description and what the host read for each field as an InspectorValue (the value, and the layer underneath where there is one); a field the host leaves out is no row. The card reads through IInspectorAdapter.Read(component, field), which both adapters answer from their target, and writes through InspectorWrites.SetComponentField(component, field, value), the generic write by field path; the editor leaves it null, so every generated card is read-only until the server gains a set-component-field edit. EditorLogicCards.For is now the editor adapter’s half of the read: where a door, a timer, a physics prop (a map’s own or an instance’s, its source as the layer underneath) and a voxel volume keep each field. All four converted, each gated by a test that the generated card’s DrawList equals the hand-written card’s (door with authored and default values, timer, prop with an authored mass, a density mass and a client sim, an instance departing and restating, a volume bare and with grids and lighting), and then the hand-written card, EditorComponentCard, EditorComponentRow and the per-kind row words were deleted. None had to stay hand-made. | ~0.6 KLOC new, ~0.3 deleted |
| 12. Eyes | Done: SetEyesFields.Description is the eye table as a component description keyed by each field’s path (look.confidence), built from the table itself, so Studio’s typed accessors and the description cannot drift: every field gained a label and a unit, a toggle describes itself as a boolean defaulting to what an avatar stating nothing does, a number with its range and the client setting’s default, and a field one of the avatar’s axes moves states no default, since its help already says what moves it. SetEyesFields.Read(behavior, settings) gives every field’s effective value. The editor shows it as a generated card on every spawned avatar with a face, read from the face’s resolved profiles over the client’s own eye preferences, which is what drives its eyes. The generated card draws a boolean field as the window’s one check box, read-only until a host wires SetComponentField; EditorHost gains that verb, wired by nothing yet and withdrawn with the rest by EditorAuthority. The new frame editorAvatarEyes is appended after every other lane, so the 289 existing frames are unchanged. | ~0.3 KLOC |
| 13. The catalog | Done: EditorAssetCatalog and EditorAssetSource move into the library, the catalog taking its search depth as a constructor argument (the host still sets it each frame from EditorPreferences.BrowserSearchDepth). The one Engine.Materials use, minting a blank material, is now MaterialSourceFile in Content.Sources (the extension, a free name, the stub), and MaterialSourceWriter mints its derived stub through the same Stub. The host-half tests (the pallet hunt, the walks, the build marker, the debounce, the catalog’s cache, minting, the packaged barcodes) move to Authoring.Ui.Tests as EditorAssetSourceTests. | ~0.9 KLOC moved |
| 14. Build states | Done: one list, PalletBuildState in Core (Unknown, NotBuilt, Stale, Built, CompiledOnly, Failed), which the editor’s browser, the freshness check and the Halcyon Studio app all read; the editor’s EditorAssetBuildState is gone and the Studio app’s Changed is now Stale. Nothing produces Failed yet, so no pixel moves. The ok and warn inks were already StudioBlack.Success and StudioBlack.Warning; the editor’s never-built red joins them as StudioBlack.NotBuilt at its own value (D05A5A, a shade under Danger, which the Studio app’s bad ink and the new Failed dot use), because folding it into Danger would have moved every never-built dot. | ~0.2 KLOC |
| 16. Card levels | Done: the field-editor table (ComponentFieldEditors) draws every generated row, keyed by field type, through InspectorRows, the inspector’s own controls lent to editors and cards. The generated card reads headers, folding groups, icons, conditions and buttons from the description; ComponentCardExtension appends and ComponentCard overrides, registered in ComponentCardTable; InspectorWrites gains WriteComponentFields and ComponentVerbs. See card levels. The read-only face of every value is the text box it already was, so the existing frames are unchanged; editorCardAttributes, editorCardAppend and editorCardOverride, each at 1x and 2x, are appended after the eye card. | ~1.5 KLOC new |
| 15. Names | Done: every type in the library lost its Editor prefix (EditorBrowserPane is BrowserPane, EditorInspectorWindow is InspectorWindow, EditorCardKinds is CardKinds), and each file took its type’s new name. The rows above name the types as they were at the time. Widget key strings and the engine’s own EditorToolbarContext are unchanged. Where the plain name was taken by another type in scope, the library type got a distinct one: EditorMaterialPreview is MaterialPreviewBinding (the library already has MaterialPreview), EditorMaterialRow is MaterialTargetRow (Core has MaterialRow), EditorMetrics is AuthoringMetrics, EditorSlider is SliderControl and EditorTextField and its state are InspectorTextField and InspectorTextFieldState (Halcyon has Slider and TextField), and the card verb attributes are CardButton and CardToggle (Core has ButtonAttribute). The options and lighting windows’ private SwitchRow and Chip helpers became SwitchRowFor and ChipFor. The notes under Engine/design-notes and the engine docs use the new names. | rename only |
After step 15 the library is about 18 KLOC and the editor’s leaf about 9 KLOC: the toolbar, its windows and the hand-off to the library. The hand-made cards are the ones listed under cards from component descriptions. Every other card that exists today becomes generated in step 11, or stays hand-made where its DrawList cannot be matched. Each such case is named in the step’s commit, never quietly restyled.
The parity work in section 4 follows as its own steps, one card or pane each. Those steps add new capture frames and never change existing ones. The audio card goes first, because reactions reuse its play chip. Studio’s adapter and its sidecar picture source come with the Studio app’s own plan, built against the interfaces steps 4, 6 and 9 create.
6. Risks and open questions
Section titled “6. Risks and open questions”Each is phrased as a decision for mltn.
- Generated cards that cannot match. When a generated card cannot reproduce a hand-written card’s pixels, keep the hand-made card (recommended), or accept the visible change once, with before and after frames?
- Descriptions in Core. Put
ComponentDescriptioninCorebesideLogicSchema, so the compiler validates against it (recommended), or inContent? - Custom component storage. A typed bag keyed by component type id on map entities and objects, which replaces today’s deliberate rejection of unknown members for declared components only (recommended), or a separate file per custom component?
- Field primitives’ home. Some primitives (the text field, slider, switch row, disclosure) carry no DigitalHeaven meaning. Fold them into Halcyon’s controls after the extraction, one at a time, each under its own byte-identity gate (recommended), or leave them in the library?
- The reactions view. Move
AvatarReactionsViewfrom the Compiler down toCoreso the library can call it (recommended), or have each host hand it in? - Raw edit. Build a multi-line code editor in Halcyon before the new Studio replaces the Avalonia one, or keep the Avalonia app for raw edits until it exists?
- Sidecar orbit latency. An interactive orbit through the sidecar costs a frame per drag. Accept that, or later share the preview texture across processes (Halcyon’s capture already handles cross-device textures)?
The menu bar
Section titled “The menu bar”The editor and the Halcyon Studio hang their menu bars from one builder, Dress.MenuBar, beside Dress.ContextMenuPanel: the same plate, border, row padding, ink and hover, both read off the host’s ToolbarPalette. A host chooses only the bar’s height, label size, padding and corner radii. BarMenu.EntryRadius and Menu.EntryRadius round a lit row (hover, press, checked, open) so it sits inside the card as a chip; zero, the default, keeps the editor’s rows square and its captures byte-identical. Studio passes a radius and a palette whose hover and press fills read against the black panel.