diff --git a/src/app/demo/colors.ts b/src/app/demo/colors.ts index 3237b6ce4..10c91fbb1 100644 --- a/src/app/demo/colors.ts +++ b/src/app/demo/colors.ts @@ -1,31 +1,20 @@ import { BLACK } from '@open-pencil/core/constants' import type { Color, Fill, GradientStop, Stroke } from '@open-pencil/scene-graph' -/** A single restrained product palette — modern, not a rainbow. */ export const DEMO_COLORS = { white: { r: 1, g: 1, b: 1, a: 1 }, black: BLACK, - // surfaces - bg: { r: 0.97, g: 0.97, b: 0.98, a: 1 }, - surface: { r: 1, g: 1, b: 1, a: 1 }, - surfaceSunken: { r: 0.96, g: 0.96, b: 0.98, a: 1 }, - // text - textPrimary: { r: 0.09, g: 0.09, b: 0.11, a: 1 }, - textSecondary: { r: 0.45, g: 0.46, b: 0.5, a: 1 }, - textTertiary: { r: 0.66, g: 0.67, b: 0.71, a: 1 }, - // border - border: { r: 0.9, g: 0.9, b: 0.93, a: 1 }, - borderStrong: { r: 0.84, g: 0.84, b: 0.88, a: 1 }, - // one accent + supporting semantics - accent: { r: 0.36, g: 0.4, b: 0.96, a: 1 }, - accentSoft: { r: 0.93, g: 0.93, b: 1, a: 1 }, - accentText: { r: 0.28, g: 0.32, b: 0.9, a: 1 }, - success: { r: 0.13, g: 0.72, b: 0.42, a: 1 }, - successSoft: { r: 0.9, g: 0.98, b: 0.93, a: 1 }, - danger: { r: 0.92, g: 0.26, b: 0.28, a: 1 }, - dangerSoft: { r: 1, g: 0.93, b: 0.93, a: 1 }, - warning: { r: 0.96, g: 0.62, b: 0.1, a: 1 }, - warningSoft: { r: 1, g: 0.96, b: 0.88, a: 1 } + gray50: { r: 0.98, g: 0.98, b: 0.98, a: 1 }, + gray100: { r: 0.96, g: 0.96, b: 0.97, a: 1 }, + gray200: { r: 0.9, g: 0.9, b: 0.92, a: 1 }, + gray500: { r: 0.55, g: 0.55, b: 0.58, a: 1 }, + blue: { r: 0.23, g: 0.51, b: 0.96, a: 1 }, + indigo: { r: 0.38, g: 0.35, b: 0.95, a: 1 }, + purple: { r: 0.59, g: 0.28, b: 0.96, a: 1 }, + green: { r: 0.13, g: 0.77, b: 0.42, a: 1 }, + orange: { r: 0.96, g: 0.52, b: 0.13, a: 1 }, + red: { r: 0.91, g: 0.22, b: 0.22, a: 1 }, + teal: { r: 0.08, g: 0.73, b: 0.73, a: 1 } } satisfies Record export function solid(color: Color, opacity = 1): Fill { @@ -46,7 +35,3 @@ export function gradient(stops: GradientStop[]): Fill { export function thinStroke(color: Color): Stroke[] { return [{ color, weight: 1, opacity: 1, visible: true, align: 'INSIDE' as const }] } - -export function noStroke(): Stroke[] { - return [] -} diff --git a/src/app/demo/document.ts b/src/app/demo/document.ts index 04e2a92aa..e6954f320 100644 --- a/src/app/demo/document.ts +++ b/src/app/demo/document.ts @@ -1,47 +1,34 @@ import { computeAllLayouts } from '@open-pencil/core/layout' -import { renderJSX } from '@open-pencil/core/design-jsx' -import { DEMO_COLORS, solid } from '@/app/demo/colors' -import { SHOWCASE_JSX } from '@/app/demo/jsx/showcase' import { createAppPreviewSection } from '@/app/demo/sections/app-preview' import { createComponentsSection } from '@/app/demo/sections/components' +import { createEffectsSection } from '@/app/demo/sections/effects' +import { createStandaloneShapes } from '@/app/demo/sections/standalone' import { createDemoVariables } from '@/app/demo/sections/variables' import type { EditorStore } from '@/app/editor/session' -export async function createDemoShapes(store: EditorStore) { +export function createDemoShapes(store: EditorStore) { const { graph } = store - const comps = createComponentsSection(store) - computeAllLayouts(graph) - const app = createAppPreviewSection(store, comps) - await renderJSX(graph, SHOWCASE_JSX) + const { btnCompId, badgeCompId } = createComponentsSection(store) + computeAllLayouts(store.graph) + const { sidebarId, headerId, frameId, headerTitle, chartTitle } = createAppPreviewSection( + store, + btnCompId, + badgeCompId + ) + + createStandaloneShapes(store) createDemoVariables(store) + createEffectsSection(store) - // Theme the screen through variables so editing one re-themes the demo. - graph.bindVariable(app.sidebar, 'fills/0/color', 'var-bg') - graph.bindVariable(app.headerTitle, 'fills/0/color', 'var-text-primary') - graph.bindVariable(app.chartTitle, 'fills/0/color', 'var-text-primary') - - computeAllLayouts(graph) - - // Apply badge tones after layout so component sync doesn't revert them. - const toneColors = { - success: [DEMO_COLORS.successSoft, DEMO_COLORS.success], - accent: [DEMO_COLORS.accentSoft, DEMO_COLORS.accent], - danger: [DEMO_COLORS.dangerSoft, DEMO_COLORS.danger] - } as const - for (const b of app.statBadges) { - const [soft, strong] = toneColors[b.tone] - const badge = graph.getNode(b.id) - if (!badge) continue - // Record the label fill as an override so component sync preserves it. - graph.updateNode(b.labelId, { fills: [solid(strong)] }) - graph.updateNode(b.id, { - fills: [solid(soft)], - overrides: { ...badge.overrides, [`${b.labelId}:fills`]: [solid(strong)] } - }) - } + graph.bindVariable(sidebarId, 'fills/0/color', 'var-bg') + graph.bindVariable(headerId, 'fills/0/color', 'var-bg') + graph.bindVariable(frameId, 'fills/0/color', 'var-bg-secondary') + graph.bindVariable(headerTitle, 'fills/0/color', 'var-text-primary') + graph.bindVariable(chartTitle, 'fills/0/color', 'var-text-primary') + computeAllLayouts(store.graph) store.clearSelection() void store.loadFontsForNodes(graph.getPages().flatMap((page) => page.childIds)).then(() => { store.requestRender() diff --git a/src/app/demo/effects.ts b/src/app/demo/effects.ts new file mode 100644 index 000000000..759a20db4 --- /dev/null +++ b/src/app/demo/effects.ts @@ -0,0 +1,47 @@ +import { TRANSPARENT } from '@open-pencil/core/constants' +import type { Color, Effect } from '@open-pencil/scene-graph' + +export function dropShadow( + ox = 0, + oy = 4, + radius = 8, + spread = 0, + color: Color = { r: 0, g: 0, b: 0, a: 0.25 } +): Effect { + return { + type: 'DROP_SHADOW', + color, + offset: { x: ox, y: oy }, + radius, + spread, + visible: true + } +} + +export function innerShadow( + ox = 0, + oy = 2, + radius = 4, + spread = 0, + color: Color = { r: 0, g: 0, b: 0, a: 0.2 } +): Effect { + return { + type: 'INNER_SHADOW', + color, + offset: { x: ox, y: oy }, + radius, + spread, + visible: true + } +} + +export function blurEffect(type: Effect['type'], radius: number): Effect { + return { + type, + color: TRANSPARENT, + offset: { x: 0, y: 0 }, + radius, + spread: 0, + visible: true + } +} diff --git a/src/app/demo/jsx/showcase.ts b/src/app/demo/jsx/showcase.ts deleted file mode 100644 index bc06fb358..000000000 --- a/src/app/demo/jsx/showcase.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * The showcase page: a music player app authored entirely in JSX. Exercises - * masks, gradients, drop shadows, a glassmorphic player bar, blend-mode - * overlays, corner smoothing, vector icons, outlined buttons, and - * component/instance — all as natural parts of one design. - */ -export const SHOWCASE_JSX = ` -
- - {/* Hero: gradient cover masked into a smooth-rounded card, with a - blend-mode legibility overlay and masked listener avatars. */} - - - - - - - - - - - Midnight Reverie - Aurora Bloom - - - {/* Up-next: album art masked into rounded squares. */} - Up next - - - - - Glass Cities - Nocturne - - - - - - - - - Slow Waves - Tidal Form - - - - - - - - - Ember Days - Solstice - - - - - - - - {/* Player bar: glassmorphism (background blur + translucent fill). */} - - - - - - - - - - -
-` diff --git a/src/app/demo/sections/app-preview.ts b/src/app/demo/sections/app-preview.ts index fef05b555..1d0e561b2 100644 --- a/src/app/demo/sections/app-preview.ts +++ b/src/app/demo/sections/app-preview.ts @@ -1,314 +1,191 @@ -import type { Color } from '@open-pencil/scene-graph' - import { DEMO_COLORS, gradient, solid, thinStroke } from '@/app/demo/colors' import type { EditorStore } from '@/app/editor/session' -interface DemoComponents { - btnPrimaryComp: string - btnSecondaryComp: string - badgeComp: string - avatarComp: string - navItemComp: string - toggleComp: string -} - -type StatTone = 'success' | 'accent' | 'danger' - -const STAT_TONE_COLORS: Record = { - success: { foreground: DEMO_COLORS.success, background: DEMO_COLORS.successSoft }, - accent: { foreground: DEMO_COLORS.accent, background: DEMO_COLORS.accentSoft }, - danger: { foreground: DEMO_COLORS.danger, background: DEMO_COLORS.dangerSoft } -} - -/** - * One cohesive product screen assembled from INSTANCEs of the component - * library, laid out with real auto-layout, and themed via bound variables. - * Resizing the root frame demonstrates live reflow; toggling a variable - * re-themes the whole screen. - */ -export function createAppPreviewSection(store: EditorStore, comps: DemoComponents) { +export function createAppPreviewSection( + store: EditorStore, + btnCompId: string, + badgeCompId: string +) { const { graph } = store - const sectionId = store.createShape('SECTION', 760, 60, 720, 760) - graph.updateNode(sectionId, { - name: 'App — Analytics', - fills: [solid(DEMO_COLORS.bg)] + const appSectionId = store.createShape('SECTION', 1040, 60, 560, 540) + graph.updateNode(appSectionId, { name: 'App Preview' }) + + const frameId = store.createShape('FRAME', 20, 48, 520, 460, appSectionId) + graph.updateNode(frameId, { + name: 'Dashboard', + fills: [solid(DEMO_COLORS.gray50)], + strokes: thinStroke(DEMO_COLORS.gray200), + clipsContent: true }) - // ── Sidebar ─────────────────────────────────────────────────────── - const sidebar = store.createShape('FRAME', 24, 24, 180, 712, sectionId) - graph.updateNode(sidebar, { + const sidebarId = store.createShape('RECTANGLE', 0, 0, 56, 460, frameId) + graph.updateNode(sidebarId, { name: 'Sidebar', - cornerRadius: 16, - fills: [solid(DEMO_COLORS.surface)], - strokes: thinStroke(DEMO_COLORS.border), - layoutMode: 'VERTICAL', - primaryAxisSizing: 'FIXED', - counterAxisSizing: 'FIXED', - itemSpacing: 4, - paddingTop: 16, - paddingBottom: 16, - paddingLeft: 12, - paddingRight: 12 + fills: [solid(DEMO_COLORS.white)], + strokes: thinStroke(DEMO_COLORS.gray200) }) - const logo = store.createShape('FRAME', 0, 0, 156, 32, sidebar) - graph.updateNode(logo, { - name: 'Logo', - fills: [], - layoutMode: 'HORIZONTAL', - counterAxisAlign: 'CENTER', - itemSpacing: 8, - paddingLeft: 8 - }) - const logoMark = store.createShape('ELLIPSE', 0, 0, 18, 18, logo) - graph.updateNode(logoMark, { name: 'Mark', fills: [solid(DEMO_COLORS.accent)] }) - const logoText = store.createShape('TEXT', 0, 0, 90, 18, logo) - graph.updateNode(logoText, { - name: 'Wordmark', - text: 'Pulse', - fontSize: 15, - fontWeight: 700, - textAutoResize: 'WIDTH_AND_HEIGHT', - fills: [solid(DEMO_COLORS.textPrimary)] - }) + for (let i = 0; i < 5; i++) { + const dotId = store.createShape('ELLIPSE', 18, 20 + i * 40, 20, 20, frameId) + graph.updateNode(dotId, { + name: `Nav ${i + 1}`, + fills: [solid(i === 0 ? DEMO_COLORS.blue : DEMO_COLORS.gray200)] + }) + } - const navLabels = ['Overview', 'Analytics', 'Customers', 'Reports', 'Settings'] - navLabels.forEach((label, i) => { - const inst = graph.createInstance(comps.navItemComp, sidebar) - if (inst) { - graph.updateNode(inst.id, { name: `Nav / ${label}` }) - const labelChild = inst.childIds - .map((cid) => graph.getNode(cid)) - .find((n) => n?.type === 'TEXT') - if (labelChild) { - // Set the text and record an override so component sync doesn't revert it. - graph.updateNode(labelChild.id, { text: label }) - graph.updateNode(inst.id, { - overrides: { ...inst.overrides, [`${labelChild.id}:text`]: label } - }) - } - if (i === 0) { - const dotChild = inst.childIds - .map((cid) => graph.getNode(cid)) - .find((n) => n?.type === 'ELLIPSE') - if (dotChild) graph.updateNode(dotChild.id, { fills: [solid(DEMO_COLORS.accent)] }) - } - } - }) - - // ── Main content (auto-layout column) ───────────────────────────── - const main = store.createShape('FRAME', 224, 24, 472, 712, sectionId) - graph.updateNode(main, { - name: 'Content', - fills: [], - layoutMode: 'VERTICAL', - primaryAxisSizing: 'FIXED', - counterAxisSizing: 'FIXED', - itemSpacing: 16 - }) - - // Header - const header = store.createShape('FRAME', 0, 0, 472, 40, main) - graph.updateNode(header, { + const headerId = store.createShape('FRAME', 56, 0, 464, 52, frameId) + graph.updateNode(headerId, { name: 'Header', - fills: [], + fills: [solid(DEMO_COLORS.white)], + strokes: thinStroke(DEMO_COLORS.gray200), layoutMode: 'HORIZONTAL', primaryAxisSizing: 'FIXED', counterAxisSizing: 'FIXED', + counterAxisAlign: 'CENTER', primaryAxisAlign: 'SPACE_BETWEEN', - counterAxisAlign: 'CENTER' + paddingLeft: 20, + paddingRight: 20, + itemSpacing: 8 }) - const headerTitleWrap = store.createShape('FRAME', 0, 0, 200, 40, header) - graph.updateNode(headerTitleWrap, { - name: 'Title', - fills: [], - layoutMode: 'VERTICAL', - itemSpacing: 2 - }) - const headerTitle = store.createShape('TEXT', 0, 0, 140, 22, headerTitleWrap) + const headerTitle = store.createShape('TEXT', 0, 0, 120, 20, headerId) graph.updateNode(headerTitle, { - name: 'Heading', - text: 'Analytics overview', - fontSize: 18, - fontWeight: 700, - textAutoResize: 'WIDTH_AND_HEIGHT', - fills: [solid(DEMO_COLORS.textPrimary)] + name: 'Page Title', + text: 'Dashboard', + fontSize: 16, + fontWeight: 600, + textAutoResize: 'WIDTH_AND_HEIGHT' as const, + fills: [solid(DEMO_COLORS.black)] }) - const headerSub = store.createShape('TEXT', 0, 0, 200, 16, headerTitleWrap) - graph.updateNode(headerSub, { - name: 'Subheading', - text: 'Last 30 days', - fontSize: 12, - fontWeight: 400, - textAutoResize: 'WIDTH_AND_HEIGHT', - fills: [solid(DEMO_COLORS.textSecondary)] - }) - graph.createInstance(comps.avatarComp, header) - graph.createInstance(comps.btnPrimaryComp, header) - // Stat row (auto-layout, fills width) - const statRow = store.createShape('FRAME', 0, 0, 472, 96, main) - graph.updateNode(statRow, { - name: 'Stat Row', - fills: [], - layoutMode: 'HORIZONTAL', - primaryAxisSizing: 'FIXED', - counterAxisSizing: 'FIXED', - itemSpacing: 12 - }) + const badgeInstance = graph.createInstance(badgeCompId, headerId) + if (badgeInstance) graph.updateNode(badgeInstance.id, { x: 264, y: 15 }) + const buttonInstance = graph.createInstance(btnCompId, headerId) + if (buttonInstance) graph.updateNode(buttonInstance.id, { x: 324, y: 6 }) const stats = [ - { title: 'Revenue', value: '$12,480', trend: '+14%', tone: 'success' as const }, - { title: 'Active users', value: '3,842', trend: '+8%', tone: 'accent' as const }, - { title: 'Churn', value: '1.9%', trend: '-3%', tone: 'danger' as const } + { title: 'Revenue', value: '$12,480', badge: '+14%', color: DEMO_COLORS.green }, + { title: 'Users', value: '3,842', badge: '+8%', color: DEMO_COLORS.blue }, + { title: 'Orders', value: '1,249', badge: '-3%', color: DEMO_COLORS.red } ] - const statBadges: StatBadge[] = [] - stats.forEach((s) => { - const card = store.createShape('FRAME', 0, 0, 148, 96, statRow) - graph.updateNode(card, { - name: `Stat / ${s.title}`, - cornerRadius: 12, - fills: [solid(DEMO_COLORS.surface)], - strokes: thinStroke(DEMO_COLORS.border), + for (let i = 0; i < stats.length; i++) { + const s = stats[i] + const cx = 76 + i * 152 + const cId = store.createShape('FRAME', cx, 72, 140, 88, frameId) + graph.updateNode(cId, { + name: s.title, + cornerRadius: 10, + fills: [solid(DEMO_COLORS.white)], + strokes: thinStroke(DEMO_COLORS.gray200), layoutMode: 'VERTICAL', primaryAxisSizing: 'FIXED', - counterAxisSizing: 'FILL', - itemSpacing: 6, + counterAxisSizing: 'FIXED', + itemSpacing: 4, paddingTop: 14, paddingBottom: 14, paddingLeft: 16, paddingRight: 16 }) - const label = store.createShape('TEXT', 0, 0, 100, 14, card) - graph.updateNode(label, { + const labelId = store.createShape('TEXT', 0, 0, 108, 14, cId) + graph.updateNode(labelId, { name: 'Label', text: s.title, - fontSize: 12, + fontSize: 11, fontWeight: 500, textAutoResize: 'HEIGHT', layoutAlignSelf: 'STRETCH', - fills: [solid(DEMO_COLORS.textSecondary)] + fills: [solid(DEMO_COLORS.gray500)] }) - const value = store.createShape('TEXT', 0, 0, 100, 26, card) - graph.updateNode(value, { + const valId = store.createShape('TEXT', 0, 0, 108, 24, cId) + graph.updateNode(valId, { name: 'Value', text: s.value, fontSize: 22, fontWeight: 700, textAutoResize: 'HEIGHT', layoutAlignSelf: 'STRETCH', - fills: [solid(DEMO_COLORS.textPrimary)] + fills: [solid(DEMO_COLORS.black)] }) - const badge = graph.createInstance(comps.badgeComp, card) - if (badge) { - const tone = STAT_TONE_COLORS[s.tone] - const bLabel = badge.childIds.map((cid) => graph.getNode(cid)).find((n) => n?.type === 'TEXT') - if (bLabel) { - const overrides: Record = { ...badge.overrides } - graph.updateNode(bLabel.id, { text: s.trend, fills: [solid(tone.foreground)] }) - overrides[`${bLabel.id}:text`] = s.trend - overrides[`${bLabel.id}:fills`] = [solid(tone.foreground)] - graph.updateNode(badge.id, { fills: [solid(tone.background)], overrides }) - statBadges.push({ id: badge.id, labelId: bLabel.id, tone: s.tone }) - } - } - }) + const bId = store.createShape('TEXT', 0, 0, 108, 14, cId) + graph.updateNode(bId, { + name: 'Badge', + text: s.badge, + fontSize: 11, + fontWeight: 600, + textAutoResize: 'HEIGHT', + layoutAlignSelf: 'STRETCH', + fills: [solid(s.color)] + }) + } - // Chart card - const chart = store.createShape('FRAME', 0, 0, 472, 260, main) - graph.updateNode(chart, { + const chartBg = store.createShape('FRAME', 76, 180, 424, 200, frameId) + graph.updateNode(chartBg, { name: 'Chart', - cornerRadius: 12, - fills: [solid(DEMO_COLORS.surface)], - strokes: thinStroke(DEMO_COLORS.border), - layoutMode: 'VERTICAL', - primaryAxisSizing: 'FIXED', - counterAxisSizing: 'FIXED', - paddingTop: 16, - paddingBottom: 16, - paddingLeft: 16, - paddingRight: 16, - itemSpacing: 12 + cornerRadius: 10, + fills: [solid(DEMO_COLORS.white)], + strokes: thinStroke(DEMO_COLORS.gray200) }) - const chartHeader = store.createShape('FRAME', 0, 0, 440, 20, chart) - graph.updateNode(chartHeader, { - name: 'Chart Header', - fills: [], - layoutMode: 'HORIZONTAL', - primaryAxisAlign: 'SPACE_BETWEEN', - counterAxisAlign: 'CENTER' - }) - const chartTitle = store.createShape('TEXT', 0, 0, 140, 18, chartHeader) + const chartTitle = store.createShape('TEXT', 16, 16, 120, 18, chartBg) graph.updateNode(chartTitle, { - name: 'Title', + name: 'Chart Title', text: 'Revenue over time', fontSize: 13, fontWeight: 600, - textAutoResize: 'WIDTH_AND_HEIGHT', - fills: [solid(DEMO_COLORS.textPrimary)] + fills: [solid(DEMO_COLORS.black)] }) - graph.createInstance(comps.toggleComp, chartHeader) - const bars = store.createShape('FRAME', 0, 0, 440, 190, chart) - graph.updateNode(bars, { - name: 'Bars', - fills: [], - layoutMode: 'HORIZONTAL', - primaryAxisSizing: 'FIXED', - counterAxisSizing: 'FIXED', - primaryAxisAlign: 'SPACE_BETWEEN', - counterAxisAlign: 'MAX', - paddingTop: 8 - }) - const barHeights = [70, 105, 88, 128, 96, 148, 112, 84, 132, 158, 120, 92] - barHeights.forEach((h, i) => { - const bar = store.createShape('RECTANGLE', 0, 0, 22, h, bars) - graph.updateNode(bar, { + const barHeights = [60, 90, 72, 110, 95, 130, 100, 80, 120, 140, 115, 88] + const barW = 24 + const barGap = 10 + const barStartX = 16 + const barBaseY = 180 + + for (let i = 0; i < barHeights.length; i++) { + const h = barHeights[i] + const bx = barStartX + i * (barW + barGap) + const by = barBaseY - h + const barId = store.createShape('RECTANGLE', bx, by, barW, h, chartBg) + graph.updateNode(barId, { name: `Bar ${i + 1}`, - cornerRadius: 5, + cornerRadius: 4, fills: [ gradient([ - { color: DEMO_COLORS.accent, position: 0 }, - { color: { r: 0.55, g: 0.5, b: 0.98, a: 1 }, position: 1 } + { color: DEMO_COLORS.blue, position: 0 }, + { color: DEMO_COLORS.indigo, position: 1 } ]) ] }) - }) + } - // Feature callout - const callout = store.createShape('FRAME', 0, 0, 472, 64, main) - graph.updateNode(callout, { - name: 'Callout', - cornerRadius: 12, - fills: [solid(DEMO_COLORS.accentSoft)], + const tableId = store.createShape('FRAME', 76, 400, 424, 40, frameId) + graph.updateNode(tableId, { + name: 'Table Header', + fills: [solid(DEMO_COLORS.white)], + strokes: thinStroke(DEMO_COLORS.gray200), layoutMode: 'HORIZONTAL', primaryAxisSizing: 'FIXED', - counterAxisSizing: 'FIXED', + counterAxisSizing: 'HUG', + primaryAxisAlign: 'SPACE_BETWEEN', counterAxisAlign: 'CENTER', - itemSpacing: 12, + paddingTop: 10, + paddingBottom: 10, paddingLeft: 16, - paddingRight: 16 - }) - const calloutDot = store.createShape('ELLIPSE', 0, 0, 24, 24, callout) - graph.updateNode(calloutDot, { name: 'Icon', fills: [solid(DEMO_COLORS.accent)] }) - const calloutText = store.createShape('TEXT', 0, 0, 340, 32, callout) - graph.updateNode(calloutText, { - name: 'Text', - text: 'This screen is built from components, auto-layout, and variables. Edit a variable to re-theme it.', - fontSize: 12, - fontWeight: 500, - textAutoResize: 'HEIGHT', - layoutAlignSelf: 'STRETCH', - fills: [solid(DEMO_COLORS.accentText)] + paddingRight: 16, + itemSpacing: 16 }) + const cols = ['Name', 'Status', 'Amount', 'Date'] + for (const col of cols) { + const colId = store.createShape('TEXT', 0, 0, 80, 16, tableId) + graph.updateNode(colId, { + name: col, + text: col, + fontSize: 12, + fontWeight: 600, + textAutoResize: 'WIDTH_AND_HEIGHT', + fills: [solid(DEMO_COLORS.gray500)] + }) + } - return { sectionId, sidebar, main, headerTitle, chartTitle, statBadges } -} - -interface StatBadge { - id: string - labelId: string - tone: StatTone + return { sidebarId, headerId, frameId, headerTitle, chartTitle } } diff --git a/src/app/demo/sections/components.ts b/src/app/demo/sections/components.ts index d0680ff30..0d57eba4e 100644 --- a/src/app/demo/sections/components.ts +++ b/src/app/demo/sections/components.ts @@ -1,191 +1,253 @@ -import { DEMO_COLORS, solid } from '@/app/demo/colors' +import { DEMO_COLORS, gradient, solid, thinStroke } from '@/app/demo/colors' import { makeComponent } from '@/app/demo/helpers' import type { EditorStore } from '@/app/editor/session' -/** - * A small but real component library: Button (primary/secondary), Badge, - * Avatar, Nav item, and Toggle — all as actual COMPONENTs. The app screen is - * then assembled from INSTANCEs so the demo exercises the component → instance - * → override workflow. - */ export function createComponentsSection(store: EditorStore) { const { graph } = store - const sectionId = store.createShape('SECTION', 60, 60, 660, 300) - graph.updateNode(sectionId, { - name: 'Component Library', - fills: [solid(DEMO_COLORS.surface)] + const compSectionId = store.createShape('SECTION', 60, 60, 920, 540) + graph.updateNode(compSectionId, { name: 'Components' }) + + const btnId = store.createShape('FRAME', 32, 76, 120, 40, compSectionId) + graph.updateNode(btnId, { + name: 'Button/Primary', + fills: [], + strokes: [], + clipsContent: false }) - - const title = store.createShape('TEXT', 24, 20, 300, 20, sectionId) - graph.updateNode(title, { - name: 'Title', - text: 'Components — the app below is built from these', - fontSize: 12, - fontWeight: 600, - fills: [solid(DEMO_COLORS.textTertiary)], - textAutoResize: 'WIDTH_AND_HEIGHT' - }) - - function caption(text: string, x: number, y: number) { - const c = store.createShape('TEXT', x, y, 140, 14, sectionId) - graph.updateNode(c, { - name: 'Caption', - text, - fontSize: 10, - fontWeight: 500, - textAutoResize: 'WIDTH_AND_HEIGHT', - fills: [solid(DEMO_COLORS.textTertiary)] - }) - } - - caption('Button / Primary', 24, 52) - caption('Button / Secondary', 156, 52) - caption('Badge', 296, 52) - caption('Avatar', 388, 52) - caption('Nav item', 24, 148) - caption('Toggle', 196, 148) - - // ── Button / Primary ────────────────────────────────────────────── - const btnPrimary = store.createShape('FRAME', 24, 72, 116, 36, sectionId) - graph.updateNode(btnPrimary, { - name: 'Button / Primary', + const btnSurfaceId = store.createShape('FRAME', 0, 0, 120, 40, btnId) + graph.updateNode(btnSurfaceId, { + name: 'Surface', cornerRadius: 8, - fills: [solid(DEMO_COLORS.accent)], + fills: [solid(DEMO_COLORS.blue)], layoutMode: 'HORIZONTAL', primaryAxisSizing: 'HUG', counterAxisSizing: 'HUG', primaryAxisAlign: 'CENTER', counterAxisAlign: 'CENTER', - paddingTop: 8, - paddingBottom: 8, - paddingLeft: 16, - paddingRight: 16 + paddingTop: 10, + paddingBottom: 10, + paddingLeft: 20, + paddingRight: 20 }) - const btnPrimaryLabel = store.createShape('TEXT', 0, 0, 84, 20, btnPrimary) - graph.updateNode(btnPrimaryLabel, { + const btnTextId = store.createShape('TEXT', 0, 0, 80, 20, btnSurfaceId) + graph.updateNode(btnTextId, { name: 'Label', - text: 'Get started', - fontSize: 13, + text: 'Get Started', + fontSize: 14, fontWeight: 600, textAutoResize: 'WIDTH_AND_HEIGHT', fills: [solid(DEMO_COLORS.white)] }) - const btnPrimaryComp = makeComponent(store, [btnPrimary]) + const btnCompId = makeComponent(store, [btnId]) - // ── Button / Secondary ──────────────────────────────────────────── - const btnSecondary = store.createShape('FRAME', 156, 72, 104, 36, sectionId) - graph.updateNode(btnSecondary, { - name: 'Button / Secondary', + const btn2Id = store.createShape('FRAME', 216, 76, 100, 40, compSectionId) + graph.updateNode(btn2Id, { + name: 'Button/Secondary', + fills: [], + strokes: [], + clipsContent: false + }) + const btn2SurfaceId = store.createShape('FRAME', 0, 0, 100, 40, btn2Id) + graph.updateNode(btn2SurfaceId, { + name: 'Surface', cornerRadius: 8, - fills: [solid(DEMO_COLORS.surfaceSunken)], + fills: [solid(DEMO_COLORS.white)], + strokes: thinStroke(DEMO_COLORS.gray200), layoutMode: 'HORIZONTAL', primaryAxisSizing: 'HUG', counterAxisSizing: 'HUG', primaryAxisAlign: 'CENTER', counterAxisAlign: 'CENTER', - paddingTop: 8, - paddingBottom: 8, - paddingLeft: 16, - paddingRight: 16 + paddingTop: 10, + paddingBottom: 10, + paddingLeft: 20, + paddingRight: 20 }) - const btnSecondaryLabel = store.createShape('TEXT', 0, 0, 72, 20, btnSecondary) - graph.updateNode(btnSecondaryLabel, { + const btn2TextId = store.createShape('TEXT', 0, 0, 60, 20, btn2SurfaceId) + graph.updateNode(btn2TextId, { name: 'Label', text: 'Cancel', - fontSize: 13, - fontWeight: 600, + fontSize: 14, + fontWeight: 500, textAutoResize: 'WIDTH_AND_HEIGHT', - fills: [solid(DEMO_COLORS.textPrimary)] + fills: [solid(DEMO_COLORS.black)] }) - const btnSecondaryComp = makeComponent(store, [btnSecondary]) + const btn2CompId = makeComponent(store, [btn2Id]) - // ── Badge / Success ─────────────────────────────────────────────── - const badge = store.createShape('FRAME', 296, 72, 72, 24, sectionId) - graph.updateNode(badge, { - name: 'Badge / Success', - cornerRadius: 12, - fills: [solid(DEMO_COLORS.successSoft)], + store.select([btnCompId, btn2CompId]) + store.createComponentSetFromComponents() + const buttonSetId = [...store.state.selectedIds][0] + graph.updateNode(buttonSetId, { x: 32, y: 44, width: 400, height: 136, fills: [] }) + graph.updateNode(btnCompId, { x: 40, y: 64 }) + graph.updateNode(btn2CompId, { x: 224, y: 64 }) + + const chipId = store.createShape('FRAME', 500, 72, 80, 28, compSectionId) + graph.updateNode(chipId, { + name: 'Tag', + cornerRadius: 14, + fills: [solid({ r: 0.93, g: 0.94, b: 1, a: 1 })], layoutMode: 'HORIZONTAL', primaryAxisSizing: 'HUG', counterAxisSizing: 'HUG', primaryAxisAlign: 'CENTER', counterAxisAlign: 'CENTER', - paddingTop: 2, - paddingBottom: 2, + paddingTop: 4, + paddingBottom: 4, + paddingLeft: 12, + paddingRight: 12 + }) + const chipTextId = store.createShape('TEXT', 0, 0, 56, 16, chipId) + graph.updateNode(chipTextId, { + name: 'Label', + text: 'Design', + fontSize: 12, + fontWeight: 500, + textAutoResize: 'WIDTH_AND_HEIGHT', + fills: [solid(DEMO_COLORS.indigo)] + }) + makeComponent(store, [chipId]) + + const avatarId = store.createShape('ELLIPSE', 640, 68, 40, 40, compSectionId) + graph.updateNode(avatarId, { + name: 'Avatar', + fills: [ + gradient([ + { color: DEMO_COLORS.purple, position: 0 }, + { color: DEMO_COLORS.blue, position: 1 } + ]) + ] + }) + const avatarCompId = makeComponent(store, [avatarId]) + graph.updateNode(avatarCompId, { name: 'Avatar' }) + + const cardId = store.createShape('FRAME', 32, 216, 280, 160, compSectionId) + graph.updateNode(cardId, { + name: 'Card', + cornerRadius: 12, + fills: [solid(DEMO_COLORS.white)], + strokes: thinStroke(DEMO_COLORS.gray200), + layoutMode: 'VERTICAL', + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'FIXED', + itemSpacing: 8, + paddingTop: 20, + paddingBottom: 20, + paddingLeft: 20, + paddingRight: 20 + }) + const cardTitleId = store.createShape('TEXT', 0, 0, 240, 22, cardId) + graph.updateNode(cardTitleId, { + name: 'Title', + text: 'Analytics Overview', + fontSize: 16, + fontWeight: 600, + textAutoResize: 'HEIGHT', + layoutAlignSelf: 'STRETCH', + fills: [solid(DEMO_COLORS.black)] + }) + const cardDescId = store.createShape('TEXT', 0, 0, 240, 36, cardId) + graph.updateNode(cardDescId, { + name: 'Description', + text: 'Track your key metrics and performance indicators in real time.', + fontSize: 13, + fontWeight: 400, + textAutoResize: 'HEIGHT', + layoutAlignSelf: 'STRETCH', + fills: [solid(DEMO_COLORS.gray500)] + }) + const cardBarBg = store.createShape('RECTANGLE', 0, 0, 240, 8, cardId) + graph.updateNode(cardBarBg, { + name: 'Progress BG', + cornerRadius: 4, + fills: [solid(DEMO_COLORS.gray100)] + }) + const cardBar = store.createShape('RECTANGLE', 0, 0, 168, 8, cardId) + graph.updateNode(cardBar, { + name: 'Progress', + cornerRadius: 4, + fills: [ + gradient([ + { color: DEMO_COLORS.blue, position: 0 }, + { color: DEMO_COLORS.teal, position: 1 } + ]) + ] + }) + makeComponent(store, [cardId]) + + const inputId = store.createShape('FRAME', 344, 216, 240, 40, compSectionId) + graph.updateNode(inputId, { + name: 'Input', + cornerRadius: 8, + fills: [solid(DEMO_COLORS.white)], + strokes: thinStroke(DEMO_COLORS.gray200), + layoutMode: 'HORIZONTAL', + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'HUG', + primaryAxisAlign: 'MIN', + counterAxisAlign: 'CENTER', + paddingTop: 10, + paddingBottom: 10, + paddingLeft: 12, + paddingRight: 12 + }) + const inputPlaceholder = store.createShape('TEXT', 0, 0, 200, 18, inputId) + graph.updateNode(inputPlaceholder, { + name: 'Placeholder', + text: 'Search...', + fontSize: 14, + fontWeight: 400, + textAutoResize: 'HEIGHT', + layoutAlignSelf: 'STRETCH', + fills: [solid(DEMO_COLORS.gray500)] + }) + makeComponent(store, [inputId]) + + const badgeId = store.createShape('FRAME', 344, 284, 48, 24, compSectionId) + graph.updateNode(badgeId, { + name: 'Badge', + cornerRadius: 12, + fills: [solid({ r: 0.93, g: 1, b: 0.95, a: 1 })], + layoutMode: 'HORIZONTAL', + primaryAxisSizing: 'HUG', + counterAxisSizing: 'HUG', + primaryAxisAlign: 'CENTER', + counterAxisAlign: 'CENTER', + itemSpacing: 4, + paddingTop: 4, + paddingBottom: 4, paddingLeft: 8, paddingRight: 8 }) - const badgeLabel = store.createShape('TEXT', 0, 0, 56, 16, badge) - graph.updateNode(badgeLabel, { + const badgeDot = store.createShape('ELLIPSE', 0, 0, 6, 6, badgeId) + graph.updateNode(badgeDot, { + name: 'Dot', + fills: [solid(DEMO_COLORS.green)] + }) + const badgeText = store.createShape('TEXT', 0, 0, 28, 14, badgeId) + graph.updateNode(badgeText, { name: 'Label', - text: '+14%', + text: 'Live', fontSize: 11, fontWeight: 600, textAutoResize: 'WIDTH_AND_HEIGHT', - fills: [solid(DEMO_COLORS.success)] + fills: [solid(DEMO_COLORS.green)] }) - const badgeComp = makeComponent(store, [badge]) + const badgeCompId = makeComponent(store, [badgeId]) - // ── Avatar ──────────────────────────────────────────────────────── - const avatar = store.createShape('ELLIPSE', 388, 72, 32, 32, sectionId) - graph.updateNode(avatar, { - name: 'Avatar', - fills: [solid(DEMO_COLORS.accentSoft)] - }) - const avatarComp = makeComponent(store, [avatar]) - - // ── Nav item ────────────────────────────────────────────────────── - const navItem = store.createShape('FRAME', 24, 168, 148, 32, sectionId) - graph.updateNode(navItem, { - name: 'Nav Item', - cornerRadius: 6, - fills: [solid(DEMO_COLORS.surfaceSunken)], - layoutMode: 'HORIZONTAL', - primaryAxisSizing: 'FIXED', - counterAxisSizing: 'FIXED', - counterAxisAlign: 'CENTER', - itemSpacing: 10, - paddingLeft: 10, - paddingRight: 10 - }) - const navDot = store.createShape('ELLIPSE', 0, 0, 14, 14, navItem) - graph.updateNode(navDot, { - name: 'Icon', - fills: [solid(DEMO_COLORS.accent)] - }) - const navLabel = store.createShape('TEXT', 0, 0, 80, 16, navItem) - graph.updateNode(navLabel, { - name: 'Label', - text: 'Overview', - fontSize: 13, - fontWeight: 500, - textAutoResize: 'WIDTH_AND_HEIGHT', - fills: [solid(DEMO_COLORS.textPrimary)] - }) - const navItemComp = makeComponent(store, [navItem]) - - // ── Toggle ──────────────────────────────────────────────────────── - const toggle = store.createShape('FRAME', 196, 172, 36, 20, sectionId) - graph.updateNode(toggle, { - name: 'Toggle', - cornerRadius: 10, - fills: [solid(DEMO_COLORS.accent)] - }) - const toggleKnob = store.createShape('ELLIPSE', 18, 2, 16, 16, toggle) - graph.updateNode(toggleKnob, { - name: 'Knob', - fills: [solid(DEMO_COLORS.white)] - }) - const toggleComp = makeComponent(store, [toggle]) - - return { - sectionId, - btnPrimaryComp, - btnSecondaryComp, - badgeComp, - avatarComp, - navItemComp, - toggleComp + const swatches = [ + { name: 'Blue', color: DEMO_COLORS.blue, x: 32 }, + { name: 'Indigo', color: DEMO_COLORS.indigo, x: 88 }, + { name: 'Purple', color: DEMO_COLORS.purple, x: 144 }, + { name: 'Green', color: DEMO_COLORS.green, x: 200 }, + { name: 'Teal', color: DEMO_COLORS.teal, x: 256 }, + { name: 'Orange', color: DEMO_COLORS.orange, x: 312 }, + { name: 'Red', color: DEMO_COLORS.red, x: 368 } + ] + for (const swatch of swatches) { + const id = store.createShape('ELLIPSE', swatch.x, 460, 44, 44, compSectionId) + graph.updateNode(id, { name: swatch.name, fills: [solid(swatch.color)] }) } + + return { btnCompId, badgeCompId } } diff --git a/src/app/demo/sections/effects.ts b/src/app/demo/sections/effects.ts new file mode 100644 index 000000000..4de4e820c --- /dev/null +++ b/src/app/demo/sections/effects.ts @@ -0,0 +1,429 @@ +import { DEMO_COLORS, gradient, solid, thinStroke } from '@/app/demo/colors' +import { blurEffect, dropShadow, innerShadow } from '@/app/demo/effects' +import type { EditorStore } from '@/app/editor/session' + +export function createEffectsSection(store: EditorStore) { + const { graph } = store + + const effectsSectionId = store.createShape('SECTION', 60, 840, 920, 780) + graph.updateNode(effectsSectionId, { + name: 'Effects', + fills: [solid(DEMO_COLORS.white)] + }) + + const shadowLabel = store.createShape('TEXT', 32, 48, 200, 20, effectsSectionId) + graph.updateNode(shadowLabel, { + name: 'Label', + text: 'Drop Shadow', + fontSize: 13, + fontWeight: 600, + fills: [solid(DEMO_COLORS.gray500)] + }) + + const shadowCard1 = store.createShape('FRAME', 32, 76, 160, 100, effectsSectionId) + graph.updateNode(shadowCard1, { + name: 'Subtle Shadow', + cornerRadius: 12, + fills: [solid(DEMO_COLORS.white)], + effects: [dropShadow(0, 2, 8, 0, { r: 0, g: 0, b: 0, a: 0.08 })] + }) + const s1Text = store.createShape('TEXT', 16, 40, 128, 20, shadowCard1) + graph.updateNode(s1Text, { + name: 'Label', + text: 'Subtle', + fontSize: 13, + fontWeight: 500, + fills: [solid(DEMO_COLORS.gray500)] + }) + + const shadowCard2 = store.createShape('FRAME', 220, 76, 160, 100, effectsSectionId) + graph.updateNode(shadowCard2, { + name: 'Medium Shadow', + cornerRadius: 12, + fills: [solid(DEMO_COLORS.white)], + effects: [dropShadow(0, 4, 16, 0, { r: 0, g: 0, b: 0, a: 0.15 })] + }) + const s2Text = store.createShape('TEXT', 16, 40, 128, 20, shadowCard2) + graph.updateNode(s2Text, { + name: 'Label', + text: 'Medium', + fontSize: 13, + fontWeight: 500, + fills: [solid(DEMO_COLORS.gray500)] + }) + + const shadowCard3 = store.createShape('FRAME', 408, 76, 160, 100, effectsSectionId) + graph.updateNode(shadowCard3, { + name: 'Heavy Shadow', + cornerRadius: 12, + fills: [solid(DEMO_COLORS.white)], + effects: [dropShadow(0, 8, 24, 0, { r: 0, g: 0, b: 0, a: 0.2 })] + }) + const s3Text = store.createShape('TEXT', 16, 40, 128, 20, shadowCard3) + graph.updateNode(s3Text, { + name: 'Label', + text: 'Heavy', + fontSize: 13, + fontWeight: 500, + fills: [solid(DEMO_COLORS.gray500)] + }) + + const shadowCard4 = store.createShape('FRAME', 596, 76, 160, 100, effectsSectionId) + graph.updateNode(shadowCard4, { + name: 'Spread Shadow', + cornerRadius: 12, + fills: [solid(DEMO_COLORS.white)], + effects: [dropShadow(0, 4, 12, 8, { r: 0.23, g: 0.51, b: 0.96, a: 0.3 })] + }) + const s4Text = store.createShape('TEXT', 16, 40, 128, 20, shadowCard4) + graph.updateNode(s4Text, { + name: 'Label', + text: 'With Spread', + fontSize: 13, + fontWeight: 500, + fills: [solid(DEMO_COLORS.blue)] + }) + + const innerLabel = store.createShape('TEXT', 32, 208, 200, 20, effectsSectionId) + graph.updateNode(innerLabel, { + name: 'Label', + text: 'Inner Shadow', + fontSize: 13, + fontWeight: 600, + fills: [solid(DEMO_COLORS.gray500)] + }) + + const innerCard1 = store.createShape('FRAME', 32, 236, 160, 100, effectsSectionId) + graph.updateNode(innerCard1, { + name: 'Inset Light', + cornerRadius: 12, + fills: [solid(DEMO_COLORS.gray100)], + effects: [innerShadow(0, 2, 6, 0, { r: 0, g: 0, b: 0, a: 0.12 })] + }) + const i1Text = store.createShape('TEXT', 16, 40, 128, 20, innerCard1) + graph.updateNode(i1Text, { + name: 'Label', + text: 'Inset', + fontSize: 13, + fontWeight: 500, + fills: [solid(DEMO_COLORS.gray500)] + }) + + const innerCard2 = store.createShape('FRAME', 220, 236, 160, 100, effectsSectionId) + graph.updateNode(innerCard2, { + name: 'Inset with Spread', + cornerRadius: 12, + fills: [solid(DEMO_COLORS.gray100)], + effects: [innerShadow(0, 2, 8, 4, { r: 0, g: 0, b: 0, a: 0.15 })] + }) + const i2Text = store.createShape('TEXT', 16, 40, 128, 20, innerCard2) + graph.updateNode(i2Text, { + name: 'Label', + text: 'Inset + Spread', + fontSize: 13, + fontWeight: 500, + fills: [solid(DEMO_COLORS.gray500)] + }) + + const innerEllipse = store.createShape('ELLIPSE', 408, 236, 100, 100, effectsSectionId) + graph.updateNode(innerEllipse, { + name: 'Inset Circle', + fills: [ + gradient([ + { color: { r: 0.93, g: 0.94, b: 1, a: 1 }, position: 0 }, + { color: { r: 0.85, g: 0.86, b: 0.95, a: 1 }, position: 1 } + ]) + ], + effects: [innerShadow(0, 4, 12, 0, { r: 0.38, g: 0.35, b: 0.95, a: 0.3 })] + }) + + const comboCard = store.createShape('FRAME', 536, 236, 160, 100, effectsSectionId) + graph.updateNode(comboCard, { + name: 'Combined', + cornerRadius: 12, + fills: [solid(DEMO_COLORS.white)], + effects: [ + dropShadow(0, 4, 16, 0, { r: 0, g: 0, b: 0, a: 0.12 }), + innerShadow(0, 1, 2, 0, { r: 0, g: 0, b: 0, a: 0.06 }) + ] + }) + const comboText = store.createShape('TEXT', 16, 40, 128, 20, comboCard) + graph.updateNode(comboText, { + name: 'Label', + text: 'Drop + Inner', + fontSize: 13, + fontWeight: 500, + fills: [solid(DEMO_COLORS.gray500)] + }) + + const textFxLabel = store.createShape('TEXT', 32, 368, 200, 20, effectsSectionId) + graph.updateNode(textFxLabel, { + name: 'Label', + text: 'Text Shadow & Blur', + fontSize: 13, + fontWeight: 600, + fills: [solid(DEMO_COLORS.gray500)] + }) + + const textShadow = store.createShape('TEXT', 32, 400, 200, 36, effectsSectionId) + graph.updateNode(textShadow, { + name: 'Text Shadow', + text: 'Glyph Shadow', + fontSize: 28, + fontWeight: 700, + fills: [solid(DEMO_COLORS.black)], + effects: [dropShadow(2, 2, 4, 0, { r: 0.23, g: 0.51, b: 0.96, a: 0.5 })] + }) + + const textInner = store.createShape('TEXT', 260, 400, 200, 36, effectsSectionId) + graph.updateNode(textInner, { + name: 'Text Inner', + text: 'Inner Glow', + fontSize: 28, + fontWeight: 700, + fills: [solid(DEMO_COLORS.indigo)], + effects: [innerShadow(0, -1, 3, 0, { r: 1, g: 1, b: 1, a: 0.6 })] + }) + + const blurRect = store.createShape('RECTANGLE', 500, 392, 120, 50, effectsSectionId) + graph.updateNode(blurRect, { + name: 'Layer Blur', + cornerRadius: 10, + fills: [ + gradient([ + { color: DEMO_COLORS.purple, position: 0 }, + { color: DEMO_COLORS.blue, position: 1 } + ]) + ], + effects: [blurEffect('LAYER_BLUR', 4)] + }) + + const glassBg = store.createShape('FRAME', 652, 368, 200, 80, effectsSectionId) + graph.updateNode(glassBg, { + name: 'Glass Card', + cornerRadius: 16, + fills: [solid({ r: 1, g: 1, b: 1, a: 0.3 })], + strokes: thinStroke({ r: 1, g: 1, b: 1, a: 0.4 }), + effects: [blurEffect('BACKGROUND_BLUR', 16)] + }) + const glassText = store.createShape('TEXT', 20, 30, 160, 20, glassBg) + graph.updateNode(glassText, { + name: 'Label', + text: 'Glassmorphism', + fontSize: 14, + fontWeight: 600, + fills: [solid(DEMO_COLORS.black)] + }) + + const blendMaskLabel = store.createShape('TEXT', 32, 488, 240, 20, effectsSectionId) + graph.updateNode(blendMaskLabel, { + name: 'Label', + text: 'Blend modes & masks', + fontSize: 13, + fontWeight: 600, + fills: [solid(DEMO_COLORS.gray500)] + }) + + const blendCard = store.createShape('FRAME', 32, 520, 256, 92, effectsSectionId) + graph.updateNode(blendCard, { + name: 'Blend Mode Stack', + cornerRadius: 16, + fills: [solid(DEMO_COLORS.gray50)], + strokes: thinStroke(DEMO_COLORS.gray200) + }) + const blendBase = store.createShape('ELLIPSE', 24, 18, 72, 72, blendCard) + graph.updateNode(blendBase, { + name: 'Multiply Blue', + fills: [solid({ r: 0.16, g: 0.46, b: 1, a: 0.8 })], + blendMode: 'MULTIPLY' + }) + const blendMiddle = store.createShape('ELLIPSE', 70, 18, 72, 72, blendCard) + graph.updateNode(blendMiddle, { + name: 'Multiply Red', + fills: [solid({ r: 1, g: 0.22, b: 0.34, a: 0.8 })], + blendMode: 'MULTIPLY' + }) + const blendTop = store.createShape('ELLIPSE', 116, 18, 72, 72, blendCard) + graph.updateNode(blendTop, { + name: 'Screen Gold', + fills: [solid({ r: 1, g: 0.72, b: 0.16, a: 0.72 })], + blendMode: 'SCREEN' + }) + const blendText = store.createShape('TEXT', 168, 32, 72, 32, blendCard) + graph.updateNode(blendText, { + name: 'Label', + text: 'Multiply\n+ Screen', + fontSize: 12, + fontWeight: 600, + lineHeight: 16, + fills: [solid(DEMO_COLORS.gray500)] + }) + + const fillBlendCard = store.createShape('FRAME', 312, 520, 204, 92, effectsSectionId) + graph.updateNode(fillBlendCard, { + name: 'Fill Blend Card', + cornerRadius: 16, + fills: [ + gradient([ + { color: DEMO_COLORS.purple, position: 0 }, + { color: DEMO_COLORS.blue, position: 1 } + ]), + { ...solid(DEMO_COLORS.orange, 0.7), blendMode: 'OVERLAY' } + ], + effects: [dropShadow(0, 6, 18, 0, { r: 0.23, g: 0.51, b: 0.96, a: 0.2 })] + }) + const fillBlendText = store.createShape('TEXT', 20, 32, 164, 24, fillBlendCard) + graph.updateNode(fillBlendText, { + name: 'Label', + text: 'Overlay fill', + fontSize: 16, + fontWeight: 700, + fills: [solid(DEMO_COLORS.white)] + }) + + const maskCard = store.createShape('FRAME', 540, 520, 300, 92, effectsSectionId) + graph.updateNode(maskCard, { + name: 'Mask Stack Card', + cornerRadius: 16, + fills: [solid(DEMO_COLORS.gray50)], + strokes: thinStroke(DEMO_COLORS.gray200), + clipsContent: true + }) + const maskBg = store.createShape('RECTANGLE', 0, 0, 300, 92, maskCard) + graph.updateNode(maskBg, { + name: 'Backdrop', + fills: [solid({ r: 0.08, g: 0.1, b: 0.18, a: 1 })] + }) + const maskText = store.createShape('TEXT', 104, 26, 172, 36, maskCard) + graph.updateNode(maskText, { + name: 'Label', + text: 'Sibling mask stack\nfrom imported files', + fontSize: 13, + fontWeight: 600, + lineHeight: 17, + fills: [solid(DEMO_COLORS.white)] + }) + const maskShape = store.createShape('ELLIPSE', 22, 16, 60, 60, maskCard) + graph.updateNode(maskShape, { + name: 'Avatar Mask', + fills: [solid(DEMO_COLORS.white)], + isMask: true, + maskType: 'ALPHA' + }) + const stripeA = store.createShape('RECTANGLE', 14, 12, 84, 28, maskCard) + graph.updateNode(stripeA, { + name: 'Masked Stripe A', + rotation: -20, + fills: [solid(DEMO_COLORS.blue)] + }) + const stripeB = store.createShape('RECTANGLE', 14, 36, 84, 28, maskCard) + graph.updateNode(stripeB, { + name: 'Masked Stripe B', + rotation: -20, + fills: [solid(DEMO_COLORS.purple)] + }) + const stripeC = store.createShape('RECTANGLE', 14, 60, 84, 28, maskCard) + graph.updateNode(stripeC, { + name: 'Masked Stripe C', + rotation: -20, + fills: [solid(DEMO_COLORS.teal)] + }) + + const smoothingLabel = store.createShape('TEXT', 32, 644, 280, 20, effectsSectionId) + graph.updateNode(smoothingLabel, { + name: 'Label', + text: 'Corner smoothing & effect blends', + fontSize: 13, + fontWeight: 600, + fills: [solid(DEMO_COLORS.gray500)] + }) + + const smoothingCard = store.createShape('FRAME', 32, 676, 256, 104, effectsSectionId) + graph.updateNode(smoothingCard, { + name: 'Smooth Corner Comparison', + cornerRadius: 16, + fills: [solid(DEMO_COLORS.gray50)], + strokes: thinStroke(DEMO_COLORS.gray200) + }) + const regularCorner = store.createShape('RECTANGLE', 24, 18, 58, 58, smoothingCard) + graph.updateNode(regularCorner, { + name: 'Regular Radius', + cornerRadius: 18, + fills: [solid(DEMO_COLORS.blue)] + }) + const smoothCorner = store.createShape('RECTANGLE', 102, 18, 58, 58, smoothingCard) + graph.updateNode(smoothCorner, { + name: 'Smoothed Radius', + cornerRadius: 18, + cornerSmoothing: 0.9, + fills: [solid(DEMO_COLORS.purple)] + }) + const cornerLabel = store.createShape('TEXT', 176, 26, 56, 36, smoothingCard) + graph.updateNode(cornerLabel, { + name: 'Label', + text: 'Radius\n+ smooth', + fontSize: 12, + fontWeight: 600, + lineHeight: 16, + fills: [solid(DEMO_COLORS.gray500)] + }) + + const independentCard = store.createShape('FRAME', 312, 676, 204, 104, effectsSectionId) + graph.updateNode(independentCard, { + name: 'Independent Smooth Corners', + cornerRadius: 28, + cornerSmoothing: 1, + independentCorners: true, + topLeftRadius: 36, + topRightRadius: 10, + bottomRightRadius: 36, + bottomLeftRadius: 10, + fills: [ + gradient([ + { color: DEMO_COLORS.teal, position: 0 }, + { color: DEMO_COLORS.blue, position: 1 } + ]) + ], + effects: [dropShadow(0, 8, 20, 0, { r: 0.08, g: 0.73, b: 0.73, a: 0.24 })] + }) + const independentText = store.createShape('TEXT', 20, 34, 164, 24, independentCard) + graph.updateNode(independentText, { + name: 'Label', + text: 'Independent corners', + fontSize: 15, + fontWeight: 700, + fills: [solid(DEMO_COLORS.white)] + }) + + const effectBlendCard = store.createShape('FRAME', 540, 676, 300, 104, effectsSectionId) + graph.updateNode(effectBlendCard, { + name: 'Effect Blend Card', + cornerRadius: 18, + fills: [solid({ r: 0.08, g: 0.1, b: 0.18, a: 1 })], + clipsContent: true + }) + const glowBase = store.createShape('ELLIPSE', 22, 14, 76, 76, effectBlendCard) + graph.updateNode(glowBase, { + name: 'Glow Base', + fills: [solid({ r: 0.24, g: 0.46, b: 1, a: 1 })] + }) + const glowShape = store.createShape('RECTANGLE', 70, 22, 96, 60, effectBlendCard) + graph.updateNode(glowShape, { + name: 'Screen Shadow Blend', + cornerRadius: 22, + cornerSmoothing: 0.85, + fills: [solid({ r: 0.58, g: 0.27, b: 0.95, a: 0.7 })], + effects: [ + { ...dropShadow(0, 0, 28, 0, { r: 0.56, g: 0.33, b: 1, a: 0.72 }), blendMode: 'SCREEN' } + ] + }) + const effectBlendText = store.createShape('TEXT', 184, 34, 88, 34, effectBlendCard) + graph.updateNode(effectBlendText, { + name: 'Label', + text: 'Screen\nshadow', + fontSize: 13, + fontWeight: 700, + lineHeight: 17, + fills: [solid(DEMO_COLORS.white)] + }) +} diff --git a/src/app/demo/sections/standalone.ts b/src/app/demo/sections/standalone.ts new file mode 100644 index 000000000..256b8fd8e --- /dev/null +++ b/src/app/demo/sections/standalone.ts @@ -0,0 +1,173 @@ +import { DEMO_COLORS, gradient, solid, thinStroke } from '@/app/demo/colors' +import type { EditorStore } from '@/app/editor/session' + +export function createStandaloneShapes(store: EditorStore) { + const { graph } = store + + const grad1 = store.createShape('FRAME', 60, 660, 180, 120) + graph.updateNode(grad1, { + name: 'Gradient Card', + cornerRadius: 16, + fills: [ + gradient([ + { color: { r: 0.99, g: 0.37, b: 0.33, a: 1 }, position: 0 }, + { color: { r: 0.93, g: 0.18, b: 0.65, a: 1 }, position: 0.5 }, + { color: { r: 0.55, g: 0.28, b: 0.96, a: 1 }, position: 1 } + ]) + ] + }) + const gradText = store.createShape('TEXT', 20, 80, 140, 20, grad1) + graph.updateNode(gradText, { + name: 'Label', + text: 'Linear Gradient', + fontSize: 14, + fontWeight: 600, + fills: [solid(DEMO_COLORS.white)] + }) + + const grad2 = store.createShape('FRAME', 260, 660, 180, 120) + graph.updateNode(grad2, { + name: 'Ocean', + cornerRadius: 16, + fills: [ + gradient([ + { color: { r: 0.04, g: 0.82, b: 0.67, a: 1 }, position: 0 }, + { color: { r: 0.13, g: 0.45, b: 0.96, a: 1 }, position: 1 } + ]) + ] + }) + const grad2Text = store.createShape('TEXT', 20, 80, 140, 20, grad2) + graph.updateNode(grad2Text, { + name: 'Label', + text: 'Ocean Breeze', + fontSize: 14, + fontWeight: 600, + fills: [solid(DEMO_COLORS.white)] + }) + + const grad3 = store.createShape('FRAME', 460, 660, 180, 120) + graph.updateNode(grad3, { + name: 'Sunset', + cornerRadius: 16, + fills: [ + gradient([ + { color: { r: 1, g: 0.6, b: 0.2, a: 1 }, position: 0 }, + { color: { r: 0.96, g: 0.26, b: 0.21, a: 1 }, position: 1 } + ]) + ] + }) + const grad3Text = store.createShape('TEXT', 20, 80, 140, 20, grad3) + graph.updateNode(grad3Text, { + name: 'Label', + text: 'Warm Sunset', + fontSize: 14, + fontWeight: 600, + fills: [solid(DEMO_COLORS.white)] + }) + + const typoFrame = store.createShape('FRAME', 700, 660, 320, 210) + graph.updateNode(typoFrame, { + name: 'Typography, OpenType & Decorations', + cornerRadius: 12, + fills: [solid(DEMO_COLORS.white)], + strokes: thinStroke(DEMO_COLORS.gray200), + layoutMode: 'VERTICAL', + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'FIXED', + itemSpacing: 6, + paddingTop: 16, + paddingBottom: 16, + paddingLeft: 20, + paddingRight: 20 + }) + const typoItems = [ + { text: 'Heading', size: 24, weight: 700 }, + { text: 'Subheading', size: 16, weight: 600 }, + { text: 'Body text — The quick brown fox jumps.', size: 13, weight: 400 }, + { + text: 'Ligatures off — office affine', + size: 13, + weight: 500, + features: [{ tag: 'LIGA', enabled: false }] + }, + { + text: 'Raw OT tags — DLIG on · KERN off', + size: 11, + weight: 500, + features: [ + { tag: 'DLIG', enabled: true }, + { tag: 'KERN', enabled: false } + ] + }, + { + text: 'Wavy underline from imported .fig', + size: 13, + weight: 500, + decoration: 'UNDERLINE' as const, + decorationStyle: 'WAVY' as const, + decorationFills: [solid(DEMO_COLORS.red)], + decorationThickness: 1.6 + }, + { + text: 'Dotted underline + custom thickness', + size: 13, + weight: 500, + decoration: 'UNDERLINE' as const, + decorationStyle: 'DOTTED' as const, + decorationFills: [solid(DEMO_COLORS.blue)], + decorationThickness: 2 + } + ] + for (const t of typoItems) { + const tid = store.createShape('TEXT', 0, 0, 280, t.size + 4, typoFrame) + graph.updateNode(tid, { + name: t.text.split(' ')[0], + text: t.text, + fontSize: t.size, + fontWeight: t.weight, + fontFeatures: t.features ?? [], + textDecoration: t.decoration ?? 'NONE', + textDecorationStyle: t.decorationStyle ?? 'SOLID', + textDecorationFills: t.decorationFills ?? [], + textDecorationThickness: t.decorationThickness ?? null, + textAutoResize: 'HEIGHT', + layoutAlignSelf: 'STRETCH', + fills: [solid(DEMO_COLORS.black)] + }) + } + + const shapes = [ + { + type: 'ELLIPSE' as const, + x: 1060, + fill: gradient([ + { color: DEMO_COLORS.purple, position: 0 }, + { color: DEMO_COLORS.blue, position: 1 } + ]) + }, + { + type: 'RECTANGLE' as const, + x: 1160, + fill: gradient([ + { color: DEMO_COLORS.green, position: 0 }, + { color: DEMO_COLORS.teal, position: 1 } + ]) + }, + { + type: 'ELLIPSE' as const, + x: 1260, + fill: gradient([ + { color: DEMO_COLORS.orange, position: 0 }, + { color: DEMO_COLORS.red, position: 1 } + ]) + } + ] + for (const s of shapes) { + const id = store.createShape(s.type, s.x, 680, 80, 80) + graph.updateNode(id, { + name: s.type === 'ELLIPSE' ? 'Circle' : 'Square', + cornerRadius: s.type === 'RECTANGLE' ? 16 : 0, + fills: [s.fill] + }) + } +} diff --git a/src/app/demo/sections/variables.ts b/src/app/demo/sections/variables.ts index 819ca9fe0..47806dcd8 100644 --- a/src/app/demo/sections/variables.ts +++ b/src/app/demo/sections/variables.ts @@ -33,40 +33,40 @@ export function createDemoVariables(store: EditorStore) { { id: 'var-gray-50', name: 'Gray/50', - light: DEMO_COLORS.bg, + light: DEMO_COLORS.gray50, dark: { r: 0.1, g: 0.1, b: 0.11, a: 1 } }, { id: 'var-gray-100', name: 'Gray/100', - light: DEMO_COLORS.surfaceSunken, + light: DEMO_COLORS.gray100, dark: { r: 0.14, g: 0.14, b: 0.16, a: 1 } }, { id: 'var-gray-200', name: 'Gray/200', - light: DEMO_COLORS.border, + light: DEMO_COLORS.gray200, dark: { r: 0.22, g: 0.22, b: 0.24, a: 1 } }, { id: 'var-gray-500', name: 'Gray/500', - light: DEMO_COLORS.textSecondary, + light: DEMO_COLORS.gray500, dark: { r: 0.65, g: 0.65, b: 0.68, a: 1 } }, { id: 'var-blue', name: 'Blue', - light: DEMO_COLORS.accent, + light: DEMO_COLORS.blue, dark: { r: 0.33, g: 0.61, b: 1, a: 1 } }, { id: 'var-green', name: 'Green', - light: DEMO_COLORS.success, + light: DEMO_COLORS.green, dark: { r: 0.23, g: 0.87, b: 0.52, a: 1 } }, - { id: 'var-red', name: 'Red', light: DEMO_COLORS.danger, dark: { r: 1, g: 0.32, b: 0.32, a: 1 } } + { id: 'var-red', name: 'Red', light: DEMO_COLORS.red, dark: { r: 1, g: 0.32, b: 0.32, a: 1 } } ] for (const c of primitiveColors) { diff --git a/tests/engine/app/demo.test.ts b/tests/engine/app/demo.test.ts index 2fd59c72c..c4d4e1478 100644 --- a/tests/engine/app/demo.test.ts +++ b/tests/engine/app/demo.test.ts @@ -1,20 +1,36 @@ import { describe, expect, test } from 'bun:test' -import { createDemoShapes } from '@/app/demo/document' +import { createStandaloneShapes } from '@/app/demo/sections/standalone' import { createEditorStore } from '@/app/editor/session' describe('demo document', () => { - test('builds the component library and analytics showcase', () => { + test('showcases imported OpenType and text decoration features', () => { const store = createEditorStore() - createDemoShapes(store) + createStandaloneShapes(store) const nodes = [...store.graph.getAllNodes()] - expect(nodes.some((node) => node.name === 'Component Library')).toBe(true) - expect(nodes.some((node) => node.name === 'App — Analytics')).toBe(true) - expect(nodes.some((node) => node.name === 'Chart')).toBe(true) - expect(nodes.filter((node) => node.type === 'COMPONENT').length).toBeGreaterThanOrEqual(6) - expect(nodes.filter((node) => node.type === 'INSTANCE').length).toBeGreaterThan(6) - expect(store.graph.variables.size).toBeGreaterThan(0) + const ligatures = nodes.find((node) => node.name === 'Ligatures') + const rawTags = nodes.find((node) => node.name === 'Raw') + const wavy = nodes.find((node) => node.name === 'Wavy') + const dotted = nodes.find((node) => node.name === 'Dotted') + + expect(ligatures?.fontFeatures).toEqual([{ tag: 'LIGA', enabled: false }]) + expect(rawTags?.fontFeatures).toEqual([ + { tag: 'DLIG', enabled: true }, + { tag: 'KERN', enabled: false } + ]) + expect(wavy).toMatchObject({ + textDecoration: 'UNDERLINE', + textDecorationStyle: 'WAVY', + textDecorationThickness: 1.6 + }) + expect(wavy?.textDecorationFills[0]?.type).toBe('SOLID') + expect(dotted).toMatchObject({ + textDecoration: 'UNDERLINE', + textDecorationStyle: 'DOTTED', + textDecorationThickness: 2 + }) + expect(dotted?.textDecorationFills[0]?.type).toBe('SOLID') }) })