Skip to content

Commit 04a5ef0

Browse files
authored
fix(canvas): polish playground authoring affordances
1 parent 511053d commit 04a5ef0

18 files changed

Lines changed: 178 additions & 20 deletions

playground/src/routes/CanvasRoute.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useCallback, useEffect, useState } from 'react'
2-
import { DesignCanvasEditor } from '@tangle-network/agent-app/design-canvas-react'
2+
import { CanvasInsertPanel, DesignCanvasEditor } from '@tangle-network/agent-app/design-canvas-react'
33
import { applySceneOperations } from '@tangle-network/agent-app/design-canvas'
44
import type { SceneOperation } from '@tangle-network/agent-app/design-canvas'
55
import { lightTheme, darkTheme } from '@tangle-network/agent-app/theme'
@@ -60,6 +60,18 @@ export function CanvasRoute() {
6060
rev={rev}
6161
canWrite
6262
onApplyOperations={onApplyOperations}
63+
renderSidePanel={({ activePage }) => (
64+
<CanvasInsertPanel
65+
canWrite
66+
page={{
67+
pageId: activePage.id,
68+
width: activePage.width,
69+
height: activePage.height,
70+
background: activePage.background,
71+
}}
72+
onInsert={onApplyOperations}
73+
/>
74+
)}
6375
render={isDark ? darkTheme.canvasRender : lightTheme.canvasRender}
6476
/>
6577
</div>

src/design-canvas-react/components/CanvasInsertPanel.tsx

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
*
77
* Endpoints are per-app, so every I/O path is a callback — no product route is
88
* hardcoded:
9-
* - `onUploadImage(file) => Promise<url>` — the host stores the file and returns
10-
* the src to insert. The url MUST be http(s) or a rooted `/api/` path
11-
* (enforced by `assertSceneMediaSrc` before insertion); a `data:` url is
12-
* rejected, matching the scene model's media boundary.
9+
* - `onUploadImage(file) => Promise<url>` — optional. When provided, the host
10+
* stores the file and returns the src to insert. The url MUST be http(s) or
11+
* a rooted `/api/` path (enforced by `assertSceneMediaSrc` before insertion);
12+
* a `data:` url is rejected, matching the scene model's media boundary.
1313
* - `loadGenerations?()` — optional provider for "already generated in this
1414
* workspace" images; omit to hide the tab.
1515
* - `templates?` — optional template set; defaults to {@link DEFAULT_INSERT_TEMPLATES}.
@@ -50,7 +50,7 @@ export interface CanvasInsertPanelProps {
5050
/** Submit operations through the host's apply pipeline. */
5151
onInsert(operations: SceneOperation[]): Promise<unknown>
5252
/** Store an uploaded file and return its src (http(s) or rooted `/api/`). */
53-
onUploadImage(file: File): Promise<string>
53+
onUploadImage?(file: File): Promise<string>
5454
/** Optional provider for the Generations tab; omit to hide it. */
5555
loadGenerations?(): Promise<InsertGeneration[]>
5656
/** Drop-in templates; defaults to the built-in starter set. */
@@ -181,14 +181,26 @@ export function CanvasInsertPanel({
181181
accept = DEFAULT_ACCEPT,
182182
className,
183183
}: CanvasInsertPanelProps) {
184-
const [tab, setTab] = useState<Tab>('uploads')
184+
const [tab, setTab] = useState<Tab>(() => (templates.length > 0 ? 'templates' : 'uploads'))
185185
const [busy, setBusy] = useState(false)
186186
const [error, setError] = useState('')
187187
const [dragOver, setDragOver] = useState(false)
188188
const [generations, setGenerations] = useState<InsertGeneration[]>([])
189189
const [generationsLoaded, setGenerationsLoaded] = useState(false)
190190
const fileInputRef = useRef<HTMLInputElement | null>(null)
191191

192+
useEffect(() => {
193+
if (tab === 'uploads' && !onUploadImage) {
194+
setTab(templates.length > 0 ? 'templates' : loadGenerations ? 'generations' : 'uploads')
195+
}
196+
if (tab === 'templates' && templates.length === 0) {
197+
setTab(onUploadImage ? 'uploads' : loadGenerations ? 'generations' : 'uploads')
198+
}
199+
if (tab === 'generations' && !loadGenerations) {
200+
setTab(templates.length > 0 ? 'templates' : onUploadImage ? 'uploads' : 'templates')
201+
}
202+
}, [tab, templates.length, onUploadImage, loadGenerations])
203+
192204
useEffect(() => {
193205
if (tab !== 'generations' || generationsLoaded || !loadGenerations) return
194206
let cancelled = false
@@ -217,6 +229,7 @@ export function CanvasInsertPanel({
217229

218230
async function handleFiles(files: FileList | File[]) {
219231
if (!canWrite || busy) return
232+
if (!onUploadImage) return
220233
const list = Array.from(files).filter((f) => f.type.startsWith('image/'))
221234
if (list.length === 0) {
222235
setError('Only image files can be added to the canvas')
@@ -250,7 +263,7 @@ export function CanvasInsertPanel({
250263
}
251264

252265
const tabs: Array<{ id: Tab; label: string; icon: (p: { className?: string }) => ReactElement; show: boolean }> = [
253-
{ id: 'uploads', label: 'Uploads', icon: ImageGlyph, show: true },
266+
{ id: 'uploads', label: 'Uploads', icon: ImageGlyph, show: !!onUploadImage },
254267
{ id: 'templates', label: 'Templates', icon: ShapesGlyph, show: templates.length > 0 },
255268
{ id: 'generations', label: 'Generations', icon: SparkleGlyph, show: !!loadGenerations },
256269
]

src/design-canvas-react/components/DesignCanvas.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -542,7 +542,7 @@ export function DesignCanvas({
542542
surfaces regardless of what the integrator passes. */}
543543
{renderSidePanel && !review ? (
544544
<aside className="hidden w-64 shrink-0 flex-col overflow-hidden border-r border-[var(--border-default)] lg:flex">
545-
{renderSidePanel()}
545+
{renderSidePanel({ selectedElements, activePageId: activePage.id, activePage })}
546546
</aside>
547547
) : null}
548548

src/design-canvas-react/components/InlineTextEditor.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export function InlineTextEditor({
8181
defaultValue={element.text}
8282
onKeyDown={handleKeyDown}
8383
onBlur={handleBlur}
84+
className="agent-app-edit-selection"
8485
style={{
8586
position: 'absolute',
8687
left: pos.left,

src/design-canvas-react/components/PagesStrip.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export function PagesStrip({
102102
</div>
103103
) : null}
104104
<div
105-
className="flex h-[84px] items-center gap-2 overflow-x-auto px-2 pb-1"
105+
className="flex h-[92px] items-center gap-2 overflow-x-auto px-2 pb-2 pt-2"
106106
aria-label="Pages"
107107
>
108108
{pages.map((page, index) => {
@@ -160,7 +160,7 @@ export function PagesStrip({
160160
<img
161161
src={thumbUrl}
162162
alt={page.name}
163-
className="h-full w-full object-cover"
163+
className="h-full w-full object-contain"
164164
draggable={false}
165165
/>
166166
) : (
@@ -178,7 +178,7 @@ export function PagesStrip({
178178

179179
{/* Per-page action buttons — visible on hover or when active */}
180180
{canWrite && canManagePages ? (
181-
<div className="pointer-events-none absolute -top-1 right-0 flex gap-0.5 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
181+
<div className="pointer-events-none absolute right-1 top-1 flex gap-0.5 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
182182
<button
183183
type="button"
184184
aria-label={`Duplicate page ${page.name}`}

src/design-canvas-react/components/Toolbar.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,7 +1009,7 @@ function BleedControls({ page, canWrite, onSetPageProps, enableBleedLabel = 'Sho
10091009
{(['top', 'right', 'bottom', 'left'] as const).map((side) => (
10101010
<NumberInput
10111011
key={side}
1012-
label={`Bleed ${side[0]!.toUpperCase()}`}
1012+
label={side[0]!.toUpperCase() + side.slice(1)}
10131013
value={bleed[side]}
10141014
min={0}
10151015
onCommit={(v) => setBleedSide(side, v)}
@@ -1021,7 +1021,8 @@ function BleedControls({ page, canWrite, onSetPageProps, enableBleedLabel = 'Sho
10211021
disabled={!canWrite}
10221022
onClick={() => onSetPageProps({ bleed: null })}
10231023
className={BTN}
1024-
title="Remove bleed"
1024+
title="Remove print bleed"
1025+
aria-label="Remove print bleed"
10251026
>
10261027
×
10271028
</button>

src/design-canvas-react/components/Workspace.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -550,12 +550,20 @@ export function WorkspaceView({
550550
if (!canWrite) return
551551
const template = DEFAULT_INSERT_TEMPLATES[0]
552552
if (!template) return
553-
const ops = template.build({ pageId: activePageId, width: activePage.width, height: activePage.height })
553+
const ops = template.build({
554+
pageId: activePageId,
555+
width: activePage.width,
556+
height: activePage.height,
557+
background: activePage.background,
558+
})
559+
const addedIds: string[] = []
554560
for (const op of ops) {
555561
if (op.type === 'add_element') {
556562
await persist(addElementCommand({ pageId: op.pageId, element: op.element }))
563+
addedIds.push(op.element.id)
557564
}
558565
}
566+
if (addedIds.length > 0) stack.setView({ selectedElementIds: addedIds })
559567
}
560568

561569
async function handleAddElement() {

src/design-canvas-react/contracts.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
* never per-pixel.
1212
*/
1313

14-
import type { Bounds, SceneDocument, SceneElement } from '../design-canvas/model'
14+
import type { Bounds, SceneDocument, SceneElement, ScenePage } from '../design-canvas/model'
1515
import type { SceneOperation } from '../design-canvas/operations'
1616
import type { CanvasRenderPalette } from '../theme/theme'
1717

@@ -187,7 +187,7 @@ export interface DesignCanvasProps {
187187
onSelectionChange?(elements: SceneElement[]): void
188188
/** Host panels: agent chat (right), asset/template browser (left). */
189189
renderAgentPanel?(ctx: { selectedElements: SceneElement[]; activePageId: string }): React.ReactNode
190-
renderSidePanel?(): React.ReactNode
190+
renderSidePanel?(ctx: { selectedElements: SceneElement[]; activePageId: string; activePage: ScenePage }): React.ReactNode
191191
/** Export hook — host persists the rendered blob (upload → asset row). */
192192
onExport?(result: { pageId: string; format: 'png' | 'jpeg'; dataUrl: string; pixelRatio: number }): Promise<void>
193193
/**

src/design-canvas-react/insert-builders.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export interface InsertPageGeometry {
3131
pageId: string
3232
width: number
3333
height: number
34+
background?: string
3435
}
3536

3637
/** Mint a DOM-safe element id. Prefers `crypto.randomUUID`; falls back to a
@@ -81,6 +82,31 @@ function addElementOp(pageId: string, element: SceneElement): SceneOperation {
8182
return { type: 'add_element', pageId, element }
8283
}
8384

85+
function hexLuminance(color: string | undefined): number | null {
86+
if (!color) return null
87+
const raw = color.trim()
88+
const hex = raw.startsWith('#') ? raw.slice(1) : ''
89+
const full = hex.length === 3
90+
? hex.split('').map((ch) => ch + ch).join('')
91+
: hex.length === 6
92+
? hex
93+
: ''
94+
if (!/^[0-9a-fA-F]{6}$/.test(full)) return null
95+
const channels = [0, 2, 4].map((start) => parseInt(full.slice(start, start + 2), 16) / 255)
96+
const [r, g, b] = channels.map((value) => (
97+
value <= 0.03928 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4)
98+
))
99+
return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!
100+
}
101+
102+
function pageTextFill(page: InsertPageGeometry, tone: 'primary' | 'secondary'): string {
103+
const luminance = hexLuminance(page.background)
104+
if (luminance !== null && luminance < 0.35) {
105+
return tone === 'primary' ? '#f8fafc' : '#cbd5e1'
106+
}
107+
return tone === 'primary' ? '#111827' : '#374151'
108+
}
109+
84110
/**
85111
* Build an `add_element` op placing an image, fitted and centered on the page.
86112
* `naturalSize` is the probed image dimensions (the panel reads them in the
@@ -134,7 +160,7 @@ export const DEFAULT_INSERT_TEMPLATES: readonly InsertTemplate[] = [
134160
fontFamily: 'Inter',
135161
fontSize: 48,
136162
fontStyle: 'bold',
137-
fill: '#111827',
163+
fill: pageTextFill(page, 'primary'),
138164
align: 'left',
139165
lineHeight: 1.1,
140166
letterSpacing: 0,
@@ -156,7 +182,7 @@ export const DEFAULT_INSERT_TEMPLATES: readonly InsertTemplate[] = [
156182
fontFamily: 'Inter',
157183
fontSize: 20,
158184
fontStyle: 'normal',
159-
fill: '#374151',
185+
fill: pageTextFill(page, 'secondary'),
160186
align: 'left',
161187
lineHeight: 1.4,
162188
letterSpacing: 0,

src/sequences-react/components/TimelineClipChip.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,7 @@ export function TimelineClipChip(props: TimelineClipChipProps) {
510510
event.stopPropagation()
511511
}}
512512
onBlur={commitText}
513-
className="absolute inset-0 z-10 w-full bg-black/80 px-1.5 text-[11px] text-[var(--text-primary)] outline-none ring-1 ring-[var(--brand-primary)]"
513+
className="agent-app-edit-selection absolute inset-0 z-10 w-full bg-black/80 px-1.5 text-[11px] text-[var(--text-primary)] outline-none ring-1 ring-[var(--brand-primary)]"
514514
aria-label="Caption text"
515515
/>
516516
) : null}

0 commit comments

Comments
 (0)