Merge branch 'figma-layout-hierarchy'

This commit is contained in:
Danila Poyarkov 2026-07-26 12:43:02 +03:00
commit 1436b9cb23
54 changed files with 455 additions and 301 deletions

View file

@ -19,12 +19,13 @@
- Drag with the Text tool to create a fixed-size text box, or click to create auto-width text.
- Target a specific open document and page from live CLI and MCP automation, including sessions with multiple documents.
- Test OpenAI-compatible provider connections from AI settings with clearer setup errors.
- Build custom property panels with new Vue SDK number fields, bindable values, property sections, segmented controls, property lists, color models, fill controls, and gradient primitives.
- Build custom property panels with new Vue SDK number fields, bindable values, property sections, responsive property grids, segmented controls, property lists, color models, fill controls, and gradient primitives.
- Connect local MCP clients through automatically discovered private Unix sockets on macOS and Linux, with localhost TCP fallback. (#338)
- Create centered frames from current Figma-style device and asset presets, or resize selected frames from the Design panel while preserving their names.
### Changed
- Choose Freeform, vertical, horizontal, or grid flow directly from the contextual Layout section, with sizing grouped alongside it and current Layout guide terminology.
- Redesign the editor chrome and Design panel with denser aligned controls, clearer selection and section states, improved menus and overlays, consistent light/dark theming, and better keyboard and screen-reader behavior.
- Scale the Layers panel to documents with thousands of nodes through virtualized rows, faster incremental updates, stable expansion, range selection, and scroll-to-selection.
- Resolve fonts before text appears, with language-aware CJK and Arabic fallback, character-specific remote subsets, and more reliable rendering as fonts load.

View file

@ -23,6 +23,7 @@ description: Component reference for headless Vue primitives in @open-pencil/vue
<SdkCardGroup>
<SdkCard title="PropertySection" to="/programmable/sdk/api/components/property-section" description="Collapsible property-section anatomy and empty states." />
<SdkCard title="PropertyGrid" to="/programmable/sdk/api/components/property-grid" description="Responsive field-grid and action-rail anatomy." />
<SdkCard title="SegmentedControl" to="/programmable/sdk/api/components/segmented-control" description="Accessible selection and action-only segment groups." />
<SdkCard title="PropertyListRoot" to="/programmable/sdk/api/components/property-list-root" description="Headless property list primitive." />
<SdkCard title="PropertyListItem" to="/programmable/sdk/api/components/property-list-item" description="Single fills, strokes, or effects row primitive." />

View file

@ -0,0 +1,5 @@
import { defineComponentMetaLoader } from '#docs/sdk/component-meta'
const sources = ['packages/vue/src/primitives/PropertyGrid/PropertyGridRoot.vue']
export default defineComponentMetaLoader(sources)

View file

@ -0,0 +1,40 @@
---
title: PropertyGrid
description: Headless field-grid and action-rail anatomy for property panels.
---
<script setup lang="ts">
import { data } from './property-grid.data'
</script>
# PropertyGrid
`PropertyGridRoot` separates responsive property fields from optional intrinsic-width actions. It exposes structural data attributes without imposing column widths, gaps, or presentation.
```vue twoslash
<script setup lang="ts">
import { PropertyGridRoot } from '@open-pencil/vue'
</script>
<template>
<PropertyGridRoot :columns="2">
<label>
Width
<input type="number" />
</label>
<label>
Height
<input type="number" />
</label>
<template #actions>
<button type="button" aria-label="Constrain proportions">Link</button>
</template>
</PropertyGridRoot>
</template>
```
Themes can target `data-slot="fields"`, `data-slot="actions"`, `data-columns`, and `data-distribution`. The `wide-first` distribution is semantic; consumers choose its exact ratio.
## Generated API reference
<SdkComponentAPI :components="data.components" />

View file

@ -128,12 +128,14 @@
"multiplayerCursors": "Cursor anderer Teilnehmer",
"direction": "Richtung",
"flow": "Fluss",
"freeform": "Freiform",
"dimensions": "Abmessungen",
"layoutHorizontal": "Horizontales Layout",
"layoutVertical": "Vertikales Layout",
"layoutGrid": "Rasterlayout",
"layoutGrids": "Layout-Raster",
"addLayoutGrid": "Layout-Raster hinzufügen",
"removeLayoutGrid": "Layout-Raster entfernen",
"layoutGrids": "Layout-Hilfslinien",
"addLayoutGrid": "Layout-Hilfslinie hinzufügen",
"removeLayoutGrid": "Layout-Hilfslinie entfernen",
"gridGrid": "Raster",
"gridColumns": "Spalten",
"gridRows": "Zeilen",

View file

@ -142,12 +142,14 @@
"multiplayerCursors": "Cursores de otros participantes",
"direction": "Dirección",
"flow": "Flujo",
"freeform": "Forma libre",
"dimensions": "Dimensiones",
"layoutHorizontal": "Diseño horizontal",
"layoutVertical": "Diseño vertical",
"layoutGrid": "Diseño de cuadrícula",
"layoutGrids": "Cuadrículas de diseño",
"addLayoutGrid": "Añadir cuadrícula de diseño",
"removeLayoutGrid": "Eliminar cuadrícula de diseño",
"layoutGrids": "Guías de diseño",
"addLayoutGrid": "Añadir guía de diseño",
"removeLayoutGrid": "Eliminar guía de diseño",
"gridGrid": "Cuadrícula",
"gridColumns": "Columnas",
"gridRows": "Filas",

View file

@ -128,12 +128,14 @@
"multiplayerCursors": "Curseurs des participants",
"direction": "Direction",
"flow": "Flux",
"freeform": "Forme libre",
"dimensions": "Dimensions",
"layoutHorizontal": "Disposition horizontale",
"layoutVertical": "Disposition verticale",
"layoutGrid": "Disposition en grille",
"layoutGrids": "Grilles de mise en page",
"addLayoutGrid": "Ajouter une grille de mise en page",
"removeLayoutGrid": "Supprimer la grille de mise en page",
"layoutGrids": "Repères de mise en page",
"addLayoutGrid": "Ajouter un repère de mise en page",
"removeLayoutGrid": "Supprimer le repère de mise en page",
"gridGrid": "Grille",
"gridColumns": "Colonnes",
"gridRows": "Rangées",

View file

@ -128,12 +128,14 @@
"multiplayerCursors": "Cursori degli altri partecipanti",
"direction": "Direzione",
"flow": "Flusso",
"freeform": "Forma libera",
"dimensions": "Dimensioni",
"layoutHorizontal": "Layout orizzontale",
"layoutVertical": "Layout verticale",
"layoutGrid": "Layout a griglia",
"layoutGrids": "Griglie di layout",
"addLayoutGrid": "Aggiungi griglia di layout",
"removeLayoutGrid": "Rimuovi griglia di layout",
"layoutGrids": "Guide di layout",
"addLayoutGrid": "Aggiungi guida di layout",
"removeLayoutGrid": "Rimuovi guida di layout",
"gridGrid": "Griglia",
"gridColumns": "Colonne",
"gridRows": "Righe",

View file

@ -142,12 +142,14 @@
"multiplayerCursors": "他の参加者のカーソル",
"direction": "方向",
"flow": "フロー",
"freeform": "フリーフォーム",
"dimensions": "寸法",
"layoutHorizontal": "横方向レイアウト",
"layoutVertical": "縦方向レイアウト",
"layoutGrid": "グリッドレイアウト",
"layoutGrids": "レイアウトグリッド",
"addLayoutGrid": "レイアウトグリッドを追加",
"removeLayoutGrid": "レイアウトグリッドを削除",
"layoutGrids": "レイアウトガイド",
"addLayoutGrid": "レイアウトガイドを追加",
"removeLayoutGrid": "レイアウトガイドを削除",
"gridGrid": "グリッド",
"gridColumns": "列",
"gridRows": "行",

View file

@ -128,12 +128,14 @@
"multiplayerCursors": "Kursory innych uczestników",
"direction": "Kierunek",
"flow": "Przepływ",
"freeform": "Swobodny",
"dimensions": "Wymiary",
"layoutHorizontal": "Układ poziomy",
"layoutVertical": "Układ pionowy",
"layoutGrid": "Układ siatki",
"layoutGrids": "Siatki układu",
"addLayoutGrid": "Dodaj siatkę układu",
"removeLayoutGrid": "Usuń siatkę układu",
"layoutGrids": "Prowadnice układu",
"addLayoutGrid": "Dodaj prowadnicę układu",
"removeLayoutGrid": "Usuń prowadnicę układu",
"gridGrid": "Siatka",
"gridColumns": "Kolumny",
"gridRows": "Wiersze",

View file

@ -128,12 +128,14 @@
"multiplayerCursors": "Курсоры участников",
"direction": "Направление",
"flow": "Поток",
"freeform": "Свободная",
"dimensions": "Размеры",
"layoutHorizontal": "Горизонтальная раскладка",
"layoutVertical": "Вертикальная раскладка",
"layoutGrid": "Сетка",
"layoutGrids": "Сетки макета",
"addLayoutGrid": "Добавить сетку макета",
"removeLayoutGrid": "Удалить сетку макета",
"layoutGrids": "Направляющие макета",
"addLayoutGrid": "Добавить направляющую макета",
"removeLayoutGrid": "Удалить направляющую макета",
"gridGrid": "Сетка",
"gridColumns": "Столбцы",
"gridRows": "Строки",

View file

@ -128,12 +128,14 @@
"multiplayerCursors": "协作光标",
"direction": "方向",
"flow": "流向",
"freeform": "自由布局",
"dimensions": "尺寸",
"layoutHorizontal": "水平布局",
"layoutVertical": "垂直布局",
"layoutGrid": "网格布局",
"layoutGrids": "布局网格",
"addLayoutGrid": "添加布局网格",
"removeLayoutGrid": "移除布局网格",
"layoutGrids": "布局参考线",
"addLayoutGrid": "添加布局参考线",
"removeLayoutGrid": "移除布局参考线",
"gridGrid": "网格",
"gridColumns": "列",
"gridRows": "行",

View file

@ -231,6 +231,8 @@ export const panelMessageDefaults = {
multiplayerCursors: 'Multiplayer cursors',
direction: 'Direction',
flow: 'Flow',
freeform: 'Freeform',
dimensions: 'Dimensions',
layoutHorizontal: 'Horizontal layout',
layoutVertical: 'Vertical layout',
layoutGrid: 'Grid layout',
@ -266,9 +268,9 @@ export const panelMessageDefaults = {
resizeAutoWidth: 'Auto width',
resizeAutoHeight: 'Auto height',
resizeFixed: 'Fixed size',
layoutGrids: 'Layout grid',
addLayoutGrid: 'Add layout grid',
removeLayoutGrid: 'Remove layout grid',
layoutGrids: 'Layout guide',
addLayoutGrid: 'Add layout guide',
removeLayoutGrid: 'Remove layout guide',
gridColumns: 'Columns',
gridRows: 'Rows',
gridGrid: 'Grid',

View file

@ -246,6 +246,14 @@ export type {
PropertyListRootSlotProps,
PropertyListRootSlots
} from '#vue/primitives/PropertyList'
export { PropertyGridRoot } from '#vue/primitives/PropertyGrid'
export type {
PropertyGridColumns,
PropertyGridDistribution,
PropertyGridRootProps,
PropertyGridRootSlots
} from '#vue/primitives/PropertyGrid'
export {
PropertySectionRoot,
PropertySectionHeader,

View file

@ -0,0 +1,27 @@
<script setup lang="ts">
import type {
PropertyGridRootProps,
PropertyGridRootSlots
} from '#vue/primitives/PropertyGrid/types'
const { columns = 1, distribution = 'equal' } = defineProps<PropertyGridRootProps>()
defineSlots<PropertyGridRootSlots>()
defineOptions({ inheritAttrs: false })
</script>
<template>
<div
v-bind="$attrs"
data-slot="root"
data-property-grid
:data-columns="columns"
:data-distribution="distribution"
>
<div data-slot="fields">
<slot />
</div>
<div v-if="$slots.actions" data-slot="actions">
<slot name="actions" />
</div>
</div>
</template>

View file

@ -0,0 +1,7 @@
export { default as PropertyGridRoot } from '#vue/primitives/PropertyGrid/PropertyGridRoot.vue'
export type {
PropertyGridColumns,
PropertyGridDistribution,
PropertyGridRootProps,
PropertyGridRootSlots
} from '#vue/primitives/PropertyGrid/types'

View file

@ -0,0 +1,18 @@
import type { VNode } from 'vue'
export type PropertyGridColumns = 1 | 2 | 3
export type PropertyGridDistribution = 'equal' | 'wide-first'
export interface PropertyGridRootProps {
/** Number of field columns. @default 1 */
columns?: PropertyGridColumns
/** Relative distribution of field columns. @default 'equal' */
distribution?: PropertyGridDistribution
}
export interface PropertyGridRootSlots {
/** Property fields arranged by the consumer's visual theme. */
default(): VNode[]
/** Optional intrinsic-width controls kept separate from the field grid. */
actions?(): VNode[]
}

View file

@ -13,6 +13,7 @@ import AppearanceSection from './properties/AppearanceSection.vue'
import EffectsSection from './properties/EffectsSection.vue'
import ExportSection from './properties/ExportSection.vue'
import FillSection from './properties/FillSection.vue'
import LayoutGridSection from './properties/LayoutSection/LayoutGridSection.vue'
import LayoutSection from './properties/LayoutSection/LayoutSection.vue'
import MaskSection from './properties/MaskSection.vue'
import PageSection from './properties/PageSection.vue'
@ -39,6 +40,10 @@ const isComponentType = computed(() => {
return type ? COMPONENT_TYPES.has(type) : false
})
const selectedIcon = computed(() => (node.value ? nodeIcon(node.value) : undefined))
const supportsLayoutGuides = computed(() => {
const type = node.value?.type
return type === 'FRAME' || type === 'COMPONENT' || type === 'COMPONENT_SET' || type === 'INSTANCE'
})
const { panels } = useI18n()
</script>
@ -131,6 +136,7 @@ const { panels } = useI18n()
<TypographySection v-if="node.type === 'TEXT'" />
<FillSection />
<StrokeSection />
<LayoutGridSection v-if="supportsLayoutGuides" />
<EffectsSection />
<ExportSection />

View file

@ -77,7 +77,7 @@ const styles = useColorSliderUI(
:max="displayMax"
:step="displayStep"
:suffix="suffix"
:ui="{ leading: 'hidden' }"
:ui="{ leading: 'hidden', field: 'pl-1.5', display: 'pl-1.5' }"
@update:model-value="emit('updateDisplay', $event)"
/>
</div>

View file

@ -72,7 +72,7 @@ const styles = useColorSliderUI(
:max="numberMax"
:step="numberStep"
:suffix="suffix"
:ui="{ leading: 'hidden' }"
:ui="{ leading: 'hidden', field: 'pl-1.5', display: 'pl-1.5' }"
@update:model-value="emit('updateNumber', $event)"
/>
</div>

View file

@ -52,7 +52,7 @@ function cancelFromEscape(event: KeyboardEvent) {
type="button"
:aria-label="panels.fill"
data-test-id="fill-picker-swatch"
class="size-5 shrink-0 cursor-pointer rounded border-0 bg-transparent p-0"
class="size-4 shrink-0 cursor-pointer rounded-sm border-0 bg-transparent p-0"
>
<FillSwatch :fill="fill" class="size-full" v-slot="swatch">
<span

View file

@ -8,7 +8,6 @@ import AppSelect from '@/components/ui/AppSelect.vue'
import IconButton from '@/components/ui/IconButton.vue'
import PanelFieldGroup from '@/components/ui/panel/PanelFieldGroup.vue'
import PanelGrid from '@/components/ui/panel/PanelGrid.vue'
import PanelRail from '@/components/ui/panel/PanelRail.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
import type { BlendMode } from '@open-pencil/scene-graph'
@ -55,7 +54,7 @@ function blendModeOptions(value: BlendMode | typeof MIXED) {
</IconButton>
</template>
<PanelGrid columns="appearance">
<PanelGrid :columns="2" distribution="wide-first">
<PanelFieldGroup :label="panels.blendMode">
<AppSelect
:model-value="blendModeValue === MIXED ? 'MIXED' : blendModeValue"
@ -103,11 +102,7 @@ function blendModeOptions(value: BlendMode | typeof MIXED) {
</PanelFieldGroup>
</PanelGrid>
<PanelGrid
v-if="hasCornerRadius && !showIndependentCorners"
columns="fill-rail"
class="mt-1.5"
>
<PanelGrid v-if="hasCornerRadius && !showIndependentCorners" :columns="2" class="mt-1.5">
<PanelFieldGroup :label="panels.radius">
<VariableNumberField
v-if="node && !isMulti"
@ -137,7 +132,7 @@ function blendModeOptions(value: BlendMode | typeof MIXED) {
</template>
</NumberField>
</PanelFieldGroup>
<PanelRail>
<div class="flex h-6 items-center justify-end">
<IconButton
:label="panels.independentCornerRadii"
size="md"
@ -146,13 +141,13 @@ function blendModeOptions(value: BlendMode | typeof MIXED) {
>
<icon-lucide-square-round-corner class="size-3" />
</IconButton>
</PanelRail>
</div>
</PanelGrid>
<PanelGrid
v-else-if="hasCornerRadius && !isMulti && node"
columns="two-rail"
class="mt-1.5"
:columns="2"
class="mt-1.5 [&>[data-slot=actions]]:self-start"
data-corner-grid
>
<VariableNumberField
@ -173,16 +168,6 @@ function blendModeOptions(value: BlendMode | typeof MIXED) {
@update:model-value="actions.updateCornerProp('topRightRadius', $event)"
@commit="(v: number, p: number) => actions.commitCornerProp('topRightRadius', v, p)"
/>
<PanelRail>
<IconButton
:label="panels.independentCornerRadii"
size="md"
active
@click="actions.toggleIndependentCorners"
>
<icon-lucide-square-round-corner class="size-3" />
</IconButton>
</PanelRail>
<VariableNumberField
label="BL"
:model-value="node.bottomLeftRadius"
@ -201,10 +186,19 @@ function blendModeOptions(value: BlendMode | typeof MIXED) {
@update:model-value="actions.updateCornerProp('bottomRightRadius', $event)"
@commit="(v: number, p: number) => actions.commitCornerProp('bottomRightRadius', v, p)"
/>
<PanelRail />
<template #actions>
<IconButton
:label="panels.independentCornerRadii"
size="md"
active
@click="actions.toggleIndependentCorners"
>
<icon-lucide-square-round-corner class="size-3" />
</IconButton>
</template>
</PanelGrid>
<PanelGrid v-if="hasCornerRadius" columns="fill" class="mt-1.5">
<PanelGrid v-if="hasCornerRadius" :columns="2" class="mt-1.5">
<PanelFieldGroup :label="panels.cornerSmoothing">
<NumberField
suffix="%"

View file

@ -93,67 +93,68 @@ function updateSolidColor(
:visibility-label="panels.toggleVisibility"
:remove-label="panels.removeFill"
>
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
<BindableValueRoot
v-slot="binding"
:provider="colorProvider"
:targets="paintBindingTargets(selectedNodeIds, 'fills', index)"
:value="fill.color"
batch-label="Change fill color"
<BindableValueRoot
v-slot="binding"
:provider="colorProvider"
:targets="paintBindingTargets(selectedNodeIds, 'fills', index)"
:value="fill.color"
batch-label="Change fill color"
>
<PaintField
class="w-full flex-none"
:opacity="fill.opacity"
:opacity-label="panels.opacity"
@update:opacity="actions.patch(index, { opacity: $event })"
>
<PaintField
class="w-full flex-none"
:opacity="fill.opacity"
:opacity-label="panels.opacity"
@update:opacity="actions.patch(index, { opacity: $event })"
>
<template #preview>
<FillPicker
:fill="displayFill(fill, binding.resolvedValue)"
:okhcl="createFillOkhclAdapter(okhcl, activeNode, index)"
@update="
updatePickerFill(binding.actions, flush, $event, (next) =>
actions.update(index, next)
)
"
@open-change="!$event && commitPaintMutation(binding.actions)"
@cancel="cancelPaintMutation(binding.actions)"
/>
</template>
<template #preview>
<FillPicker
:fill="displayFill(fill, binding.resolvedValue)"
:okhcl="createFillOkhclAdapter(okhcl, activeNode, index)"
@update="
updatePickerFill(binding.actions, flush, $event, (next) =>
actions.update(index, next)
)
"
@open-change="!$event && commitPaintMutation(binding.actions)"
@cancel="cancelPaintMutation(binding.actions)"
/>
</template>
<template #value>
<PaintValue
v-if="fill.type === 'SOLID'"
:color="fill.color"
:resolved-color="binding.resolvedValue"
:variable-name="binding.variable?.name"
:label="panels.fill"
@update="
updateSolidColor(binding.actions, flush, fill, $event, (next) =>
actions.update(index, next)
)
"
/>
<span v-else class="min-w-0 flex-1 truncate font-mono text-xs text-surface">
{{ fillLabel(fill) }}
</span>
</template>
<template #value>
<PaintValue
v-if="fill.type === 'SOLID'"
:color="fill.color"
:resolved-color="binding.resolvedValue"
:variable-name="binding.variable?.name"
:label="panels.fill"
@update="
updateSolidColor(binding.actions, flush, fill, $event, (next) =>
actions.update(index, next)
)
"
/>
<span v-else class="min-w-0 flex-1 truncate font-mono text-xs text-surface">
{{ fillLabel(fill) }}
</span>
</template>
<template v-if="fill.type === 'SOLID'" #binding>
<VariableBindingPicker
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:detach-label="panels.detachVariable"
:create-label="
panels.createColorVariable({ value: `#${colorToHexRaw(fill.color)}` })
"
:create-name-placeholder="panels.variableName"
:create-submit-label="panels.create"
/>
</template>
</PaintField>
</BindableValueRoot>
<template v-if="fill.type === 'SOLID'" #binding>
<VariableBindingPicker
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:detach-label="panels.detachVariable"
:create-label="
panels.createColorVariable({ value: `#${colorToHexRaw(fill.color)}` })
"
:create-name-placeholder="panels.variableName"
:create-submit-label="panels.create"
/>
</template>
</PaintField>
</BindableValueRoot>
<template #details>
<PanelFieldGroup :label="panels.blendMode">
<AppSelect
:model-value="fill.blendMode ?? 'NORMAL'"
@ -167,7 +168,7 @@ function updateSolidColor(
"
/>
</PanelFieldGroup>
</div>
</template>
</PropertyItemRow>
</div>
</PanelSection>

View file

@ -4,46 +4,56 @@ import { computed } from 'vue'
import { useI18n, useLayoutControlsContext } from '@open-pencil/vue'
import IconButton from '@/components/ui/IconButton.vue'
import SegmentedControl from '@/components/ui/SegmentedControl.vue'
import Tip from '@/components/ui/Tip.vue'
import type { LayoutMode } from '@open-pencil/scene-graph'
const ctx = useLayoutControlsContext()
const { panels } = useI18n()
const layoutModes = computed<Array<{ mode: LayoutMode; label: string }>>(() => [
{ mode: 'HORIZONTAL', label: panels.value.layoutHorizontal },
{ mode: 'VERTICAL', label: panels.value.layoutVertical },
{ mode: 'GRID', label: panels.value.layoutGrid }
const layoutModes = computed<Array<{ value: LayoutMode; label: string }>>(() => [
{ value: 'NONE', label: panels.value.freeform },
{ value: 'VERTICAL', label: panels.value.layoutVertical },
{ value: 'HORIZONTAL', label: panels.value.layoutHorizontal },
{ value: 'GRID', label: panels.value.layoutGrid }
])
function setLayoutMode(mode: string) {
ctx.editor.setLayoutMode(ctx.node.id, mode as LayoutMode)
}
</script>
<template>
<div
v-if="ctx.node.layoutMode !== 'NONE'"
class="flex items-center gap-1"
role="toolbar"
:aria-label="panels.flow"
>
<IconButton
v-for="direction in layoutModes"
:key="direction.mode"
:label="direction.label"
size="md"
:active="direction.mode === 'GRID' ? ctx.isGrid : ctx.node.layoutMode === direction.mode"
@click="ctx.editor.setLayoutMode(ctx.node.id, direction.mode)"
>
<icon-lucide-arrow-right v-if="direction.mode === 'HORIZONTAL'" class="size-3.5" />
<icon-lucide-arrow-down v-else-if="direction.mode === 'VERTICAL'" class="size-3.5" />
<icon-lucide-layout-grid v-else class="size-3.5" />
</IconButton>
<IconButton
v-if="ctx.isFlex"
:label="panels.layoutWrap"
size="md"
:active="ctx.node.layoutWrap === 'WRAP'"
@click="ctx.updateProp('layoutWrap', ctx.node.layoutWrap === 'WRAP' ? 'NO_WRAP' : 'WRAP')"
>
<icon-lucide-wrap-text class="size-3.5" />
</IconButton>
<div>
<label class="mb-1 block text-[11px] text-muted">{{ panels.flow }}</label>
<div class="flex items-center gap-1.5">
<SegmentedControl
:model-value="ctx.node.layoutMode"
:options="layoutModes"
:label="panels.flow"
:ui="{ root: 'flex min-w-0 flex-1' }"
@change="setLayoutMode"
>
<template #option="{ option }">
<Tip :label="option.label" class="flex items-center justify-center">
<icon-lucide-move v-if="option.value === 'NONE'" class="size-3.5" />
<icon-lucide-rows-2 v-else-if="option.value === 'VERTICAL'" class="size-3.5" />
<icon-lucide-columns-2 v-else-if="option.value === 'HORIZONTAL'" class="size-3.5" />
<icon-lucide-layout-grid v-else class="size-3.5" />
</Tip>
</template>
</SegmentedControl>
<IconButton
v-if="ctx.isFlex"
:label="panels.layoutWrap"
size="md"
:active="ctx.node.layoutWrap === 'WRAP'"
@click="ctx.updateProp('layoutWrap', ctx.node.layoutWrap === 'WRAP' ? 'NO_WRAP' : 'WRAP')"
>
<icon-lucide-wrap-text class="size-3.5" />
</IconButton>
</div>
</div>
</template>

View file

@ -52,7 +52,7 @@ function alignmentCellClass(primary: LayoutAlign, counter: string) {
<template>
<div class="mt-2">
<label class="mb-1 block text-[11px] text-muted">{{ panels.flow }}</label>
<label class="mb-1 block text-[11px] text-muted">{{ panels.direction }}</label>
<AppSelect
:model-value="ctx.layoutDirection"
:options="[

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n, useLayoutControlsContext, useSceneComputed } from '@open-pencil/vue'
import { useEditor, useI18n, useSceneComputed } from '@open-pencil/vue'
import NumberField from '@/components/inputs/NumberField.vue'
import IconButton from '@/components/ui/IconButton.vue'
@ -10,13 +10,14 @@ import PanelFieldGroup from '@/components/ui/panel/PanelFieldGroup.vue'
import PanelGrid from '@/components/ui/panel/PanelGrid.vue'
import PanelItemRow from '@/components/ui/panel/PanelItemRow.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
import SharedStyleField from '@/components/properties/shared-style/SharedStyleField.vue'
import type { LayoutGrid } from '@open-pencil/scene-graph'
const ctx = useLayoutControlsContext()
const editor = useEditor()
const { panels } = useI18n()
const selectedNode = useSceneComputed(() => ctx.editor.getSelectedNode() ?? null)
const selectedNode = useSceneComputed(() => editor.getSelectedNode() ?? null)
const grids = computed<LayoutGrid[]>(() => selectedNode.value?.layoutGrids ?? [])
const patternOptions = computed(() => [
@ -41,21 +42,21 @@ function defaultGrid(): LayoutGrid {
function commit(next: LayoutGrid[], label: string) {
const node = selectedNode.value
if (!node) return
ctx.editor.updateNodeWithUndo(node.id, { layoutGrids: next }, label)
editor.updateNodeWithUndo(node.id, { layoutGrids: next }, label)
}
function add() {
commit([...grids.value, defaultGrid()], 'Add layout grid')
commit([...grids.value, defaultGrid()], 'Add layout guide')
}
function remove(index: number) {
commit(
grids.value.filter((_, i) => i !== index),
'Remove layout grid'
'Remove layout guide'
)
}
function patch(index: number, changes: Partial<LayoutGrid>, label = 'Edit layout grid') {
function patch(index: number, changes: Partial<LayoutGrid>, label = 'Edit layout guide') {
commit(
grids.value.map((grid, i) => (i === index ? { ...grid, ...changes } : grid)),
label
@ -74,6 +75,7 @@ function isGrid(grid: LayoutGrid): boolean {
<template>
<PanelSection :label="panels.layoutGrids" :empty="grids.length === 0">
<SharedStyleField kind="grid" :label="panels.gridStyle" />
<template #actions>
<IconButton :label="panels.addLayoutGrid" @click="add">
<icon-lucide-plus class="size-3.5" />
@ -98,7 +100,7 @@ function isGrid(grid: LayoutGrid): boolean {
</Tip>
</template>
</SegmentedControl>
<PanelGrid columns="two">
<PanelGrid :columns="2">
<PanelFieldGroup :label="panels.gridCount">
<NumberField
:model-value="grid.count ?? grid.numSections ?? 1"
@ -136,7 +138,7 @@ function isGrid(grid: LayoutGrid): boolean {
<IconButton
:label="panels.toggleVisibility"
:active="grid.visible === false"
@click="patch(index, { visible: grid.visible === false }, 'Toggle layout grid')"
@click="patch(index, { visible: grid.visible === false }, 'Toggle layout guide')"
>
<icon-lucide-eye-off v-if="grid.visible === false" class="size-3.5" />
<icon-lucide-eye v-else class="size-3.5" />

View file

@ -5,12 +5,10 @@ import AutoLayoutControls from '@/components/properties/LayoutSection/AutoLayout
import ClipContentControl from '@/components/properties/LayoutSection/ClipContentControl.vue'
import FlexControls from '@/components/properties/LayoutSection/FlexControls.vue'
import GridControls from '@/components/properties/LayoutSection/GridControls.vue'
import LayoutGridSection from '@/components/properties/LayoutSection/LayoutGridSection.vue'
import IconButton from '@/components/ui/IconButton.vue'
import PaddingControls from '@/components/properties/LayoutSection/PaddingControls.vue'
import SizeControls from '@/components/properties/LayoutSection/size/SizeControls.vue'
import TextResizingControl from '@/components/properties/LayoutSection/TextResizingControl.vue'
import SharedStyleField from '@/components/properties/shared-style/SharedStyleField.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
const { panels } = useI18n()
@ -21,45 +19,41 @@ const CONTAINER_TYPES = ['FRAME', 'COMPONENT', 'COMPONENT_SET', 'INSTANCE']
<template>
<LayoutControlsRoot v-slot="ctx">
<template v-if="ctx.node">
<PanelSection :label="panels.layout">
<SharedStyleField kind="grid" :label="panels.gridStyle" />
<PanelSection :label="ctx.node.layoutMode === 'NONE' ? panels.layout : panels.autoLayout">
<template v-if="CONTAINER_TYPES.includes(ctx.node.type)" #actions>
<IconButton
:label="ctx.node.layoutMode === 'NONE' ? panels.addAutoLayout : panels.removeAutoLayout"
size="md"
:active="ctx.node.layoutMode !== 'NONE'"
class="data-[state=on]:bg-accent/15"
@click="
ctx.editor.setLayoutMode(
ctx.node.id,
ctx.node.layoutMode === 'NONE' ? 'VERTICAL' : 'NONE'
)
"
>
<icon-lucide-layout-panel-top class="size-3.5" />
</IconButton>
</template>
<AutoLayoutControls v-if="CONTAINER_TYPES.includes(ctx.node.type)" />
<div class="mt-2 mb-1 text-[11px] text-muted">{{ panels.dimensions }}</div>
<TextResizingControl v-if="ctx.node.type === 'TEXT'" />
<SizeControls />
<ClipContentControl
v-if="CONTAINER_TYPES.includes(ctx.node.type) && ctx.node.layoutMode === 'NONE'"
/>
<template v-if="CONTAINER_TYPES.includes(ctx.node.type) && ctx.node.layoutMode !== 'NONE'">
<FlexControls v-if="ctx.isFlex" />
<template v-if="ctx.isGrid">
<GridControls />
<PaddingControls />
<ClipContentControl />
</template>
</template>
</PanelSection>
<template v-if="CONTAINER_TYPES.includes(ctx.node.type)">
<PanelSection :label="panels.autoLayout">
<template #actions>
<IconButton
v-if="ctx.node.layoutMode === 'NONE'"
:label="panels.addAutoLayout"
@click="ctx.editor.setLayoutMode(ctx.node.id, 'VERTICAL')"
>
<icon-lucide-plus class="size-3.5" />
</IconButton>
<IconButton
v-else
:label="panels.removeAutoLayout"
@click="ctx.editor.setLayoutMode(ctx.node.id, 'NONE')"
>
<icon-lucide-minus class="size-3.5" />
</IconButton>
</template>
<AutoLayoutControls />
<template v-if="ctx.node.layoutMode !== 'NONE'">
<FlexControls v-if="ctx.isFlex" />
<template v-if="ctx.isGrid">
<GridControls />
<PaddingControls />
<ClipContentControl />
</template>
</template>
</PanelSection>
<LayoutGridSection />
</template>
</template>
</LayoutControlsRoot>
</template>

View file

@ -48,12 +48,12 @@ const visibleSizeLimits = computed(() =>
</script>
<template>
<PanelGrid columns="two">
<PanelGrid :columns="2">
<SizeAxisField axis="width" icon="W" :label="panels.width" />
<SizeAxisField axis="height" icon="H" :label="panels.height" />
</PanelGrid>
<PanelGrid v-if="visibleSizeLimits.length" columns="two" class="mt-1.5">
<PanelGrid v-if="visibleSizeLimits.length" :columns="2" class="mt-1.5">
<SizeLimitField v-for="item in visibleSizeLimits" :key="item.prop" :item="item" />
</PanelGrid>
</template>

View file

@ -79,7 +79,7 @@ function handleAlign(
</div>
</div>
<PanelGrid columns="two">
<PanelGrid :columns="2">
<Tip :label="panels.xAxis">
<NumberField
icon="X"
@ -102,7 +102,7 @@ function handleAlign(
</Tip>
</PanelGrid>
<PanelGrid v-if="isMulti" columns="two" class="mt-1.5">
<PanelGrid v-if="isMulti" :columns="2" class="mt-1.5">
<Tip :label="panels.width">
<NumberField
icon="W"
@ -127,7 +127,7 @@ function handleAlign(
</Tip>
</PanelGrid>
<div class="mt-1.5 grid grid-cols-[minmax(0,1fr)_repeat(3,24px)] gap-0.5">
<PanelGrid :columns="2" class="mt-1.5">
<Tip :label="panels.rotation">
<NumberField
suffix="°"
@ -144,16 +144,18 @@ function handleAlign(
</template>
</NumberField>
</Tip>
<IconButton :label="panels.flipHorizontal" size="md" @click="actions.flip('horizontal')">
<icon-lucide-flip-horizontal-2 class="size-3.5" />
</IconButton>
<IconButton :label="panels.flipVertical" size="md" @click="actions.flip('vertical')">
<icon-lucide-flip-vertical-2 class="size-3.5" />
</IconButton>
<IconButton :label="panels.rotate90" size="md" @click="actions.rotate(90)">
<icon-lucide-rotate-cw-square class="size-3.5" />
</IconButton>
</div>
<div class="flex h-6 items-center justify-end gap-0.5">
<IconButton :label="panels.flipHorizontal" size="md" @click="actions.flip('horizontal')">
<icon-lucide-flip-horizontal-2 class="size-3.5" />
</IconButton>
<IconButton :label="panels.flipVertical" size="md" @click="actions.flip('vertical')">
<icon-lucide-flip-vertical-2 class="size-3.5" />
</IconButton>
<IconButton :label="panels.rotate90" size="md" @click="actions.rotate(90)">
<icon-lucide-rotate-cw-square class="size-3.5" />
</IconButton>
</div>
</PanelGrid>
</PanelSection>
</PositionControlsRoot>
</template>

View file

@ -154,7 +154,7 @@ function onToggleSides(activeNode: SceneNode | null) {
<button
type="button"
:aria-label="panels.stroke"
class="size-5 shrink-0 cursor-pointer rounded border-0 bg-transparent p-0"
class="size-4 shrink-0 cursor-pointer rounded-sm border-0 bg-transparent p-0"
>
<FillSwatch
:fill="strokePreview(stroke, binding.resolvedValue ?? stroke.color)"
@ -266,7 +266,7 @@ function onToggleSides(activeNode: SceneNode | null) {
</template>
</div>
<PanelGrid v-if="advancedActive" columns="three" class="mt-1.5">
<PanelGrid v-if="advancedActive" :columns="3" class="mt-1.5">
<PanelFieldGroup :label="panels.strokeCap">
<SegmentedControl
:model-value="cap === MIXED ? 'MIXED' : cap"

View file

@ -88,7 +88,7 @@ function featureEnabled(features: Array<{ tag: string; enabled: boolean }>, tag:
</Tip>
</div>
<PanelGrid columns="two" class="mb-3">
<PanelGrid :columns="2" class="mb-3">
<PanelFieldGroup :label="panels.fontWeight">
<AppSelect
:label="panels.fontWeight"
@ -111,7 +111,7 @@ function featureEnabled(features: Array<{ tag: string; enabled: boolean }>, tag:
</PanelFieldGroup>
</PanelGrid>
<PanelGrid columns="two" class="mb-3">
<PanelGrid :columns="2" class="mb-3">
<PanelFieldGroup :label="panels.lineHeight">
<VariableNumberField
:model-value="
@ -196,7 +196,11 @@ function featureEnabled(features: Array<{ tag: string; enabled: boolean }>, tag:
</SegmentedControl>
</PanelFieldGroup>
<PanelFieldGroup :label="panels.textFormatting" class="mb-3" :ui="{ container: 'flex-row gap-1.5' }">
<PanelFieldGroup
:label="panels.textFormatting"
class="mb-3"
:ui="{ container: 'flex-row gap-1.5' }"
>
<div
class="inline-flex items-center gap-0.5 rounded bg-panel-field p-0.5 hover:bg-panel-field-hover"
role="toolbar"
@ -237,7 +241,7 @@ function featureEnabled(features: Array<{ tag: string; enabled: boolean }>, tag:
</div>
</PanelFieldGroup>
<PanelGrid columns="two" class="mb-3">
<PanelGrid :columns="2" class="mb-3">
<PanelFieldGroup :label="panels.textCase">
<AppSelect
:label="panels.textCase"

View file

@ -32,6 +32,7 @@ const emit = defineEmits<{
defineSlots<{
default(props: PropertyListItemSlotProps<K>): VNode[]
rail?(props: PropertyListItemSlotProps<K>): VNode[]
details?(props: PropertyListItemSlotProps<K>): VNode[]
}>()
</script>
@ -47,6 +48,9 @@ defineSlots<{
>
<PanelItemRow>
<slot v-bind="item" />
<template v-if="$slots.details" #details>
<slot name="details" v-bind="item" />
</template>
<template #rail="{ removeClass }">
<slot name="rail" v-bind="item" />
<Tip v-if="showVisibility" :label="visibilityLabel">

View file

@ -56,7 +56,9 @@ const styles = computed(() => tv(paintFieldTheme)())
:max="100"
:ui="{
root: 'h-full rounded-none border-0 bg-transparent shadow-none',
leading: 'hidden'
leading: 'hidden',
field: 'pl-1.5',
display: 'pl-1.5'
}"
data-property="opacity"
@update:model-value="emit('update:opacity', Math.max(0, Math.min(1, $event / 100)))"

View file

@ -45,7 +45,7 @@ function update(value: string) {
</script>
<template>
<PanelGrid v-if="visible" columns="fill" class="mb-1.5">
<PanelGrid v-if="visible" class="mb-1.5">
<PanelFieldGroup :label="label">
<AppSelect
:model-value="styleId === MIXED ? 'MIXED' : (styleId ?? 'NONE')"

View file

@ -16,7 +16,6 @@ import SegmentedControl from '@/components/ui/SegmentedControl.vue'
import PanelFieldGroup from './PanelFieldGroup.vue'
import PanelGrid from './PanelGrid.vue'
import PanelHeader from './PanelHeader.vue'
import PanelRail from './PanelRail.vue'
import PanelSection from './PanelSection.vue'
const meta = {
@ -47,7 +46,6 @@ export const StateMatrix: Story = {
PanelFieldGroup,
PanelGrid,
PanelHeader,
PanelRail,
PanelSection,
RotateIcon,
SegmentedControl,
@ -98,30 +96,30 @@ export const StateMatrix: Story = {
<template #actions>
<IconButton label="Reset layout"><RotateIcon class="size-3.5" /></IconButton>
</template>
<PanelGrid columns="two-rail">
<PanelGrid :columns="2">
<PanelFieldGroup label="Width">
<AppInput v-model="width" tone="panel" data-story-control data-state="idle" aria-label="Width" />
</PanelFieldGroup>
<PanelFieldGroup label="Height">
<AppInput v-model="height" tone="panel" data-story-control data-state="focus" aria-label="Height" />
</PanelFieldGroup>
<PanelRail>
<template #actions>
<IconButton label="Constrain proportions" size="md"><LinkIcon class="size-3.5" /></IconButton>
</PanelRail>
</template>
</PanelGrid>
</PanelSection>
<PanelSection label="Appearance">
<PanelGrid columns="two-rail">
<PanelGrid :columns="2">
<PanelFieldGroup label="Blend mode">
<AppSelect v-model="blendMode" :options="blendModes" data-story-control aria-label="Blend mode" />
</PanelFieldGroup>
<PanelFieldGroup label="Opacity">
<AppInput v-model="mixed" tone="panel" state="mixed" readonly data-story-control aria-label="Mixed opacity" />
</PanelFieldGroup>
<PanelRail>
<template #actions>
<IconButton label="Toggle visibility"><EyeIcon class="size-3.5" /></IconButton>
</PanelRail>
</template>
</PanelGrid>
</PanelSection>

View file

@ -1,37 +1,36 @@
<script lang="ts">
import type { VNode } from 'vue'
import type { PropertyGridRootSlots } from '@open-pencil/vue'
import type { ClassValue } from 'tailwind-variants'
import type { PanelGridTheme } from '@/theme/panel/grid'
type PanelGridColumns = keyof PanelGridTheme['variants']['columns']
export interface PanelGridProps {
columns?: PanelGridColumns
columns?: 1 | 2 | 3
distribution?: 'equal' | 'wide-first'
class?: ClassValue
}
export interface PanelGridSlots {
default(): VNode[]
}
export type PanelGridSlots = PropertyGridRootSlots
</script>
<script setup lang="ts">
import { PropertyGridRoot } from '@open-pencil/vue'
import { tv } from 'tailwind-variants'
import theme from '@/theme/panel/grid'
const { columns = 'two-rail', class: className } = defineProps<PanelGridProps>()
const { columns = 1, distribution = 'equal', class: className } = defineProps<PanelGridProps>()
defineSlots<PanelGridSlots>()
const panelGrid = tv(theme)
</script>
<template>
<div
data-slot="root"
data-panel-grid
:data-columns="columns"
:class="panelGrid({ columns, class: className })"
<PropertyGridRoot
:columns="columns"
:distribution="distribution"
:class="panelGrid({ columns, distribution, class: className })"
>
<slot />
</div>
<template v-if="$slots.actions" #actions>
<slot name="actions" />
</template>
</PropertyGridRoot>
</template>

View file

@ -15,6 +15,7 @@ export interface PanelItemRowProps {
export interface PanelItemRowSlots {
default(): VNode[]
rail?(props: { removeClass: string }): VNode[]
details?(): VNode[]
}
</script>
@ -37,5 +38,8 @@ const styles = computed(() => tv(itemRowTheme)())
<div v-if="$slots.rail" :class="styles.rail({ class: ui?.rail })" data-slot="rail">
<slot name="rail" :remove-class="styles.remove({ class: ui?.remove })" />
</div>
<div v-if="$slots.details" :class="styles.details({ class: ui?.details })" data-slot="details">
<slot name="details" />
</div>
</div>
</template>

View file

@ -1,28 +0,0 @@
<script lang="ts">
import type { VNode } from 'vue'
import type { ClassValue } from 'tailwind-variants'
export interface PanelRailProps {
class?: ClassValue
}
export interface PanelRailSlots {
default(): VNode[]
}
</script>
<script setup lang="ts">
import { tv } from 'tailwind-variants'
import theme from '@/theme/panel/rail'
const { class: className } = defineProps<PanelRailProps>()
defineSlots<PanelRailSlots>()
const panelRail = tv(theme)
</script>
<template>
<div data-slot="root" data-panel-rail :class="panelRail({ class: className })">
<slot />
</div>
</template>

View file

@ -2,5 +2,4 @@ export { default as PanelFieldGroup } from './PanelFieldGroup.vue'
export { default as PanelGrid } from './PanelGrid.vue'
export { default as PanelHeader } from './PanelHeader.vue'
export { default as PanelItemRow } from './PanelItemRow.vue'
export { default as PanelRail } from './PanelRail.vue'
export { default as PanelSection } from './PanelSection.vue'

View file

@ -3,12 +3,12 @@ import { CHECKERBOARD_BACKGROUND } from '@/theme/checkerboard'
export default {
slots: {
root: 'flex items-center gap-2',
label: 'w-4 shrink-0 text-[10px] font-medium text-muted',
label: 'w-7 shrink-0 text-[10px] font-medium text-muted',
slider: 'relative flex h-3 flex-1 touch-none items-center rounded-md select-none',
track: 'absolute inset-0 overflow-hidden rounded-md',
thumb:
'block size-3.5 rounded-full border-2 border-white shadow-sm outline-none ring-offset-1 focus-visible:ring-2 focus-visible:ring-primary',
input: 'w-14 shrink-0'
input: 'w-14 flex-none shrink-0'
},
variants: {
checkerboard: {

View file

@ -1,10 +1,10 @@
export default {
slots: {
root: 'flex h-6 min-w-0 flex-1 items-center overflow-hidden rounded border border-transparent bg-panel-field text-[11px] transition-colors hover:bg-panel-field-hover focus-within:border-panel-focus focus-within:bg-panel-field-hover',
preview: 'flex shrink-0 items-center pl-0.5',
value: 'flex min-w-0 flex-1 items-center px-1.5',
divider: 'h-4 w-px shrink-0 bg-border',
opacity: 'h-full w-14 shrink-0',
preview: 'flex shrink-0 items-center pl-1',
value: 'flex min-w-0 flex-1 items-center pl-1.5 pr-1',
divider: 'h-4 w-px shrink-0 bg-muted/40',
opacity: 'h-full w-12 flex-none shrink-0',
binding: 'flex shrink-0 items-center pr-0.5'
}
} as const

View file

@ -1,17 +1,24 @@
const panelGridTheme = {
base: 'grid min-w-0 items-end gap-1.5',
base: [
'flex min-w-0 items-end gap-1.5',
'[&>[data-slot=fields]]:grid [&>[data-slot=fields]]:min-w-0 [&>[data-slot=fields]]:flex-1 [&>[data-slot=fields]]:items-end [&>[data-slot=fields]]:gap-1.5',
'[&>[data-slot=actions]]:flex [&>[data-slot=actions]]:h-6 [&>[data-slot=actions]]:min-w-[26px] [&>[data-slot=actions]]:shrink-0 [&>[data-slot=actions]]:items-center [&>[data-slot=actions]]:justify-end [&>[data-slot=actions]]:gap-0.5',
'[&>[data-slot=actions]_[data-slot=icon-button]]:size-6 [&>[data-slot=actions]_[data-slot=icon-button]]:rounded'
],
variants: {
columns: {
two: 'grid-cols-2',
three: 'grid-cols-3',
appearance: 'grid-cols-[minmax(0,7fr)_minmax(0,5fr)]',
'two-rail': 'grid-cols-[minmax(0,1fr)_minmax(0,1fr)_26px]',
fill: 'grid-cols-[minmax(0,1fr)]',
'fill-rail': 'grid-cols-[minmax(0,1fr)_26px]'
1: '[&>[data-slot=fields]]:grid-cols-1',
2: '[&>[data-slot=fields]]:grid-cols-2',
3: '[&>[data-slot=fields]]:grid-cols-3'
},
distribution: {
equal: '',
'wide-first': '[&>[data-slot=fields]]:grid-cols-[minmax(0,7fr)_minmax(0,5fr)]'
}
},
defaultVariants: {
columns: 'two-rail' as const
columns: 1 as const,
distribution: 'equal' as const
}
}

View file

@ -1,9 +1,9 @@
export default {
slots: {
root: 'group flex min-h-6 items-center gap-1.5 py-0.5',
content: 'flex min-w-0 flex-1 items-center gap-1.5',
root: 'group grid min-h-6 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-1.5 gap-y-1.5 py-0.5',
content: 'flex min-w-0 items-center gap-1.5',
rail: 'flex shrink-0 items-center gap-0.5',
remove:
'transition-opacity [@media(hover:hover)]:pointer-events-none [@media(hover:hover)]:opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100'
details: 'col-span-2 min-w-0',
remove: ''
}
} as const

View file

@ -1,3 +0,0 @@
export default {
base: 'flex h-6 w-[26px] shrink-0 items-center justify-end gap-0.5 [&_[data-slot=icon-button]]:size-6 [&_[data-slot=icon-button]]:rounded'
}

View file

@ -89,9 +89,19 @@ test('fill section appears with default fill', async () => {
await expect(fillItems.first()).toBeVisible()
})
test('fill item shows color swatch', async () => {
test('fill item shows color swatch and full hex value', async () => {
const swatch = fillSection().getByTestId('fill-picker-swatch').first()
await expect(swatch).toBeVisible()
const fillItem = propertyItems(editor.page, 'fills').first()
await expect(fillItem.getByRole('button', { name: 'Remove fill' })).toBeVisible()
const hex = fillItem.locator('[data-property="color-hex"]')
const metrics = await hex.evaluate((element) => ({
fits: element.scrollWidth <= element.clientWidth,
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth
}))
expect(metrics.fits, JSON.stringify(metrics)).toBe(true)
})
test('clicking color area changes fill color', async () => {
@ -129,6 +139,9 @@ test('adding a stroke creates stroke section item', async () => {
const strokeItems = propertyItems(editor.page, 'strokes')
await expect(strokeItems.first()).toBeVisible()
await expect(strokeItems.first().getByRole('button', { name: 'Remove stroke' })).toBeVisible()
const hex = strokeItems.first().locator('[data-property="color-hex"]')
expect(await hex.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
const id = await getSelectedId()
const node = await getNode(expectDefined(id, 'selected id'))
@ -148,7 +161,7 @@ test('adding an effect creates effect item', async () => {
expect(expectDefined(node, 'node node').effects.length).toBe(1)
})
test('effect settings expand semantically and row remove reveals on hover', async () => {
test('effect settings expand semantically and row remove stays visible', async () => {
const effectItem = propertyItems(editor.page, 'effects').first()
const expand = effectItem.locator('[data-property="effect-expand"]')
await expect(expand).toHaveAttribute('aria-expanded', 'false')
@ -160,8 +173,6 @@ test('effect settings expand semantically and row remove reveals on hover', asyn
await expect(remove).toHaveCSS('opacity', '1')
await editor.page.getByRole('tab', { name: 'Design' }).focus()
await editor.page.mouse.move(0, 0)
await expect(remove).toHaveCSS('opacity', '0')
await effectItem.hover()
await expect(remove).toHaveCSS('opacity', '1')
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -50,7 +50,8 @@ test('Shift+A wraps selection in auto-layout frame', async () => {
expect(expectDefined(node, 'node').childIds.length).toBe(2)
frameId = expectDefined(node, 'node').id
await expect(propertySection(page, 'Layout')).toHaveScreenshot('layout-size-controls.png')
await expect(propertySection(page, 'Auto layout')).toHaveScreenshot('layout-size-controls.png')
await expect(propertySection(page, 'Layout')).toHaveCount(0)
canvas.assertNoErrors()
})
@ -158,7 +159,7 @@ test('padding controls set horizontal and vertical padding pairs', async () => {
test('size dropdown adds and removes min width', async () => {
await selectFrame()
const layout = propertySection(page, 'Layout')
const layout = propertySection(page, 'Auto layout')
await propertyField(page, 'width').getByRole('combobox', { name: 'Width' }).click()
await page.getByRole('option', { name: 'Add min width' }).click()
await canvas.waitForRender()
@ -264,15 +265,36 @@ test('alignment grid center sets CENTER alignment', async () => {
canvas.assertNoErrors()
})
test('remove auto-layout sets layoutMode to NONE', async () => {
test('Freeform disables auto layout and a flow option restores it', async () => {
await selectFrame()
await propertySection(page, 'Auto layout')
.getByRole('button', { name: 'Remove auto layout' })
.click()
await propertySection(page, 'Auto layout').getByRole('button', { name: 'Freeform' }).click()
await canvas.waitForRender()
const frame = await getNodeById(page, frameId)
let frame = await getNodeById(page, frameId)
expect(expectDefined(frame, 'frame').layoutMode).toBe('NONE')
const layout = propertySection(page, 'Layout')
await expect(layout).toBeVisible()
await expect(layout.getByRole('button', { name: 'Add auto layout' })).toHaveAttribute(
'data-state',
'off'
)
await expect(layout.getByText('Flow', { exact: true })).toBeVisible()
await expect(layout.getByRole('checkbox', { name: 'Clip content' })).toBeVisible()
await expect(layout.getByRole('button', { name: 'Freeform' })).toHaveAttribute('data-state', 'on')
await expect(propertySection(page, 'Auto layout')).toHaveCount(0)
await expect(layout).toHaveScreenshot('layout-freeform-controls.png')
await layout.getByRole('button', { name: 'Vertical layout' }).click()
await canvas.waitForRender()
frame = await getNodeById(page, frameId)
expect(expectDefined(frame, 'frame').layoutMode).toBe('VERTICAL')
const autoLayout = propertySection(page, 'Auto layout')
await expect(autoLayout).toBeVisible()
await expect(autoLayout.getByRole('button', { name: 'Remove auto layout' })).toHaveAttribute(
'data-state',
'on'
)
await expect(propertySection(page, 'Layout')).toHaveCount(0)
canvas.assertNoErrors()
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View file

@ -1,7 +1,7 @@
import { expect, test, type Page } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
import { propertySection } from '#tests/helpers/properties'
import { propertyItems, propertySection } from '#tests/helpers/properties'
let page: Page
let canvas: CanvasHelper
@ -114,7 +114,7 @@ test('applies fill, stroke, effect, and grid styles from local definitions', asy
await chooseStyle('Fill', 'Fill style', 'Brand/Primary')
await chooseStyle('Stroke', 'Stroke style', 'Brand/Primary')
await chooseStyle('Effects', 'Effect style', 'Effects/Card')
await chooseStyle('Layout', 'Grid style', 'Grid/12 columns')
await chooseStyle('Layout guide', 'Grid style', 'Grid/12 columns')
expect(await targetStyles()).toMatchObject({
fillStyleId: '1:100',
@ -128,6 +128,7 @@ test('applies fill, stroke, effect, and grid styles from local definitions', asy
})
test('manual paint edits detach the style and undo restores the reference', async () => {
await propertyItems(page, 'fills').first().hover()
await propertySection(page, 'Fill').getByRole('button', { name: 'Remove fill' }).click()
await canvas.waitForRender()
expect((await targetStyles())?.fillStyleId).toBeNull()