diff --git a/docs/contributing.mdx b/docs/contributing.mdx index 17d1e19fc..f32bc060f 100644 --- a/docs/contributing.mdx +++ b/docs/contributing.mdx @@ -53,6 +53,14 @@ bun run build # Build all packages bun run --filter '*' typecheck # Type-check all packages ``` +### Studio Editing Work + +If you are changing Studio's visual editing surface, read +[Studio Manual DOM Editing](/contributing/studio-manual-dom-editing) before +editing code. The inspector intentionally exposes only interactions it can +persist safely back to HTML, so changes should preserve the capability gates, +source patching model, and documented limitations. + ### Running Tests diff --git a/docs/contributing/studio-manual-dom-editing.mdx b/docs/contributing/studio-manual-dom-editing.mdx new file mode 100644 index 000000000..e2a343afe --- /dev/null +++ b/docs/contributing/studio-manual-dom-editing.mdx @@ -0,0 +1,315 @@ +--- +title: Studio Manual DOM Editing +description: What the Studio manual DOM editing inspector ships today, including capabilities, UX, and constraints. +--- + +This page documents the current manual DOM editing surface in HyperFrames Studio. It reflects the implementation that ships in the Studio inspector today, not the earlier design draft that explored third-party transform engines. + +## What Shipped + +Studio now supports a direct DOM editing workflow inside the preview: + +- select supported elements directly in the preview +- see an editor-owned overlay around the current selection +- move and resize supported elements on canvas when geometry is safe +- detach eligible layout-controlled layers with an explicit `Make movable` action +- edit style properties from the right-side `Design` inspector +- edit text layers for safe text-bearing selections, including empty text values +- add and remove child text layers for multi-text selections +- edit solid fills, gradients, project-asset image fills, external image fills, opacity, radius, flex metadata, typography, and blend mode +- drill into nested compositions from master view instead of pretending every inner node is editable in place +- generate an element-scoped `Ask agent` prompt bundle from the right inspector + +The important rule is conservative: Studio only exposes interactions it can round-trip back to authored HTML with deterministic behavior. + +## Current User Experience + +### Preview selection + +- Single click selects a patchable element in the preview. +- The selection overlay is rendered in Studio chrome, not injected into authored content. +- The overlay is cleared when: + - the `Inspector` panel is closed + - the user clicks an empty area in the preview + - the underlying element disappears after a source refresh + +### Overlay behavior + +The overlay provides: + +- selection bounds +- drag behavior for supported elements +- a resize handle when width and height are safely patchable +- blocked-drag feedback for unsupported movement + +The overlay intentionally does not include a floating action toolbar. `Ask agent` lives in the right inspector header, and style controls live in the `Design` panel. + +The current implementation uses Studio-owned pointer handling in `DomEditOverlay.tsx`. It does **not** use `Moveable`. + +### Inspector behavior + +The `Design` panel currently includes: + +- `Layout` + - X / Y / W / H fields + - wheel and arrow-key numeric scrubbing + - `Make movable` for block-ish layout-controlled layers that can be detached safely +- `Flex` + - direction, justify, align, gap, clip content +- `Radius` + - slider + live readout +- `Blending` + - opacity slider + live readout + - blend mode +- `Fill` + - solid color + - multi-stop gradient editing + - project asset image fills + - inline image upload into the project assets list + - external image URL fill + - text color +- `Color picker` + - viewport-clamped floating picker + - saturation / brightness crosshair + - hue and alpha sliders + - hex input +- `Text` + - direct text layer editing when the selection is safe to patch + - add / remove text layers for child text selections + - font size, weight, and family controls +- `Selection colors` + - a summary of detected colors for the current selection + +The inspector is intentionally split from `Renders` with a `Design / Renders` tab control in the right panel. Switching to `Renders` does not mean the header-level `Inspector` panel is closed. + +## What Counts As Editable + +Studio builds a `DomEditSelection` and `DomEditCapabilities` object for each selection. + +### Selection requirements + +A node is only useful to Studio if it can be identified with a stable patch target, for example: + +- `id` +- stable selector +- selector index scoped to the correct source file +- composition host mapping when master view is involved + +### Move support + +Move is allowed only when the selected element: + +- has a stable patch target +- is `absolute` or `fixed` +- has `left` and `top` values that resolve to pixel values +- is not transform-driven (`transform: none`) + +### Resize support + +Resize is allowed only when move is already allowed and Studio can also safely patch pixel `width` and/or `height`. + +### Detach from layout support + +Some block-ish layers are selectable and style-editable, but cannot be moved directly because flex, grid, or normal document flow owns their position. + +For those layers, Studio can expose `Make movable` instead of silently converting on drag. The action measures the current visual rect relative to the composition root and writes conservative inline geometry: + +- `position: absolute` +- `left`, `top`, `width`, and `height` in pixels +- `margin: 0` + +The UI explains that this detaches the layer from flex/grid flow and preserves the current visual position. Inline text nodes are not detached directly. + +### Text editing support + +Text editing is allowed only for safe text-bearing selections: + +- supported text-bearing tags such as `div`, `span`, `p`, `strong`, and headings +- self text selections or leaf child text layers +- empty text values after a user clears the content +- not a composition host + +For multi-text selections, Studio shows a text-layer list. Users can select a specific text layer, edit content live, change size, weight, and font family, add a sibling text layer, or remove the active layer. + +### Unsupported examples + +Studio intentionally withholds direct geometry editing for: + +- flex/grid children whose position is emergent from layout, unless the user chooses `Make movable` +- transform-driven geometry +- nested composition internals while the user is still in master view +- nodes without a stable patch target +- inline text spans as geometry targets + +When geometry is blocked but style edits are still safe, the inspector shows the selection and the reason direct geometry editing is unavailable. + +If the user tries to drag a blocked layer, Studio shows a toast. Layout-owned layers point users to `Make movable`; transform-driven or unsafe targets explain that direct move/resize is limited to absolute or fixed pixel geometry with no transform-driven layout. + +## Nested Composition Rules + +Nested compositions are handled explicitly. + +### In master view + +- clicking content inside a nested composition maps back to the composition host +- supported composition hosts can move as a whole when their host geometry is safe +- Studio does not expose direct inner-node geometry edits from the master preview +- double click drills into the subcomposition + +### After drill-down + +- Studio resolves selections inside that composition normally +- direct move/resize becomes available again if the selected inner node meets the capability rules +- text, fill, gradient, image, radius, opacity, and typography edits apply to the selected inner node + +This keeps Studio honest about what it can patch safely from the current editing context. + +## Source Patching Model + +Studio still uses authored HTML as the source of truth. + +The manual DOM editing flow patches source through the existing patch pipeline in `packages/studio/src/utils/sourcePatcher.ts`. + +Current patch types used by the inspector include: + +- inline style patches +- attribute patches for timeline-linked editing paths +- text-content patches +- detach-from-layout style patches + +The flow is: + +1. user selects or manipulates an element in the preview +2. Studio resolves a stable target +3. the preview is updated optimistically for interaction feedback +4. the patch is written back to source +5. the preview refreshes and selection is reattached + +## Gradient Editing + +The current gradient editor is a structured Studio control, not a raw CSS text field. + +It supports: + +- `linear`, `radial`, and `conic` gradients +- repeating variants +- multiple stops +- stop insertion by clicking the preview strip +- stop removal +- angle control +- radial shape and size controls +- radial/conic center controls + +The editor still serializes back to CSS `background-image`, but the inspector works with a parsed gradient model instead of forcing the user to type raw gradient syntax. + +## Image Fill Editing + +The image fill editor is no longer just a raw `background-image` input. + +It supports: + +- selecting an existing project image asset +- uploading an image from the fill panel, which also adds it to the Assets tab +- previewing the selected project asset in the panel +- entering an external URL when the image is not a project asset + +Studio serializes project asset selections back to `background-image: url(...)`, and rewrites asset URLs so nested subcomposition previews still resolve the image correctly. + +## Color Editing + +The color editor is a custom Studio popover instead of the native browser color dialog. + +It supports: + +- opening from the whole color row +- staying inside the viewport near the clicked color +- saturation / brightness picking with visible crosshair guides +- hue and alpha controls with visible handles +- a current color swatch, readout, and hex input + +The picker writes CSS `rgb(...)` or `rgba(...)` values and preserves alpha through edits. + +## Numeric Scrubbing + +Numeric layout/detail inputs support lightweight design-tool-style nudging: + +- mouse wheel over the focused field +- `ArrowUp` / `ArrowDown` +- `Shift` for larger steps +- `Alt` for finer steps + +This is currently used across the numeric commit fields in the inspector, including layout metrics and other numeric text inputs that parse cleanly as values plus units. + +## Files That Own The Feature + +The main implementation lives in: + +- `packages/studio/src/App.tsx` + - overall inspector wiring + - selection lifecycle + - preview hit testing + - persistence hooks + - detach-from-layout commit flow +- `packages/studio/src/components/editor/DomEditOverlay.tsx` + - overlay box, drag, resize, blocked-drag feedback +- `packages/studio/src/components/editor/PropertyPanel.tsx` + - right-side inspector UI +- `packages/studio/src/components/editor/domEditing.ts` + - selection resolution + - capability gating + - text field modeling + - prompt generation +- `packages/studio/src/components/editor/colorValue.ts` + - color parsing, HSV conversion, and CSS color serialization +- `packages/studio/src/components/editor/floatingPanel.ts` + - viewport-safe floating panel placement for color picking +- `packages/studio/src/components/editor/fontAssets.ts` + - imported font asset helpers +- `packages/studio/src/components/editor/fontCatalog.ts` + - Google font catalog metadata and stylesheet URLs +- `packages/studio/src/components/editor/gradientValue.ts` + - gradient parsing, serialization, and stop editing helpers +- `packages/studio/src/utils/sourcePatcher.ts` + - source patch persistence + +Supporting Studio shell changes also landed in: + +- `packages/studio/src/components/nle/NLELayout.tsx` +- `packages/studio/src/components/nle/NLEPreview.tsx` +- `packages/studio/src/components/sidebar/CompositionsTab.tsx` +- `packages/studio/src/components/sidebar/LeftSidebar.tsx` +- `packages/studio/src/player/components/Player.tsx` +- `packages/studio/src/player/components/Timeline.tsx` +- `packages/studio/src/player/components/TimelineClip.tsx` +- `packages/studio/src/player/hooks/useTimelinePlayer.ts` +- `packages/studio/src/utils/mediaTypes.ts` + +## Current Constraints + +This feature is intentionally **not** a full general-purpose visual builder. + +Still out of scope today: + +- rotation +- arbitrary transforms +- snapping and alignment guides +- multi-select +- marquee selection +- freeform editing of every DOM node regardless of layout model +- editing nested subcomposition internals directly from the master preview without drill-down +- automatic conversion to absolute positioning on drag without user confirmation +- direct geometry editing of inline text spans + +## Bottom Line + +Studio manual DOM editing is now a narrow, deterministic visual editing layer over authored HTML. + +It does **not** try to make the whole DOM freely editable. Instead it: + +- keeps source HTML as the source of truth +- exposes only patchable interactions +- uses a Studio-owned overlay layer for direct manipulation +- gives users a real inspector for safe style and text edits +- treats nested compositions as drill-down boundaries instead of flattening them into an unsafe editing surface + +That tradeoff is the reason the current feature feels reliable instead of deceptive. diff --git a/docs/docs.json b/docs/docs.json index 8548fd0b1..ebdea31c9 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -196,7 +196,8 @@ "pages": [ "contributing", "contributing/release-channels", - "contributing/testing-local-changes" + "contributing/testing-local-changes", + "contributing/studio-manual-dom-editing" ] } ] diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index c59058478..f20c0f7da 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -3,7 +3,11 @@ import { join, resolve, isAbsolute, sep } from "path"; import { parseHTML } from "linkedom"; import { transformSync } from "esbuild"; import { compileHtml, type MediaDurationProber } from "./htmlCompiler"; -import { rewriteAssetPaths, rewriteCssAssetUrls } from "./rewriteSubCompPaths"; +import { + rewriteAssetPaths, + rewriteCssAssetUrls, + rewriteInlineStyleAssetUrls, +} from "./rewriteSubCompPaths"; import { validateHyperframeHtmlContract } from "./staticGuard"; /** @@ -489,6 +493,17 @@ export async function bundleToSingleHtml( el.setAttribute(attr, val); }, ); + const styledEls = innerRoot + ? innerRoot.querySelectorAll("[style]") + : contentDoc.querySelectorAll("[style]"); + rewriteInlineStyleAssetUrls( + styledEls, + src, + (el: Element) => el.getAttribute("style"), + (el: Element, val: string) => { + el.setAttribute("style", val); + }, + ); if (innerRoot) { const innerCompId = innerRoot.getAttribute("data-composition-id"); diff --git a/packages/core/src/compiler/rewriteSubCompPaths.test.ts b/packages/core/src/compiler/rewriteSubCompPaths.test.ts index d2048a195..7de4f42f0 100644 --- a/packages/core/src/compiler/rewriteSubCompPaths.test.ts +++ b/packages/core/src/compiler/rewriteSubCompPaths.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { rewriteAssetPath, rewriteCssAssetUrls } from "./rewriteSubCompPaths.js"; +import { + rewriteAssetPath, + rewriteCssAssetUrls, + rewriteInlineStyleAssetUrls, +} from "./rewriteSubCompPaths.js"; describe("rewriteAssetPath", () => { it("rewrites `../` against the sub-composition dir", () => { @@ -36,4 +40,19 @@ describe("rewriteAssetPath", () => { expect(out).not.toMatch(/\\/); expect(out).not.toMatch(/:\\/); }); + + it("rewrites CSS urls inside inline style attributes", () => { + const elements = [{ style: `background-image: url("../cover.png")` }]; + + rewriteInlineStyleAssetUrls( + elements, + "compositions/scene.html", + (el) => el.style, + (el, value) => { + el.style = value; + }, + ); + + expect(elements[0]?.style).toBe(`background-image: url("cover.png")`); + }); }); diff --git a/packages/core/src/compiler/rewriteSubCompPaths.ts b/packages/core/src/compiler/rewriteSubCompPaths.ts index 23ea579e9..72bc1ba23 100644 --- a/packages/core/src/compiler/rewriteSubCompPaths.ts +++ b/packages/core/src/compiler/rewriteSubCompPaths.ts @@ -96,6 +96,28 @@ export function rewriteAssetPaths( } } +/** + * Rewrite CSS url(...) references inside inline style attributes. + */ +export function rewriteInlineStyleAssetUrls( + elements: Iterable, + compSrcPath: string, + getStyle: (el: T) => string | null | undefined, + setStyle: (el: T, value: string) => void, +): void { + const compDir = dirname(compSrcPath); + if (!compDir || compDir === ".") return; + + for (const el of elements) { + const style = getStyle(el); + if (!style) continue; + const rewritten = rewriteCssAssetUrls(style, compSrcPath); + if (rewritten !== style) { + setStyle(el, rewritten); + } + } +} + /** * Rewrite CSS url(...) references in a sub-composition's inline styles so * ../foo.woff2 remains valid after the CSS is hoisted into the root document. diff --git a/packages/core/src/studio-api/createStudioApi.ts b/packages/core/src/studio-api/createStudioApi.ts index 6159c0fdc..87daf08ee 100644 --- a/packages/core/src/studio-api/createStudioApi.ts +++ b/packages/core/src/studio-api/createStudioApi.ts @@ -6,6 +6,7 @@ import { registerPreviewRoutes } from "./routes/preview.js"; import { registerLintRoutes } from "./routes/lint.js"; import { registerRenderRoutes } from "./routes/render.js"; import { registerThumbnailRoutes } from "./routes/thumbnail.js"; +import { registerFontRoutes } from "./routes/fonts.js"; /** * Create a Hono sub-app with all studio API routes. @@ -22,6 +23,7 @@ export function createStudioApi(adapter: StudioApiAdapter): Hono { registerLintRoutes(api, adapter); registerRenderRoutes(api, adapter); registerThumbnailRoutes(api, adapter); + registerFontRoutes(api); return api; } diff --git a/packages/core/src/studio-api/helpers/subComposition.test.ts b/packages/core/src/studio-api/helpers/subComposition.test.ts index 9d4378d73..e2121b12f 100644 --- a/packages/core/src/studio-api/helpers/subComposition.test.ts +++ b/packages/core/src/studio-api/helpers/subComposition.test.ts @@ -23,6 +23,7 @@ describe("buildSubCompositionHtml", () => { "compositions/hero.html": `