UI designer · Assembly guide
Build your own designer · Beta
BetaWorking examples, component contracts and the application code that turns them into an editor.
Start here
The demos are reference applications. Start with the smaller examples below, then add the behaviour your product needs. Each preview and its copyable source come from the same Vue file.
Use the package in an existing Vue 3 application, or copy the components through the example’s Source option. The examples use Tailwind CSS v4 utilities; keep your app’s Tailwind setup scanning the files you copy. Installation guide.
npm install @getdom/studioimport '@getdom/studio/style.css';Choose the pieces
| Component | Responsibility |
|---|---|
| DomSplitterPanel | Resizes the shell panes. Bind startSize/endSize if your app wants to remember widths. |
| DomDesignerLayers | Displays your tree. Its selected ID is shared with the stage and inspector. |
| DomDesignerStage | Selects marked HTML and emits CSS patches for movement and resizing. Positioning and snapping are opt-in. |
| DomColorSwatch | Edits a CSS colour string. Bind it to the selected layer’s colour or fill. |
| DomBorderEditor | Edits a border record; borderToCss produces the CSS border value. |
| DomShadowEditor | Edits shadow records; shadowListToCss produces box-shadow. |
| DomBackgroundEditor | Edits ordered fills; backgroundImageToCss produces stacked CSS backgrounds. |
| DomBackdropEditor | Edits blur/saturation; backdropToCss produces backdrop-filter. |
1. Connect layers, stage and inspector
Keep stable IDs, content and styles in your own records. Bind one selected ID to both the tree and stage. Mark rendered elements with data-designer-id. The inspector finds that record by ID; apply each style-change { id, style } patch to it. Vue then updates the HTML. The stage does not create records, CSS classes or stylesheet rules.
A minimal HTML editor
Select from the tree or stage, edit the text, drag the outline, resize its handles, or resize the sidebar.
Use Alt plus Up or Down arrow to reorder the focused tree item.
Make something yours.
Real HTML. One shared model.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { computed, ref } from 'vue';
import { DomDesignerLayers, DomDesignerStage, DomSplitterPanel, DomTextInput, DomColorSwatch } from '@getdom/studio';
const selected = ref('heading');
const panelWidth = ref(190);
const layers = ref([
{ id: 'heading', label: 'Heading', text: 'Make something yours.', color: 'var(--canvas-fg)', style: { position: 'absolute', left: '24px', top: '32px', width: '250px', height: '100px', fontSize: '30px' } },
{ id: 'caption', label: 'Caption', text: 'Real HTML. One shared model.', color: 'var(--muted-fg)', style: { position: 'absolute', left: '24px', top: '180px', width: '250px', height: '60px', fontSize: '16px' } },
]);
const active = computed(() => layers.value.find((layer) => layer.id === selected.value));
/** Persists the stage's CSS patch in the same model rendered by Vue. */
function applyStyle({ id, style }) {
const layer = layers.value.find((item) => item.id === id);
if (layer) Object.assign(layer.style, style);
}
</script>
<template>
<div class="w-full overflow-auto">
<DomSplitterPanel v-model:start-size="panelWidth" :min-start="160" :min-main="320" class="h-[360px]">
<template #start>
<aside class="h-full space-y-4 overflow-auto p-3">
<DomDesignerLayers v-model="selected" :items="layers" />
<template v-if="active">
<DomTextInput v-model="active.text" label="Text" />
<DomColorSwatch v-model="active.color" label="Text colour" />
</template>
</aside>
</template>
<template #default>
<DomDesignerStage v-model="selected" positioning snapping class="relative h-full overflow-hidden bg-secondary" @style-change="applyStyle">
<p v-for="layer in layers" :key="layer.id" :data-designer-id="layer.id" :style="{ ...layer.style, color: layer.color }" class="m-0 whitespace-pre-wrap p-2 font-semibold">{{ layer.text }}</p>
</DomDesignerStage>
</template>
</DomSplitterPanel>
</div>
</template>
For repeated gaps, add :spacing-snapping="true" to the stage. This independent, default-off step fills axes without an alignment match while dragging. Try the equal-spacing example and copy its source →
The example scrolls horizontally on narrow screens. The poster demo instead switches between Layers, Poster and Inspect tabs. Use positioning=false for selection without dragging, interactive=true to pass clicks through to preview content, and snapping=true for stage and visible-layer alignment. Hold Control to bypass snapping. Uniform stage scaling is supported; rotated layers and independently transformed ancestors are not supported in this beta.
2. Turn control records into CSS
Controls expose v-model values; serializers turn those values into CSS. In a multi-layer editor, store these records on each layer and bind the controls to the selected layer. Give every layer its own records and arrays. The controls do not apply styles to a selected DOM element themselves.
Compose the appearance controls
Border, shadows, backgrounds and backdrop effects applied to one HTML element.
Behind the layer
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { computed, ref } from 'vue';
import { DomBorderEditor, DomShadowEditor, DomBackgroundEditor, DomBackdropEditor, createDesignerBorder, borderToCss, shadowListToCss, createBackgroundState, backgroundImageToCss, createBackdrop, backdropToCss } from '@getdom/studio';
const border = ref(createDesignerBorder());
const shadows = ref([]);
const background = ref(createBackgroundState({}, { allowEmpty: true }));
const backdrop = ref(createBackdrop());
const style = computed(() => ({
border: borderToCss(border.value),
boxShadow: shadowListToCss(shadows.value),
backgroundImage: backgroundImageToCss(background.value),
backdropFilter: backdropToCss(backdrop.value),
WebkitBackdropFilter: backdropToCss(backdrop.value),
}));
</script>
<template>
<div class="grid w-full gap-6 md:grid-cols-2">
<div class="max-h-[400px] overflow-auto">
<DomBorderEditor v-model="border" />
<DomShadowEditor v-model="shadows" />
<DomBackgroundEditor v-model="background" />
<DomBackdropEditor v-model="backdrop" />
</div>
<div class="relative grid min-h-64 place-items-center overflow-hidden rounded-xl bg-secondary p-6">
<p class="absolute inset-x-4 top-12 text-4xl font-bold text-primary">Behind the layer</p>
<div :style="style" class="relative rounded-xl bg-canvas/60 p-8 text-center font-semibold">Your HTML layer</div>
</div>
</div>
</template>
Background fill order follows CSS: the first image is on top. Backdrop effects need translucent content to be visible. Box shadows and borders do not require a special layer class. Create fill records on the client, or assign deterministic fill IDs when server rendering.
3. Assemble the poster editor
- Start with the HTML editor above. Add image, text and shape records with x, y, width and height in document pixels. The demo’s posterModel.js supplies factories, geometry conversion and style serialization.
- Put the layer tree in the splitter’s start slot, the scrollable stage in its default slot and the selected-layer controls in its end slot. Bind startSize and endSize independently.
- Use a fixed document size and scale the viewport separately. ResizeObserver recalculates “Fit” when a splitter moves. Convert style-change CSS values into numeric document coordinates before saving.
- Add history around model mutations. The demo batches continuous edits into snapshots; layer creation, duplication and deletion form separate undo actions. Viewport zoom is not a document edit.
- Export from the model, including the serialized appearance values. Keep selection outlines, guides and editor attributes out of the output. Local uploads are embedded; linked photos remain external.
<!-- Keep document coordinates separate from viewport scale. -->
<div class="overflow-auto">
<div :style="{ width: width * zoom + 'px', height: height * zoom + 'px' }">
<DomDesignerStage
v-model="selected" positioning snapping
:style="{ width: width + 'px', height: height + 'px', transform: 'scale(' + zoom + ')', transformOrigin: 'top left', position: 'relative' }"
@style-change="applyStyle"
>
<!-- Render your layers here. -->
</DomDesignerStage>
</div>
</div>Cmd/Ctrl + wheel uses a non-passive listener on the workspace, changes the zoom and compensates scroll position around the pointer. It leaves normal scrolling alone. See wheelZoom, mount and cleanup in the full source below for the complete implementation, including listener cleanup.
5. Assemble a flowchart editor
Vue Flow owns the graph viewport, node dragging and connection geometry. DOM Studio provides the surrounding splitter, inputs and actions. There is no public DomFlowchart component: this is a composition with a separate Vue Flow dependency. Install the core and whichever companion components you use.
npm install @vue-flow/core @vue-flow/background @vue-flow/controls @vue-flow/minimapA minimal graph editor
Drag from the bottom dot of one node to the top dot of the other. Move nodes, pan empty space and use the zoom buttons.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { onMounted, ref, useId } from 'vue';
import { VueFlow, addEdge } from '@vue-flow/core';
import { Background } from '@vue-flow/background';
import { DomButton } from '@getdom/studio';
import '@vue-flow/core/dist/style.css';
import '@vue-flow/core/dist/theme-default.css';
const id = useId();
const ready = ref(false);
const graph = ref(null);
const nodes = ref([
{ id: 'idea', label: 'An idea', position: { x: 0, y: 0 } },
{ id: 'draft', label: 'First draft', position: { x: 230, y: 130 } },
]);
const edges = ref([]);
/** Defers the interactive viewport until browser hydration is complete. */
function mount() { ready.value = true; }
onMounted(mount);
/** Captures the instance belonging to this particular editor. */
function init(instance) { graph.value = instance; }
/** Stores new connections in application state, suppressing duplicates. */
function connect(connection) { edges.value = addEdge(connection, edges.value); }
</script>
<template>
<div class="w-full">
<div class="mb-3 flex gap-2">
<DomButton size="sm" :disabled="!graph" @click="graph.zoomOut()">Zoom out</DomButton>
<DomButton size="sm" :disabled="!graph" @click="graph.zoomIn()">Zoom in</DomButton>
<DomButton size="sm" :disabled="!graph" @click="graph.fitView()">Fit</DomButton>
</div>
<div class="h-[320px] rounded-xl border border-border">
<VueFlow v-if="ready" :id="id" v-model:nodes="nodes" v-model:edges="edges" fit-view-on-init :min-zoom="0.1" :max-zoom="3" @init="init" @connect="connect">
<Background pattern-color="var(--border)" />
</VueFlow>
</div>
</div>
</template>
Give the graph a sized parent and an instance ID. Bind nodes and edges to application state; handle connect by adding an edge. Mount the interactive viewport after hydration. The full demo adds a node-step slot with Handle ports, an inspector, Controls and MiniMap. screenToFlowCoordinate converts a pointer or viewport centre into graph coordinates. toObject exports nodes, edges and viewport; fromObject restores a saved graph. Validate imported data in your app before restoring it.
6. Understand the source-linked Studio
Studio shares DomDesignerLayers and DomSplitterPanel, but its parser, source editor and stage renderer are still repository-local experiment modules. It is not an exported, drop-in Studio component. Use the HTML starter for a model-driven creation tool; use Studio’s source as a reference when you need actual Vue source editing.
src/pages/experiments/template-editor/lib/editorModel.jsparses Vue template source into an editor tree and builds source back from that tree.components/TemplateMonacoEditor.vueprovides the code editor;components/StageViewport.vueprovides its viewport shell. The experiment’s Index.vue coordinates preview rendering, source locations, tree selection and inspector edits.lib/componentStorageClient.jsconnects the demo to repository-specific file APIs. Replace storage and component discovery for your host application; they are not part of the designer component API.
// Repository-local example, beside the template-editor Index.vue.
import { parseSource, buildSource } from './lib/editorModel.js';
const parsed = parseSource('<template><h2>Hello</h2></template>');
// Keep parsed.tree as the editable model. Its nodes have editor IDs.
const { rows, lineMap } = buildSource(parsed.tree, {
props: parsed.props,
scriptData: parsed.scriptData,
});
const source = rows.map((row) => row.text).join('\n');
// row.id links a source row to a tree node; lineMap[id] finds its line.
// Rebuild after visual edits. Reparse after a committed source edit.What your application owns
Own the document schema, IDs, history, persistence, asset uploads, import validation and export. The poster, social-card and flowchart demos keep edits in memory until exported; they do not provide a storage service or run workflows. Studio uses the repository-specific storage adapter described above. For production, save the model rather than the live editor DOM, restore stable IDs, and keep selection and viewport preferences separate from document changes. The beta APIs may change.
Full demo source
These are the actual repository files, including local imports and route links. Use the starter examples above for a standalone component; use these files to inspect the complete application wiring. Poster needs both files plus the shared designer components and serializers.
Poster editor
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import { DomSplitterPanel, DomButton, DomTextInput, DomTextareaInput, DomNumberInput, DomNativeSelect, DomRangeInput, DomDesignerLayers, DomDesignerStage, DomColorSwatch, DomShadowEditor, DomBorderEditor, DomBackgroundEditor, DomBackdropEditor } from '../../lib/vue';
import { applyPosterGeometry, createPoster, createPosterLayer, exportPosterHtml, fontOptions, posterHtmlLines, posterLayerStyle, safePosterImage } from './posterModel.js';
const poster = ref(createPoster());
const selected = ref('headline');
const workspace = ref(null);
const upload = ref(null);
const zoomChoice = ref('fit');
const snapping = ref(true);
const spacingSnapping = ref(false);
const leftPanelWidth = ref(220);
const rightPanelWidth = ref(300);
const artwork = ref(null);
const fitScale = ref(0.3);
const mobilePanel = ref('poster');
const showCode = ref(false);
const notice = ref('');
const imageErrors = ref({});
const imageUrlDraft = ref('');
const past = ref([]);
const future = ref([]);
let lastSnapshot = JSON.stringify(poster.value);
let historyTimer;
let observer;
let restoring = false;
let nextId = 1;
let disposed = false;
const active = computed(() => poster.value.layers.find((layer) => layer.id === selected.value));
const scale = computed(() => zoomChoice.value === 'fit' ? fitScale.value : Number(zoomChoice.value));
const layers = computed(() => poster.value.layers.toReversed().map((layer) => ({ id: layer.id, label: `${layer.name}${layer.locked ? ' · locked' : ''}${layer.hidden ? ' · hidden' : ''}` })));
const source = computed(() => posterHtmlLines(poster.value));
const activeIndex = computed(() => poster.value.layers.findIndex((layer) => layer.id === selected.value));
const geometryFields = [{ key: 'x', label: 'X' }, { key: 'y', label: 'Y' }, { key: 'width', label: 'Width', min: 16 }, { key: 'height', label: 'Height', min: 16 }];
const presets = [{ label: 'Story · 1080 × 1920', value: '1080x1920' }, { label: 'Portrait · 1080 × 1350', value: '1080x1350' }, { label: 'Square · 1080 × 1080', value: '1080x1080' }];
/** Commits one undo point after a burst of typing or pointer movement. */
function commitHistory() {
clearTimeout(historyTimer);
const next = JSON.stringify(poster.value);
if (next === lastSnapshot) return;
past.value = [...past.value.slice(-49), lastSnapshot];
lastSnapshot = next;
future.value = [];
}
/** Groups continuous property edits into an undoable operation. */
function queueHistory() {
if (restoring) return;
clearTimeout(historyTimer);
historyTimer = setTimeout(commitHistory, 350);
}
/** Restores a trusted in-memory history entry without recording another edit. */
function restore(snapshot) {
restoring = true;
poster.value = JSON.parse(snapshot);
lastSnapshot = snapshot;
restoring = false;
if (!poster.value.layers.some((layer) => layer.id === selected.value)) selected.value = 'frame';
}
/** Reverts the latest edit, including changes waiting for the history debounce. */
function undo() {
commitHistory();
if (!past.value.length) return;
future.value.push(lastSnapshot);
restore(past.value.pop());
}
/** Reapplies the latest reverted edit. */
function redo() {
if (!future.value.length) return;
past.value.push(lastSnapshot);
restore(future.value.pop());
}
/** Starts an independent undoable action rather than merging it with previous typing. */
function act(callback) {
commitHistory();
callback();
commitHistory();
}
/** Adds a new editable text, image or shape layer above existing artwork. */
function addLayer(type) {
act(() => {
const layer = createPosterLayer(type, `layer-${nextId++}`, { width: Math.min(920, poster.value.width - 80), y: Math.round(poster.value.height / 3) });
poster.value.layers.push(layer);
selected.value = layer.id;
});
}
/** Duplicates the current layer with a unique identity and an offset position. */
function duplicate() {
if (!active.value) return;
act(() => {
const layer = JSON.parse(JSON.stringify(active.value));
Object.assign(layer, { id: `layer-${nextId++}`, name: `${layer.name} copy`, locked: false, x: layer.x + 30, y: layer.y + 30 });
poster.value.layers.splice(activeIndex.value + 1, 0, layer);
selected.value = layer.id;
});
}
/** Deletes only an unlocked selected layer; undo can recover it. */
function remove() {
if (!active.value || active.value.locked) return;
act(() => { poster.value.layers.splice(activeIndex.value, 1); selected.value = 'frame'; });
}
/** Changes the selected layer's stacking order while preserving stable selection. */
function reorder(direction) {
if (!active.value || active.value.locked) return;
const target = activeIndex.value + direction;
if (target < 0 || target >= poster.value.layers.length) return;
act(() => {
const [layer] = poster.value.layers.splice(activeIndex.value, 1);
poster.value.layers.splice(target, 0, layer);
});
}
/** Applies the shared stage's CSS geometry to the poster model. */
function patch(event) {
applyPosterGeometry(poster.value.layers.find((layer) => layer.id === event.id), event.style);
}
/** Updates a finite numeric inspector value without allowing invalid dimensions. */
function number(key, value, min = -10000, max = 10000) {
if (!active.value || active.value.locked || value === '' || !Number.isFinite(Number(value))) return;
active.value[key] = Math.min(max, Math.max(min, Number(value)));
}
/** Resizes the frame and proportionally scales layer geometry to retain the composition. */
function setPreset(value) {
const [width, height] = value.split('x').map(Number);
if (!presets.some((preset) => preset.value === value)) return;
act(() => {
const sx = width / poster.value.width;
const sy = height / poster.value.height;
for (const layer of poster.value.layers) {
layer.x = Math.round(layer.x * sx); layer.y = Math.round(layer.y * sy);
layer.width = Math.round(layer.width * sx); layer.height = Math.round(layer.height * sy);
layer.fontSize = Math.round(layer.fontSize * Math.min(sx, sy));
}
poster.value.width = width; poster.value.height = height;
});
nextTick(fit);
}
/** Fits the full-resolution HTML into the available workspace without changing its model. */
function fit() {
if (!workspace.value) return;
fitScale.value = Math.max(0.05, Math.min(1, (workspace.value.clientWidth - 72) / poster.value.width, (workspace.value.clientHeight - 80) / poster.value.height));
}
/** Commits a supported image URL and clears any previous loading error. */
function setImage(value) {
if (!active.value || active.value.locked) return;
const url = safePosterImage(value);
if (!url) { notice.value = 'Use an http or https image URL, or upload a PNG, JPEG, WebP or GIF.'; return; }
active.value.value = url;
delete imageErrors.value[active.value.id];
}
/** Reads a user-selected raster image locally; no upload leaves the browser. */
function uploadImage(event) {
const file = event.target.files?.[0];
const targetId = active.value?.type === 'image' && !active.value.locked ? active.value.id : null;
if (!file) return;
if (!['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(file.type) || file.size > 8 * 1024 * 1024) {
notice.value = 'Choose a PNG, JPEG, WebP or GIF under 8 MB.'; event.target.value = ''; return;
}
const reader = new FileReader();
reader.onload = () => {
if (disposed) return;
act(() => {
let target = poster.value.layers.find((layer) => layer.id === targetId && !layer.locked);
if (!target) { target = createPosterLayer('image', `layer-${nextId++}`); poster.value.layers.push(target); }
target.value = String(reader.result); target.name = file.name; selected.value = target.id;
});
notice.value = 'Image added locally. Export HTML to keep your work.';
};
reader.onerror = () => { if (!disposed) notice.value = 'That image could not be read. Try another file.'; };
reader.readAsDataURL(file);
event.target.value = '';
}
/** Downloads a standalone HTML poster containing local images and the current CSS. */
function download() {
const url = URL.createObjectURL(new Blob([exportPosterHtml(poster.value)], { type: 'text/html' }));
const link = document.createElement('a');
link.href = url; link.download = 'dom-studio-poster.html'; link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
notice.value = 'HTML exported. Linked photos still need an internet connection.';
}
/** Copies the exact export document, reporting clipboard failures without losing edits. */
async function copy() {
try { await navigator.clipboard.writeText(exportPosterHtml(poster.value)); notice.value = 'HTML copied.'; }
catch { notice.value = 'Clipboard unavailable. Use Export HTML instead.'; }
}
/** Zooms around the pointer; ordinary wheel events retain native workspace scrolling. */
async function wheelZoom(event) {
if (!event.metaKey && !event.ctrlKey) return;
event.preventDefault();
const area = workspace.value;
const sheet = artwork.value;
if (!area || !sheet) return;
const before = sheet.getBoundingClientRect();
const oldScale = scale.value;
const x = (event.clientX - before.left) / oldScale;
const y = (event.clientY - before.top) / oldScale;
const delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? area.clientHeight : 1);
const next = Math.max(0.05, Math.min(3, oldScale * Math.exp(-delta * 0.002)));
zoomChoice.value = String(next);
await nextTick();
if (disposed || workspace.value !== area) return;
const after = sheet.getBoundingClientRect();
area.scrollLeft += after.left + x * next - event.clientX;
area.scrollTop += after.top + y * next - event.clientY;
}
/** Installs workspace measurement after hydration. */
function mount() {
observer = new ResizeObserver(fit);
observer.observe(workspace.value);
workspace.value.addEventListener('wheel', wheelZoom, { passive: false });
fit();
}
/** Releases measurement and history work when leaving the example. */
function cleanup() {
disposed = true;
workspace.value?.removeEventListener('wheel', wheelZoom);
observer?.disconnect();
clearTimeout(historyTimer);
}
/** Keeps the URL draft aligned with the selected image without editing the poster while typing. */
function syncImageDraft() {
imageUrlDraft.value = active.value?.type === 'image' && !active.value.value.startsWith('data:') ? active.value.value : '';
}
watch(() => [selected.value, active.value?.value], syncImageDraft, { immediate: true });
watch(poster, queueHistory, { deep: true, flush: 'sync' });
onMounted(mount);
onBeforeUnmount(cleanup);
</script>
<template>
<main class="flex h-dvh min-h-0 flex-col bg-canvas text-canvas-fg">
<header class="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3">
<div class="flex flex-wrap items-center gap-3"><RouterLink to="/designer" class="text-sm text-muted-fg hover:text-canvas-fg">← UI designer</RouterLink><RouterLink to="/designer/assembly#poster" class="text-sm text-primary underline underline-offset-4">Assembly guide</RouterLink><span class="h-4 border-l border-border"></span><h1 class="text-sm font-semibold">Poster studio</h1><span class="rounded bg-secondary px-2 py-1 text-[10px] font-semibold uppercase tracking-wide">Beta</span></div>
<div class="flex items-center gap-2"><DomButton size="sm" variant="ghost" :disabled="!past.length && JSON.stringify(poster) === lastSnapshot" @click="undo">Undo</DomButton><DomButton size="sm" variant="ghost" :disabled="!future.length" @click="redo">Redo</DomButton><DomButton size="sm" variant="ghost" :aria-pressed="showCode" @click="showCode = !showCode">HTML</DomButton><DomButton size="sm" @click="download">Export HTML</DomButton></div>
</header>
<nav class="flex shrink-0 border-b border-border p-1 lg:hidden" aria-label="Editor panels"><button v-for="panel in ['layers', 'poster', 'inspect']" :key="panel" type="button" class="flex-1 rounded px-3 py-2 text-sm capitalize" :class="mobilePanel === panel ? 'bg-secondary font-semibold' : 'text-muted-fg'" :aria-pressed="mobilePanel === panel" @click="mobilePanel = panel">{{ panel }}</button></nav>
<DomSplitterPanel v-model:start-size="leftPanelWidth" v-model:end-size="rightPanelWidth" :min-start="180" :min-main="280" :min-end="240" class="poster-panels min-h-0 flex-1" :data-panel="mobilePanel">
<template #start>
<aside class="h-full min-h-0 overflow-auto border-r border-border" :class="mobilePanel === 'layers' ? 'block' : 'hidden lg:block'" aria-label="Poster layers">
<div class="border-b border-border p-4"><p class="mb-3 text-[10px] font-semibold uppercase tracking-widest text-muted-fg">Document</p><DomTextInput v-model="poster.title" label="Title" /><div class="mt-3"><DomNativeSelect :model-value="`${poster.width}x${poster.height}`" :options="presets" label="Format" @update:model-value="setPreset" /></div></div>
<div class="p-4"><h2 class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-fg">Add a layer</h2><div class="grid grid-cols-3 gap-1"><DomButton size="sm" variant="secondary" @click="addLayer('text')">Text</DomButton><DomButton size="sm" variant="secondary" @click="addLayer('image')">Image</DomButton><DomButton size="sm" variant="secondary" @click="addLayer('shape')">Shape</DomButton></div></div>
<div class="px-2"><div class="flex items-center justify-between px-2 pb-2"><h2 class="text-xs font-semibold">Layers</h2><span class="text-[10px] text-muted-fg">Front to back</span></div><DomDesignerLayers v-model="selected" :items="layers" label="Poster layers" /></div>
<div class="m-4 space-y-3 border-t border-border pt-4"><button type="button" class="flex w-full items-center justify-between rounded-lg border border-border px-3 py-2 text-sm" :class="selected === 'frame' && 'bg-secondary'" @click="selected = 'frame'"><span>Poster background</span><span class="size-4 rounded border border-border" :style="{ background: poster.background }"></span></button><p class="text-xs leading-5 text-muted-fg">Real HTML at {{ poster.width }} × {{ poster.height }}. Select a layer here or on the poster.</p><p class="text-xs leading-5 text-muted-fg">Edits stay in this tab. Export HTML before leaving.</p></div>
</aside>
</template>
<template #default>
<section class="flex h-full min-h-0 min-w-0 flex-col" :class="mobilePanel === 'poster' ? 'flex' : 'hidden lg:flex'" aria-label="Poster workspace">
<div class="flex h-10 shrink-0 items-center justify-between border-b border-border px-4 text-[11px] text-muted-fg"><span class="truncate">{{ poster.title }}</span><span class="shrink-0">{{ poster.width }} × {{ poster.height }}</span></div>
<div ref="workspace" class="poster-workspace min-h-0 flex-1 overflow-auto p-9">
<div ref="artwork" class="relative mx-auto shadow-xl" :style="{ width: `${poster.width * scale}px`, height: `${poster.height * scale}px` }">
<DomDesignerStage v-model="selected" :snapping="snapping" :spacing-snapping="spacingSnapping" :positioning="Boolean(active && !active.locked && !active.hidden)" class="absolute left-0 top-0 origin-top-left [--primary:var(--chart-1)]" :style="{ width: `${poster.width}px`, height: `${poster.height}px`, transform: `scale(${scale})` }" @style-change="patch">
<article aria-label="Poster artwork" :style="{ position: 'relative', width: `${poster.width}px`, height: `${poster.height}px`, overflow: 'hidden', background: poster.background }">
<template v-for="(layer, index) in poster.layers" :key="layer.id">
<img v-if="layer.type === 'image'" :src="safePosterImage(layer.value)" :alt="layer.name" :data-designer-id="layer.hidden ? undefined : layer.id" :style="posterLayerStyle(layer, index)" draggable="false" @error="imageErrors[layer.id] = true" @load="delete imageErrors[layer.id]" />
<component :is="layer.type === 'text' ? 'p' : 'div'" v-else :data-designer-id="layer.hidden ? undefined : layer.id" :style="posterLayerStyle(layer, index)">{{ layer.type === 'text' ? layer.value : '' }}</component>
</template>
</article>
</DomDesignerStage>
</div>
</div>
<div class="flex shrink-0 items-center justify-between gap-3 flex-wrap border-t border-border bg-canvas px-4 py-2"><span class="truncate text-xs text-muted-fg">{{ active?.name || 'Poster' }}{{ active?.locked ? ' · locked' : '' }}</span><label class="flex shrink-0 items-center gap-1 text-xs" title="Snap to stage and visible layers. Hold Control while moving or resizing to bypass."><input v-model="snapping" type="checkbox" class="accent-primary" />Snap</label><label class="flex shrink-0 items-center gap-1 text-xs" title="Repeat horizontal and vertical gaps while dragging. Independent of alignment snapping; hold Control to bypass."><input v-model="spacingSnapping" type="checkbox" class="accent-primary" />Spacing</label><label class="flex items-center gap-2 text-xs"><span>Zoom</span><select v-model="zoomChoice" aria-label="Stage zoom" class="rounded border border-border bg-canvas px-2 py-1"><option value="fit">Fit · {{ Math.round(fitScale * 100) }}%</option><option v-if="zoomChoice !== 'fit' && !['0.25', '0.5', '0.75', '1'].includes(zoomChoice)" :value="zoomChoice">{{ Math.round(scale * 100) }}%</option><option value="0.25">25%</option><option value="0.5">50%</option><option value="0.75">75%</option><option value="1">100%</option></select></label></div>
<div v-if="showCode" class="max-h-52 shrink-0 overflow-auto border-t border-border bg-canvas p-3"><div class="mb-2 flex items-center justify-between"><h2 class="text-xs font-semibold">Linked HTML</h2><DomButton size="sm" variant="ghost" @click="copy">Copy HTML</DomButton></div><button v-for="(line, index) in source" :key="index" class="block max-w-full truncate text-left font-mono text-[11px] leading-6" :class="selected === line.id && 'bg-primary/10 text-primary'" :title="line.text" @click="selected = line.id">{{ line.text }}</button></div>
</section>
</template>
<template #end>
<aside class="h-full min-h-0 overflow-auto border-l border-border" :class="mobilePanel === 'inspect' ? 'block' : 'hidden lg:block'" aria-label="Poster properties">
<div class="flex items-center justify-between border-b border-border px-4 py-3"><h2 class="text-sm font-semibold">{{ active?.name || 'Poster background' }}</h2><span class="text-[10px] uppercase tracking-wider text-muted-fg">{{ active?.type || 'Frame' }}</span></div>
<template v-if="active">
<div class="flex flex-wrap gap-1 border-b border-border p-3"><DomButton size="sm" variant="ghost" @click="act(() => active.locked = !active.locked)">{{ active.locked ? 'Unlock' : 'Lock' }}</DomButton><DomButton size="sm" variant="ghost" :disabled="active.locked" @click="act(() => active.hidden = !active.hidden)">{{ active.hidden ? 'Show' : 'Hide' }}</DomButton><DomButton size="sm" variant="ghost" @click="duplicate">Duplicate</DomButton><DomButton size="sm" variant="ghost" :disabled="active.locked" @click="remove">Delete</DomButton></div>
<p v-if="active.locked" class="bg-secondary px-4 py-3 text-xs leading-5 text-muted-fg">This layer is locked. Unlock it to change its properties or position.</p>
<fieldset :disabled="active.locked" class="min-w-0 space-y-5 p-4 disabled:opacity-50">
<DomTextInput v-model="active.name" label="Layer name" />
<div><h3 class="mb-3 text-[10px] font-semibold uppercase tracking-widest text-muted-fg">Position & size</h3><div class="grid grid-cols-2 gap-3"><DomNumberInput v-for="field in geometryFields" :key="field.key" :label="field.label" :model-value="active[field.key]" :min="field.min" @update:model-value="number(field.key, $event, field.min)" /></div><div class="mt-3 flex gap-2"><DomButton size="sm" variant="secondary" :disabled="activeIndex === 0 || active.locked" @click="reorder(-1)">Send back</DomButton><DomButton size="sm" variant="secondary" :disabled="activeIndex === poster.layers.length - 1 || active.locked" @click="reorder(1)">Bring forward</DomButton></div></div>
<template v-if="active.type === 'text'">
<DomTextareaInput v-model="active.value" label="Text" :rows="3" />
<DomNativeSelect v-model="active.fontFamily" label="Font" :options="fontOptions" />
<div class="grid grid-cols-2 gap-3"><DomNumberInput label="Font size" :model-value="active.fontSize" :min="8" @update:model-value="number('fontSize', $event, 8, 500)" /><DomNativeSelect v-model="active.fontWeight" label="Weight" :options="[{ label: 'Regular', value: '400' }, { label: 'Bold', value: '700' }, { label: 'Black', value: '900' }]" /><DomNumberInput label="Line height" :model-value="active.lineHeight" :step="0.05" :min="0.5" @update:model-value="number('lineHeight', $event, 0.5, 3)" /><DomNumberInput label="Letter spacing" :model-value="active.letterSpacing" :step="0.5" @update:model-value="number('letterSpacing', $event, -20, 100)" /></div>
<DomColorSwatch v-model="active.color" label="Text colour" />
<div class="grid grid-cols-2 gap-3"><DomNativeSelect v-model="active.textAlign" label="Align" :options="['left', 'center', 'right']" /><DomNativeSelect v-model="active.textTransform" label="Case" :options="['none', 'uppercase', 'lowercase', 'capitalize']" /></div>
</template>
<template v-if="active.type === 'image'">
<DomTextInput v-model="imageUrlDraft" label="Image URL" placeholder="https://…" />
<DomButton size="sm" variant="secondary" :disabled="active.locked || !imageUrlDraft" @click="setImage(imageUrlDraft)">Apply image URL</DomButton>
<DomButton size="sm" variant="secondary" :disabled="active.locked" @click="upload.click()">Replace with local image</DomButton>
<p v-if="imageErrors[active.id]" role="status" class="text-xs text-destructive">The photo could not load. Try another URL or a local image.</p>
<DomNativeSelect v-model="active.objectFit" label="Image fit" :options="['cover', 'contain', 'fill']" />
<DomRangeInput v-model="active.objectY" label="Vertical crop" :min="0" :max="100" suffix="%" />
<DomRangeInput v-model="active.brightness" label="Brightness" :min="0" :max="200" suffix="%" />
<DomRangeInput v-model="active.saturation" label="Saturation" :min="0" :max="200" suffix="%" />
<DomRangeInput v-model="active.blur" label="Blur" :min="0" :max="40" suffix="px" />
<DomRangeInput v-model="active.sepia" label="Sepia" :min="0" :max="100" suffix="%" />
</template>
<DomColorSwatch v-if="active.type !== 'image'" v-model="active.fill" label="Fill" />
<div class="grid grid-cols-2 gap-3"><DomNumberInput :model-value="active.opacity" label="Opacity %" :min="0" :max="100" @update:model-value="number('opacity', $event, 0, 100)" /><DomNumberInput :model-value="active.radius" label="Radius" :min="0" @update:model-value="number('radius', $event, 0, 1000)" /></div>
<DomBackgroundEditor v-if="active.type !== 'image'" v-model="active.background" />
<DomBackdropEditor v-if="active.type !== 'image'" v-model="active.backdrop" />
<DomBorderEditor v-model="active.border" />
<DomShadowEditor v-model="active.shadows" />
</fieldset>
</template>
<div v-else class="space-y-4 p-4"><DomColorSwatch v-model="poster.background" label="Background colour" /><p class="text-xs leading-5 text-muted-fg">Cmd/Ctrl + mouse wheel zooms the stage. Choose a layer to edit its content. Drag its outline to move it, or drag a corner or edge handle to resize. Arrow keys move by 1px; Shift moves by 10px; Alt resizes.</p></div>
</aside>
</template>
</DomSplitterPanel>
<footer class="flex min-h-8 shrink-0 items-center justify-between gap-3 border-t border-border px-4 py-2 text-[10px] text-muted-fg"><span role="status">{{ notice || 'HTML elements · no canvas · export to keep your work' }}</span><a class="shrink-0 underline underline-offset-2" href="https://unsplash.com/photos/H3yr77Q_mwg" target="_blank" rel="noopener noreferrer">Photo: Zhi Xuan Hew / Unsplash</a></footer>
<input ref="upload" type="file" accept="image/png,image/jpeg,image/webp,image/gif" class="hidden" aria-label="Upload poster image" @change="uploadImage" />
</main>
</template>
<style scoped>
.poster-workspace {
background-color: var(--secondary);
background-image: radial-gradient(var(--border) 1px, transparent 1px);
background-size: 16px 16px;
}
/* Slot wrappers belong to the splitter; on mobile expose only the selected panel. */
@media (max-width: 1023px) {
.poster-panels { display: block; min-width: 0 !important; }
.poster-panels :deep(> button), .poster-panels :deep(> section) { display: none; }
.poster-panels[data-panel="layers"] :deep(> section:nth-child(1)),
.poster-panels[data-panel="poster"] :deep(> section:nth-child(3)),
.poster-panels[data-panel="inspect"] :deep(> section:nth-child(5)) { display: block; height: 100%; }
}
</style>
Poster model and HTML export
import { createBackgroundState, backgroundImageToCss } from '../background-editor/backgroundModel.js';
import { createBackdrop, backdropToCss } from '../backdrop-editor/backdropModel.js';
import { createDesignerBorder, borderToCss } from '../border-editor/borderModel.js';
import { shadowListToCss } from '../shadow-editor/shadowModel.js';
export const posterImage = 'https://images.unsplash.com/photo-1609674750700-33895b9b7ce1?auto=format&fit=crop&w=1080&q=85';
export const fontOptions = [
{ label: 'Condensed', value: 'Impact, "Arial Narrow", sans-serif' },
{ label: 'Sans serif', value: 'Arial, Helvetica, sans-serif' },
{ label: 'Serif', value: 'Georgia, "Times New Roman", serif' },
{ label: 'Monospace', value: 'monospace' },
];
/** Creates a complete HTML layer with deterministic, caller-supplied identity. */
export function createPosterLayer(type, id, overrides = {}) {
return {
id, type, name: type === 'text' ? 'Text' : type === 'image' ? 'Image' : 'Shape',
value: type === 'text' ? 'Your next adventure' : type === 'image' ? posterImage : '',
x: 80, y: 480, width: 920, height: type === 'text' ? 260 : 600,
background: createBackgroundState({}, { allowEmpty: true }), backdrop: createBackdrop(), border: createDesignerBorder({ enabled: false }), locked: false, hidden: false, opacity: 100, radius: 0, shadows: [],
color: '#171717', fill: type === 'shape' ? '#f4d35e' : 'transparent',
fontFamily: fontOptions[0].value, fontSize: 100, fontWeight: '400', lineHeight: 1.1, letterSpacing: -2,
textAlign: 'center', textTransform: 'uppercase',
objectFit: 'cover', objectY: 50, blur: 0, saturation: 100, brightness: 100, sepia: 0,
...overrides,
};
}
/** Returns the initial portrait poster; the photo is locked so text remains easy to select. */
export function createPoster() {
return {
version: 1, title: 'A weekend in the Highlands', width: 1080, height: 1920, background: '#ffffff',
layers: [
createPosterLayer('image', 'photo', { name: 'Glencoe, Scottish Highlands', x: 0, y: 0, width: 1080, height: 1920, locked: true }),
createPosterLayer('text', 'headline', { name: 'Headline', value: 'A WEEKEND IN\nTHE HIGHLANDS', x: 40, y: 90, width: 1000, height: 350, fontSize: 120 }),
],
};
}
/** Projects a layer into ordinary CSS, shared by the visible HTML and exported document. */
export function posterLayerStyle(layer, index) {
return {
position: 'absolute', boxSizing: 'border-box', margin: '0', left: `${layer.x}px`, top: `${layer.y}px`,
width: `${layer.width}px`, height: `${layer.height}px`, zIndex: index,
opacity: layer.opacity / 100, borderRadius: `${layer.radius}px`, boxShadow: shadowListToCss(layer.shadows), border: borderToCss(layer.border),
...(layer.hidden ? { display: 'none' } : {}),
...(layer.type === 'image' ? {
objectFit: layer.objectFit, objectPosition: `50% ${layer.objectY}%`,
filter: `blur(${layer.blur}px) saturate(${layer.saturation}%) brightness(${layer.brightness}%) sepia(${layer.sepia}%)`,
} : { backgroundColor: layer.fill, backgroundImage: backgroundImageToCss(layer.background), backdropFilter: backdropToCss(layer.backdrop), WebkitBackdropFilter: backdropToCss(layer.backdrop) }),
...(layer.type === 'text' ? {
color: layer.color, fontFamily: layer.fontFamily, fontSize: `${layer.fontSize}px`, fontWeight: layer.fontWeight,
lineHeight: layer.lineHeight, letterSpacing: `${layer.letterSpacing}px`, textAlign: layer.textAlign,
textTransform: layer.textTransform, whiteSpace: 'pre-wrap', overflowWrap: 'break-word',
} : {}),
};
}
/** Escapes text and quoted attributes so user content cannot become executable markup. */
export function escapeHtml(value) {
return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
}
/** Accepts browser image URLs and raster data URLs, rejecting executable or vector payloads. */
export function safePosterImage(value) {
if (/^data:image\/(png|jpeg|webp|gif);base64,[a-z0-9+/=\s]+$/i.test(value)) return value;
try {
const url = new URL(value);
return ['https:', 'http:'].includes(url.protocol) ? url.href : '';
} catch { return ''; }
}
/** Serializes camel-case styles as escaped HTML attributes. */
function styleAttribute(style) {
return escapeHtml(Object.entries(style).map(([key, value]) => `${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}:${value}`).join(';'));
}
/** Returns selectable source lines without any editor overlays, selection IDs or hidden layers. */
export function posterHtmlLines(poster) {
return [
{ id: 'frame', text: `<article aria-label="${escapeHtml(poster.title)}" style="position:relative;width:${poster.width}px;height:${poster.height}px;overflow:hidden;background:${escapeHtml(poster.background)}">` },
...poster.layers.flatMap((layer, index) => {
if (layer.hidden) return [];
const style = styleAttribute(posterLayerStyle(layer, index));
const text = layer.type === 'image'
? `<img src="${escapeHtml(safePosterImage(layer.value))}" alt="${escapeHtml(layer.name)}" style="${style}">`
: `<${layer.type === 'text' ? 'p' : 'div'} style="${style}">${layer.type === 'text' ? escapeHtml(layer.value) : ''}</${layer.type === 'text' ? 'p' : 'div'}>`;
return [{ id: layer.id, text: `\t${text}` }];
}),
{ id: 'frame', text: '</article>' },
];
}
/** Generates a standalone HTML document with the original poster dimensions. */
export function exportPosterHtml(poster) {
return `<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(poster.title)}</title><style>body{margin:0}*{box-sizing:border-box}@media print{@page{margin:0}body{print-color-adjust:exact}}</style></head>\n<body>\n${posterHtmlLines(poster).map((line) => line.text).join('\n')}\n</body>\n</html>`;
}
/** Applies finite CSS geometry emitted by the shared stage; locked layers cannot be moved. */
export function applyPosterGeometry(layer, style) {
if (!layer || layer.locked) return;
for (const [css, key] of [['left', 'x'], ['top', 'y'], ['width', 'width'], ['height', 'height']]) {
const value = Number.parseFloat(style[css]);
if (Number.isFinite(value)) layer[key] = ['width', 'height'].includes(key) ? Math.max(16, value) : value;
}
}
Social card editor
<script setup>
import { computed, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { DomButton, DomTextareaInput, DomNumberInput, DomDesignerLayers, DomDesignerStage, DomShadowEditor, DomColorSwatch } from '../../lib/vue';
import { shadowListToCss } from '../shadow-editor/shadowModel.js';
const selected = ref('headline');
const positioning = ref(true);
const notice = ref('');
const layers = ref([
{ id: 'brand', label: 'Brand', tag: 'p', text: 'DOM STUDIO / FIELD NOTES', style: { position: 'absolute', left: '40px', top: '36px', width: '280px', fontSize: '12px', letterSpacing: '0.14em' }, shadows: [] },
{ id: 'headline', label: 'Headline', tag: 'h2', text: 'Small ideas.\nWorth sharing.', style: { position: 'absolute', left: '40px', top: '100px', width: '480px', fontSize: '52px', fontWeight: '750', lineHeight: '1.05' }, shadows: [] },
{ id: 'caption', label: 'Caption', tag: 'p', text: 'Made with real HTML. Ready for your next idea.', style: { position: 'absolute', left: '40px', top: '270px', width: '480px', fontSize: '16px' }, shadows: [] },
]);
const active = computed(() => layers.value.find((layer) => layer.id === selected.value));
const tree = computed(() => [{ id: 'card', label: 'Social card · 600 × 360', open: true, children: layers.value.map(({ id, label }) => ({ id, label })) }]);
const background = ref('var(--secondary)');
const stageStyle = computed(() => ({ width: '600px', height: '360px', position: 'relative', overflow: 'hidden', background: background.value, color: 'var(--canvas-fg)', fontFamily: 'sans-serif' }));
/** Applies a stage gesture to the same model used by the HTML and property editor. */
function patch({ id, style }) {
const layer = layers.value.find((item) => item.id === id);
if (layer) Object.assign(layer.style, style);
}
/** Escapes model text and attribute values before generating copyable HTML. */
function escape(value) {
return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
}
/** Serializes camel-case Vue style objects as ordinary CSS declarations. */
function css(style) {
return Object.entries(style).map(([key, value]) => `${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}: ${value}`).join('; ');
}
/** Returns the rendered layer style, including independently editable shadow layers. */
function layerStyle(layer) {
return { margin: '0px', whiteSpace: 'pre-wrap', ...layer.style, boxShadow: shadowListToCss(layer.shadows) };
}
const sourceLines = computed(() => [
{ id: 'card', text: `<article style="${escape(css(stageStyle.value))}">` },
...layers.value.map((layer) => ({ id: layer.id, text: `\t<${layer.tag} style="${escape(css(layerStyle(layer)))}">${escape(layer.text)}</${layer.tag}>` })),
{ id: 'card', text: '</article>' },
]);
/** Copies HTML with resolved theme variables so it can be used outside the demo. */
async function copyHtml() {
try {
const theme = getComputedStyle(document.documentElement);
const html = sourceLines.value.map((line) => line.text).join('\n').replace(/var\((--[\w-]+)\)/g, (match, key) => theme.getPropertyValue(key).trim() || match);
await navigator.clipboard.writeText(html);
notice.value = 'HTML copied';
} catch { notice.value = 'Clipboard unavailable. Select and copy the HTML below.'; }
}
</script>
<template>
<main class="min-h-dvh bg-canvas text-canvas-fg">
<header class="flex flex-wrap items-center justify-between gap-3 border-b border-border px-5 py-4">
<div class="flex flex-wrap items-center gap-3"><RouterLink to="/designer" class="text-sm text-muted-fg">← UI designer</RouterLink><RouterLink to="/designer/assembly#social-card" class="text-sm text-primary underline underline-offset-4">Assembly guide</RouterLink><h1 class="font-semibold">Social card</h1><span class="rounded bg-primary/10 px-2 py-1 text-xs text-primary">Beta</span></div>
<div class="flex items-center gap-3"><label class="flex items-center gap-2 text-sm"><input v-model="positioning" type="checkbox" />Move & resize</label><DomButton size="sm" @click="copyHtml">Copy HTML</DomButton></div>
</header>
<p class="border-b border-border px-5 py-2 text-xs text-muted-fg">Select a layer, the HTML, or an element on the stage. Drag the selection box; use its corner to resize. Changes stay in this page until copied. <span role="status">{{ notice }}</span></p>
<div class="grid lg:min-h-[620px] lg:grid-cols-[210px_minmax(0,1fr)_290px]">
<aside class="border-b border-border p-4 lg:border-r"><h2 class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-fg">Layers</h2><DomDesignerLayers v-model="selected" :items="tree" /></aside>
<section class="min-w-0 overflow-auto bg-secondary/40 p-6 sm:p-10" aria-label="Design workspace">
<div class="mb-3 flex justify-between text-xs text-muted-fg"><span>HTML stage</span><span>600 × 360</span></div>
<DomDesignerStage v-model="selected" :positioning="positioning && selected !== 'card'" class="w-[600px] shadow-lg" @style-change="patch">
<article data-designer-id="card" :style="stageStyle">
<component :is="layer.tag" v-for="layer in layers" :key="layer.id" :data-designer-id="layer.id" :style="layerStyle(layer)">{{ layer.text }}</component>
</article>
</DomDesignerStage>
</section>
<aside class="space-y-5 border-t border-border p-4 lg:border-l lg:border-t-0" aria-label="Layer properties">
<h2 class="font-semibold">{{ active?.label || 'Social card' }}</h2>
<template v-if="active">
<DomTextareaInput v-model="active.text" label="Text" :rows="3" />
<DomColorSwatch :model-value="active.style.color || 'var(--canvas-fg)'" label="Text colour" @update:model-value="active.style.color = $event" />
<div class="grid grid-cols-2 gap-3"><DomNumberInput v-for="key in ['left', 'top', 'width', 'fontSize']" :key="key" :label="({ left: 'X', top: 'Y', width: 'Width', fontSize: 'Font size' })[key]" :model-value="parseFloat(active.style[key]) || 0" @update:model-value="active.style[key] = `${$event}px`" /></div>
<DomShadowEditor v-model="active.shadows" />
</template>
<DomColorSwatch v-else v-model="background" label="Background" />
<p class="text-xs leading-5 text-muted-fg">Arrow keys on the selection move by 1px. Shift moves by 10px. Alt + arrows resize. Disabling movement keeps click selection available.</p>
</aside>
</div>
<section class="border-t border-border p-5"><h2 class="mb-3 text-sm font-semibold">Linked HTML <span class="font-normal text-muted-fg">· select a line to inspect its layer</span></h2><div class="overflow-auto rounded-lg bg-secondary p-3"><button v-for="(line, index) in sourceLines" :key="index" type="button" class="block w-full whitespace-pre text-left font-mono text-xs leading-6" :class="selected === line.id && 'bg-primary/10 text-primary'" :aria-pressed="selected === line.id" @click="selected = line.id">{{ line.text }}</button></div></section>
</main>
</template>
Flowchart editor
<script setup>
import { computed, nextTick, onMounted, ref } from 'vue';
import { VueFlow, Handle, Position, MarkerType, useVueFlow } from '@vue-flow/core';
import { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls';
import { MiniMap } from '@vue-flow/minimap';
import { DomButton, DomSplitterPanel, DomTextInput, DomTextareaInput } from '../../lib/vue';
import '@vue-flow/core/dist/style.css';
import '@vue-flow/controls/dist/style.css';
import '@vue-flow/minimap/dist/style.css';
const ready = ref(false);
const showInspector = ref(false);
const board = ref(null);
const panelWidth = ref(260);
const notice = ref('Edits stay in this tab. Export JSON before leaving.');
let nextId = 5;
const nodes = ref([
{ id: '1', type: 'step', position: { x: 60, y: 80 }, data: { label: 'An idea arrives', detail: 'Capture a new idea for the next campaign.' } },
{ id: '2', type: 'step', position: { x: 380, y: 80 }, data: { label: 'Create a draft', detail: 'Turn the idea into a first version.' } },
{ id: '3', type: 'step', position: { x: 700, y: -40 }, data: { label: 'Review together', detail: 'Gather feedback and refine the details.' } },
{ id: '4', type: 'step', position: { x: 700, y: 210 }, data: { label: 'Ready to share', detail: 'Publish something worth sharing.' } },
]);
const edges = ref([
{ id: '1-2', source: '1', target: '2' },
{ id: '2-3', source: '2', target: '3' },
{ id: '3-4', source: '3', target: '4' },
]);
const { addEdges, addNodes, removeNodes, removeEdges, findNode, addSelectedNodes, removeSelectedElements, screenToFlowCoordinate, fitView, toObject } = useVueFlow('designer-flowchart');
const selectedNode = computed(() => nodes.value.find((node) => node.selected));
const selectedEdge = computed(() => edges.value.find((edge) => edge.selected));
const edgeDefaults = { type: 'smoothstep', markerEnd: { type: MarkerType.ArrowClosed, color: 'var(--muted-fg)' }, style: { stroke: 'var(--muted-fg)', strokeWidth: 2 } };
/** Mounts the browser-only graph after SSR hydration. */
function mount() { ready.value = true; }
onMounted(mount);
/** Selects a node from the sidebar using the same selection state as the stage. */
function selectNode(id) {
removeSelectedElements();
const node = findNode(id);
if (node) addSelectedNodes([node]);
}
/** Adds a step near the visible viewport centre, in graph coordinates. */
async function addStep() {
await revealStage();
const bounds = board.value.getBoundingClientRect();
const position = screenToFlowCoordinate({ x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2 });
position.x -= 110;
position.y -= 50;
const id = String(nextId++);
addNodes([{ id, type: 'step', position, data: { label: 'New step', detail: 'Describe what happens here.' } }]);
selectNode(id);
}
/** Reveals the mobile stage and allows its viewport measurement to catch up. */
async function revealStage() {
showInspector.value = false;
await nextTick();
await new Promise(requestAnimationFrame);
}
/** Fits all nodes after revealing the stage when the mobile inspector was open. */
async function fitGraph() {
await revealStage();
fitView({ padding: 0.2 });
}
/** Adds an arrow between two distinct nodes; Vue Flow suppresses duplicate connections. */
function connect(connection) {
if (connection.source !== connection.target) addEdges([connection]);
}
/** Deletes the selected node and its connections, or the selected connection alone. */
function removeSelection() {
if (selectedNode.value) removeNodes([selectedNode.value.id]);
else if (selectedEdge.value) removeEdges([selectedEdge.value.id]);
}
/** Saves graph data and viewport position as a portable JSON document. */
function exportGraph() {
const url = URL.createObjectURL(new Blob([JSON.stringify(toObject(), null, '\t')], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.download = 'dom-studio-flowchart.json';
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
notice.value = 'Flowchart exported as JSON.';
}
</script>
<template>
<main class="flowchart flex h-dvh min-h-0 flex-col bg-canvas text-fg">
<header class="flex shrink-0 flex-wrap items-center gap-3 border-b border-border px-4 py-3">
<RouterLink to="/designer" class="text-sm text-muted-fg">← UI designer</RouterLink><RouterLink to="/designer/assembly#flowchart" class="text-sm text-primary underline underline-offset-4">Assembly guide</RouterLink>
<h1 class="text-sm font-semibold">Flowchart designer</h1>
<span class="rounded bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase text-primary">Beta</span>
<div class="ml-auto flex gap-2">
<DomButton size="sm" variant="outline" :disabled="!ready" @click="addStep">+ Step</DomButton>
<DomButton size="sm" variant="outline" :disabled="!ready" @click="fitGraph">Fit</DomButton>
<DomButton size="sm" variant="outline" :disabled="!ready" @click="exportGraph">Export JSON</DomButton>
<DomButton size="sm" variant="outline" class="lg:hidden" :aria-pressed="showInspector" @click="showInspector = !showInspector">{{ showInspector ? 'Stage' : 'Inspector' }}</DomButton>
</div>
</header>
<DomSplitterPanel v-model:start-size="panelWidth" :min-start="220" :min-main="280" class="flow-panels min-h-0 flex-1" :data-inspector="showInspector">
<template #start>
<aside class="h-full overflow-auto border-r border-border p-4" aria-label="Flowchart inspector">
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-fg">Steps</h2>
<div class="mb-6 space-y-1">
<button v-for="node in nodes" :key="node.id" type="button" class="w-full rounded px-3 py-2 text-left text-sm hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring" :class="node.selected ? 'bg-primary/10 text-primary' : ''" :aria-pressed="Boolean(node.selected)" @click="selectNode(node.id)">{{ node.data.label }}</button>
</div>
<div v-if="selectedNode" class="space-y-4">
<DomTextInput v-model="selectedNode.data.label" label="Step name" />
<DomTextareaInput v-model="selectedNode.data.detail" label="Description" />
</div>
<p v-else class="text-sm leading-6 text-muted-fg">{{ selectedEdge ? 'Connection selected.' : 'Select a step to edit its name and description.' }}</p>
<DomButton v-if="selectedNode || selectedEdge" size="sm" variant="outline" class="mt-4" @click="removeSelection">Delete {{ selectedNode ? 'step' : 'connection' }}</DomButton>
<p class="mt-6 text-xs leading-5 text-muted-fg">Drag a step to move it. Drag between the dots to connect steps. Drag empty space to pan; scroll or pinch to zoom. Select a connection to delete it.</p>
<p class="mt-4 text-xs leading-5 text-muted-fg">Powered by <a href="https://vueflow.dev/" target="_blank" rel="noopener noreferrer" class="underline">Vue Flow</a>. An open-ended workspace with HTML nodes and SVG connections.</p>
</aside>
</template>
<template #default>
<div ref="board" class="h-full min-h-0" aria-label="Flowchart stage">
<VueFlow v-if="ready" id="designer-flowchart" v-model:nodes="nodes" v-model:edges="edges" :default-edge-options="edgeDefaults" :min-zoom="0.1" :max-zoom="3" fit-view-on-init :delete-key-code="null" @connect="connect">
<Background :gap="24" :size="1" pattern-color="var(--border)" />
<Controls position="bottom-left" :show-interactive="false">
<template #icon-zoom-in><span aria-hidden="true">+</span><span class="sr-only">Zoom in</span></template>
<template #icon-zoom-out><span aria-hidden="true">−</span><span class="sr-only">Zoom out</span></template>
<template #icon-fit-view><span aria-hidden="true">⛶</span><span class="sr-only">Fit flowchart</span></template>
</Controls>
<MiniMap class="hidden sm:block" pannable zoomable node-color="var(--primary)" mask-color="var(--secondary)" />
<template #node-step="{ data, selected }">
<div class="w-[220px] rounded-xl border bg-canvas p-4 text-fg shadow-sm" :class="selected ? 'border-primary ring-2 ring-primary/20' : 'border-border'">
<Handle type="target" :position="Position.Left" aria-label="Input connection" />
<div class="mb-2 text-[10px] font-semibold uppercase tracking-wider text-primary">Step</div>
<h2 class="break-words text-sm font-semibold">{{ data.label }}</h2>
<p class="mt-2 whitespace-pre-wrap break-words text-xs leading-5 text-muted-fg">{{ data.detail }}</p>
<Handle type="source" :position="Position.Right" aria-label="Output connection" />
</div>
</template>
</VueFlow>
</div>
</template>
</DomSplitterPanel>
<footer class="shrink-0 border-t border-border px-4 py-2 text-xs text-muted-fg" role="status">{{ notice }}</footer>
</main>
</template>
<style scoped>
.flowchart :deep(.vue-flow__handle) { width: 10px; height: 10px; border: 2px solid var(--canvas); background: var(--primary); }
.flowchart :deep(.vue-flow__connection-path) { stroke: var(--primary); }
.flowchart :deep(.vue-flow__edge.selected .vue-flow__edge-path) { stroke: var(--primary) !important; }
.flowchart :deep(.vue-flow__controls-button) { background: var(--canvas); color: var(--fg); fill: currentColor; border-color: var(--border); }
.flowchart :deep(.vue-flow__controls-button:hover) { background: var(--secondary); }
.flowchart :deep(.vue-flow__minimap) { background: var(--canvas); border: 1px solid var(--border); }
/* Preserve a full-width stage on small screens; the inspector is an explicit tab. */
@media (max-width: 1023px) {
.flow-panels { display: block; min-width: 0 !important; }
.flow-panels :deep(> button), .flow-panels :deep(> section:first-child) { display: none; }
.flow-panels :deep(> section) { height: 100%; }
.flow-panels[data-inspector="true"] :deep(> section:first-child) { display: block; }
.flow-panels[data-inspector="true"] :deep(> section:last-child) { display: none; }
}
</style>
4. Add a source view to a social card
Use the same layer model and selection contract as the HTML starter. Derive source lines from your records and attach the matching layer ID to each line. Clicking a line assigns selected; selecting on the stage highlights the corresponding line. This demo’s source display is a model projection, not an editable HTML parser. Its layerStyle, sourceLines and copyHtml functions show how to serialize text and CSS safely for copying.