Files
pansy/docs/design_handoff_pansy_ui/Pansy Editor.dc.html

902 lines
70 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<link rel="stylesheet" href="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/styles.css">
<script src="pansy-theme.js"></script>
<script src="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/_ds_bundle.js"></script>
<style>
:root{--p-field:#efe3c9;--p-grid-ink:#201e1d;--p-ink-strong:#474238;--p-ink-soft:#645c50;--p-ink-mute:#82796a;--p-bed-fill:#e3d0ac;--p-bed-stroke:#b5a37f;--p-ing-fill:#d6bf98;--p-ing-stroke:#b1a07c;--p-path-fill:#ece1cb;--p-path-stroke:#cabfa6;--p-bag-fill:#d8c5a2;--p-bag-stroke:#a99677;--p-bkt-fill:#cfc3ad;--p-bkt-stroke:#9a8d76;--p-tree-fill:#dce9c6;--p-tree-stroke:#8fa073;--p-str-fill:#d9cfbc;--p-str-stroke:#a3947c}
html,body{height:100%;margin:0}
a{color:var(--color-accent)} a:hover{color:var(--color-accent-600)}
::-webkit-scrollbar{height:6px;width:6px} ::-webkit-scrollbar-thumb{background:var(--color-neutral-400);border-radius:99px} ::-webkit-scrollbar-track{background:transparent}
</style>
</helmet>
<div ref="{{ rootRef }}" style="height:100%;background:var(--color-bg);font-family:var(--font-body);color:var(--color-text);user-select:none">
<sc-if value="{{ isDesktop }}" hint-placeholder-val="{{ true }}">
<div data-screen-label="Editor — desktop" style="height:100%;display:flex;flex-direction:column">
<nav class="nav" style="flex:none">
<span class="nav-brand" style="display:flex;align-items:center;gap:8px">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-2-600)" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M7 20h10"></path><path d="M10 20c5.5-2.5.8-6.4 3-10"></path><path d="M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z"></path><path d="M14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2z"></path></svg>
pansy
</span>
<a href="Pansy Gardens.dc.html" aria-current="page">Gardens</a>
<a href="Pansy Plants.dc.html">Plants</a>
<span style="margin-left:auto;display:flex;align-items:center;gap:10px">
<button class="btn btn-icon btn-secondary" style="border-radius:999px" onClick="{{ onTheme }}" title="{{ themeTitle }}">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="{{ themeD1 }}"></path><path d="{{ themeD2 }}"></path><path d="{{ themeD3 }}"></path></svg>
</button>
<a href="Pansy Settings.dc.html" class="btn btn-icon btn-secondary" style="border-radius:999px" title="Settings">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0 2-2z"></path></svg>
</a>
<a href="Pansy Login.dc.html" style="width:32px;height:32px;border-radius:999px;background:var(--color-accent-2-300);color:var(--color-accent-2-800);display:inline-flex;align-items:center;justify-content:center;font-weight:700;font-size:14px;text-decoration:none">S</a>
</span>
</nav>
<div style="flex:1;display:grid;grid-template-columns:216px minmax(0,1fr) 336px;gap:14px;padding:14px;min-height:0">
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);padding:14px;overflow-y:auto;overflow-x:hidden;display:flex;flex-direction:column;gap:8px">
<sc-if value="{{ notFocus }}" hint-placeholder-val="{{ true }}">
<h6 style="margin:4px 4px 6px">Toolkit</h6>
<sc-for list="{{ kindsR }}" as="k" hint-placeholder-count="7">
<button draggable="true" onDragStart="{{ k.onDrag }}" onClick="{{ k.onClick }}" style="{{ k.btnStyle }}" title="Drag onto the plan — or click to arm, then click the plan">
<svg width="38" height="30" viewBox="-20 -15 40 30" style="flex:none">
<sc-if value="{{ k.isRect }}"><rect x="{{ k.mx0 }}" y="{{ k.my0 }}" width="{{ k.mw }}" height="{{ k.mh }}" rx="3" fill="{{ k.fill }}" stroke="{{ k.stroke }}" stroke-width="1.5" stroke-dasharray="{{ k.mdash }}"></rect></sc-if>
<sc-if value="{{ k.isCircle }}"><circle r="{{ k.mr }}" fill="{{ k.fill }}" stroke="{{ k.stroke }}" stroke-width="1.5" stroke-dasharray="{{ k.mdash }}"></circle></sc-if>
</svg>
<span style="display:flex;flex-direction:column;align-items:flex-start;gap:1px">
<span style="font-size:13px;font-weight:700;white-space:nowrap">{{ k.label }}</span>
<span style="font-size:11px;color:var(--p-ink-mute);white-space:nowrap">{{ k.sizeText }}</span>
</span>
</button>
</sc-for>
<div style="margin-top:auto;font-size:12px;color:var(--p-ink-mute);line-height:1.5;padding:8px 6px 2px">Drag a shape onto the plan. Double-click any bed to plant it.</div>
</sc-if>
<sc-if value="{{ focusName }}" hint-placeholder-val="{{ false }}">
<div style="display:flex;align-items:center;gap:8px;margin:2px 0 4px">
<button class="btn btn-icon btn-secondary" style="border-radius:999px;width:30px;height:30px;flex:none" onClick="{{ onBack }}" title="Back to the plan">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"></path></svg>
</button>
<span style="font-family:var(--font-heading);font-size:16px;line-height:1.15">{{ focusName }}</span>
</div>
<input class="input" style="border-radius:999px" placeholder="Find a plant…" value="{{ q }}" onChange="{{ onQ }}">
<sc-for list="{{ plantsR }}" as="c" hint-placeholder-count="8">
<button draggable="true" onDragStart="{{ c.onDrag }}" onClick="{{ c.onClick }}" style="{{ c.btnStyle }}" title="Drag into the bed — or click to arm, then click the bed">
<svg width="16" height="16" style="flex:none"><circle cx="8" cy="8" r="8" fill="{{ c.color }}"></circle></svg>
<span style="font-size:13px;font-weight:700">{{ c.name }}</span>
<span style="font-size:11px;color:var(--p-ink-mute);margin-left:auto">{{ c.spacingText }}</span>
</button>
</sc-for>
</sc-if>
</div>
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);display:flex;flex-direction:column;min-height:0;overflow:hidden">
<div style="display:flex;align-items:center;gap:12px;padding:12px 18px;border-bottom:1px solid var(--color-divider);flex-wrap:wrap">
<h4 style="margin:0;font-size:19px">Home Garden</h4>
<span style="font-size:13px;color:var(--p-ink-mute);font-weight:600">40 × 28</span>
<sc-if value="{{ focusName }}" hint-placeholder-val="{{ false }}">
<span style="opacity:0.4">/</span><span style="font-size:14px;font-weight:700;color:var(--color-accent-700)">{{ focusName }}</span>
</sc-if>
<span style="margin-left:auto;display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;min-width:0">
<span style="display:flex;background:var(--color-neutral-200);border-radius:999px;padding:3px">
<sc-for list="{{ seasonsR }}" as="sn">
<button onClick="{{ sn.onClick }}" style="{{ sn.style }}">{{ sn.label }}</button>
</sc-for>
</span>
<button class="btn btn-secondary" style="border-radius:999px;gap:7px" onClick="{{ onUndo }}" disabled="{{ undoDisabled }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 4 9l5-5"></path><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"></path></svg>
Undo
</button>
</span>
</div>
<sc-if value="{{ banner }}" hint-placeholder-val="{{ false }}">
<div style="padding:8px 18px;background:var(--color-accent-2-200);color:var(--color-accent-2-800);font-size:13px;font-weight:600">{{ banner }}</div>
</sc-if>
<div style="flex:1;min-height:0;position:relative">
<svg ref="{{ svgRef }}" width="100%" height="100%" style="{{ svgStyle }}" onPointerDown="{{ onCanvasDown }}" onPointerMove="{{ onCanvasMove }}" onPointerUp="{{ onCanvasUp }}" onPointerCancel="{{ onCanvasUp }}" onDragOver="{{ onDragOver }}" onDrop="{{ onDrop }}">
<g style="{{ viewStyle }}">
<rect x="0" y="0" width="{{ gw }}" height="{{ gh }}" rx="18" fill="var(--p-field)" stroke="var(--color-accent-2-500)" stroke-width="{{ borderW }}"></rect>
<path d="{{ gridMinor }}" stroke="var(--p-grid-ink)" stroke-width="{{ hairW }}" opacity="0.06" fill="none"></path>
<path d="{{ gridMajor }}" stroke="var(--p-grid-ink)" stroke-width="{{ hairW }}" opacity="0.12" fill="none"></path>
<sc-for list="{{ objectsR }}" as="o" hint-placeholder-count="4">
<g transform="{{ o.transform }}" opacity="{{ o.opacity }}" style="cursor:grab" onPointerDown="{{ o.onDown }}" onDoubleClick="{{ o.onDbl }}">
<sc-if value="{{ o.isRect }}" hint-placeholder-val="{{ true }}"><rect x="{{ o.x0 }}" y="{{ o.y0 }}" width="{{ o.w }}" height="{{ o.h }}" rx="{{ o.rr }}" fill="{{ o.fill }}" stroke="{{ o.stroke }}" stroke-width="{{ o.sw }}" stroke-dasharray="{{ o.dash }}"></rect></sc-if>
<sc-if value="{{ o.isCircle }}" hint-placeholder-val="{{ false }}"><circle r="{{ o.r }}" fill="{{ o.fill }}" stroke="{{ o.stroke }}" stroke-width="{{ o.sw }}" stroke-dasharray="{{ o.dash }}"></circle></sc-if>
</g>
</sc-for>
<sc-for list="{{ plopsR }}" as="p" hint-placeholder-count="0">
<g transform="{{ p.transform }}" opacity="{{ p.opacity }}" style="{{ p.gStyle }}" onPointerDown="{{ p.onDown }}">
<circle r="{{ p.r }}" fill="{{ p.fill }}" stroke="{{ p.stroke }}" stroke-width="{{ p.sw }}"></circle>
</g>
</sc-for>
<g style="pointer-events:none">{{ labelsLayer }}</g>
<sc-if value="{{ selO }}" hint-placeholder-val="{{ false }}">
<g transform="{{ selO.transform }}" style="pointer-events:none">
<sc-if value="{{ selO.isRect }}"><rect x="{{ selO.x0 }}" y="{{ selO.y0 }}" width="{{ selO.w }}" height="{{ selO.h }}" rx="{{ selO.rr }}" fill="none" stroke="var(--color-accent)" stroke-width="{{ selO.sw }}" stroke-dasharray="{{ selO.dash }}"></rect></sc-if>
<sc-if value="{{ selO.isCircle }}"><circle r="{{ selO.r }}" fill="none" stroke="var(--color-accent)" stroke-width="{{ selO.sw }}" stroke-dasharray="{{ selO.dash }}"></circle></sc-if>
</g>
</sc-if>
<sc-if value="{{ ghostR }}" hint-placeholder-val="{{ false }}">
<g transform="{{ ghostR.transform }}" opacity="0.55" style="pointer-events:none">
<sc-if value="{{ ghostR.isRect }}"><rect x="{{ ghostR.x0 }}" y="{{ ghostR.y0 }}" width="{{ ghostR.w }}" height="{{ ghostR.h }}" rx="12" fill="{{ ghostR.fill }}" stroke="var(--color-accent)" stroke-width="{{ ghostR.sw }}" stroke-dasharray="{{ ghostR.dash }}"></rect></sc-if>
<sc-if value="{{ ghostR.isCircle }}"><circle r="{{ ghostR.r }}" fill="{{ ghostR.fill }}" stroke="var(--color-accent)" stroke-width="{{ ghostR.sw }}" stroke-dasharray="{{ ghostR.dash }}"></circle></sc-if>
</g>
</sc-if>
</g>
</svg>
<div style="position:absolute;bottom:12px;right:12px;display:flex;gap:4px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:999px;box-shadow:var(--shadow-sm);padding:4px">
<button class="btn btn-icon" style="border-radius:999px;width:30px;height:30px" style-hover="background:var(--color-accent-100)" onClick="{{ zoomOut }}" title="Zoom out"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path></svg></button>
<button class="btn btn-icon" style="border-radius:999px;width:30px;height:30px" style-hover="background:var(--color-accent-100)" onClick="{{ zoomFit }}" title="Fit"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3"></path><path d="M21 8V5a2 2 0 0 0-2-2h-3"></path><path d="M3 16v3a2 2 0 0 0 2 2h3"></path><path d="M16 21h3a2 2 0 0 0 2-2v-3"></path></svg></button>
<button class="btn btn-icon" style="border-radius:999px;width:30px;height:30px" style-hover="background:var(--color-accent-100)" onClick="{{ zoomIn }}" title="Zoom in"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg></button>
</div>
</div>
</div>
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);display:flex;flex-direction:column;min-height:0;overflow:hidden">
<div style="display:flex;gap:4px;padding:10px 10px 0">
<sc-for list="{{ tabsR }}" as="t">
<button onClick="{{ t.onClick }}" style="{{ t.style }}">{{ t.label }}</button>
</sc-for>
</div>
<div style="flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px">
<sc-if value="{{ tabPlot }}" hint-placeholder-val="{{ true }}">
<sc-if value="{{ insp }}" hint-placeholder-val="{{ false }}">
<sc-if value="{{ insp.isObj }}">
<input class="input" style="border-radius:999px;font-weight:700" value="{{ insp.name }}" onChange="{{ insp.onRename }}">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span class="tag tag-neutral" style="border-radius:999px">{{ insp.kindLabel }}</span>
<span style="font-size:13px;color:var(--p-ink-soft);font-weight:600">{{ insp.sizeText }}</span>
</div>
<sc-if value="{{ insp.rosterText }}"><div style="font-size:12.5px;color:var(--p-ink-soft);line-height:1.5">{{ insp.rosterText }}</div></sc-if>
<div style="display:flex;gap:8px">
<sc-if value="{{ insp.plantable }}"><button class="btn btn-primary" style="border-radius:999px;flex:1" onClick="{{ insp.onOpen }}">Plant this</button></sc-if>
<button class="btn btn-icon btn-secondary" style="border-radius:999px" onClick="{{ insp.onRotate }}" title="Rotate 90°"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg></button>
<button class="btn btn-icon btn-secondary" style="border-radius:999px" onClick="{{ insp.onDelete }}" title="Remove"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-700)" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>
</div>
</sc-if>
<sc-if value="{{ insp.isPlop }}">
<div style="display:flex;align-items:center;gap:10px">
<svg width="18" height="18"><circle cx="9" cy="9" r="8" fill="{{ insp.plantColor }}"></circle></svg>
<span style="font-family:var(--font-heading);font-size:17px">{{ insp.plantName }}</span>
</div>
<div style="font-size:13px;color:var(--p-ink-soft)">{{ insp.countText }} · {{ insp.plopSize }} patch</div>
<button class="btn btn-secondary" style="border-radius:999px" onClick="{{ insp.onDelete }}">Pull it out</button>
</sc-if>
</sc-if>
<sc-if value="{{ summary }}" hint-placeholder-val="{{ true }}">
<h5 style="margin:2px 0 0">This garden</h5>
<div style="font-size:13px;color:var(--p-ink-soft);line-height:1.6">{{ summary.countsText }}</div>
<div style="display:flex;flex-direction:column;gap:7px">
<sc-for list="{{ summary.roster }}" as="r" hint-placeholder-count="3">
<div style="display:flex;align-items:center;gap:9px">
<svg width="13" height="13" style="flex:none"><circle cx="6.5" cy="6.5" r="6.5" fill="{{ r.color }}"></circle></svg>
<span style="font-size:13px;font-weight:600">{{ r.name }}</span>
<span style="font-size:12px;color:var(--p-ink-mute);margin-left:auto">{{ r.where }}</span>
</div>
</sc-for>
</div>
<div style="font-size:12px;color:var(--p-ink-mute);line-height:1.5;margin-top:auto;padding-top:10px">Select anything on the plan to edit it here. Double-click a bed to plant it.</div>
</sc-if>
</sc-if>
<sc-if value="{{ tabJournal }}" hint-placeholder-val="{{ false }}">
<sc-for list="{{ entriesR }}" as="e" hint-placeholder-count="3">
<div style="background:var(--color-bg);border:1px solid var(--color-divider);border-radius:var(--radius-md);padding:12px 14px">
<div style="display:flex;gap:6px;align-items:center;margin-bottom:5px">
<span style="font-size:11.5px;font-weight:700;color:var(--color-accent-2-700)">{{ e.obj }}</span>
<span style="font-size:11.5px;color:var(--p-ink-mute);margin-left:auto">{{ e.d }}</span>
</div>
<div style="font-size:13px;line-height:1.5">{{ e.txt }}</div>
</div>
</sc-for>
<div style="display:flex;gap:6px;margin-top:auto;padding-top:6px">
<input class="input" style="border-radius:999px" placeholder="{{ jrPlaceholder }}" value="{{ jr }}" onChange="{{ onJr }}" onKeyDown="{{ onJrKey }}">
<button class="btn btn-primary btn-icon" style="border-radius:999px;flex:none" onClick="{{ onAddJr }}" title="Log it">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg>
</button>
</div>
</sc-if>
<sc-if value="{{ tabHistory }}" hint-placeholder-val="{{ false }}">
<div style="font-size:12.5px;color:var(--p-ink-mute);line-height:1.5">Every change — yours or the assistant's — is one undoable step.</div>
<sc-for list="{{ historyR }}" as="h" hint-placeholder-count="2">
<div style="display:flex;align-items:center;gap:9px;background:var(--color-bg);border:1px solid var(--color-divider);border-radius:999px;padding:8px 14px">
<span style="width:7px;height:7px;border-radius:99px;background:var(--color-accent-400);flex:none"></span>
<span style="font-size:13px;font-weight:600">{{ h.label }}</span>
<span style="font-size:11.5px;color:var(--p-ink-mute);margin-left:auto">{{ h.when }}</span>
</div>
</sc-for>
<sc-if value="{{ historyEmpty }}"><div style="font-size:13px;color:var(--p-ink-mute)">Nothing yet this session — go move something.</div></sc-if>
<button class="btn btn-secondary" style="border-radius:999px;margin-top:auto" onClick="{{ onUndo }}" disabled="{{ undoDisabled }}">Undo the last step</button>
</sc-if>
<sc-if value="{{ tabChat }}" hint-placeholder-val="{{ false }}">
<sc-for list="{{ msgsR }}" as="m" hint-placeholder-count="2">
<div style="{{ m.style }}">{{ m.txt }}</div>
</sc-for>
<div style="display:flex;gap:6px;margin-top:auto;padding-top:6px">
<input class="input" style="border-radius:999px" placeholder="Ask about your garden…" value="{{ chat }}" onChange="{{ onChat }}" onKeyDown="{{ onChatKey }}">
<button class="btn btn-primary btn-icon" style="border-radius:999px;flex:none" onClick="{{ onSend }}" title="Send">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"></path><path d="M22 2 11 13"></path></svg>
</button>
</div>
</sc-if>
</div>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ isMobile }}" hint-placeholder-val="{{ false }}">
<div data-screen-label="Editor — phone" style="height:100%;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;gap:8px;padding:10px 12px;flex:none">
<sc-if value="{{ focusName }}" hint-placeholder-val="{{ false }}">
<button class="btn btn-icon btn-secondary" style="border-radius:999px;flex:none;width:38px;height:38px" onClick="{{ onBack }}" title="Back to the plan">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"></path></svg>
</button>
</sc-if>
<sc-if value="{{ notFocus }}" hint-placeholder-val="{{ true }}">
<a href="Pansy Gardens.dc.html" class="btn btn-icon btn-secondary" style="border-radius:999px;flex:none;width:38px;height:38px;text-decoration:none" title="Your gardens">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"></path></svg>
</a>
</sc-if>
<span style="font-family:var(--font-heading);font-size:17px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0">{{ mTitle }}</span>
<span style="margin-left:auto;display:flex;align-items:center;gap:8px;flex:none">
<button class="btn btn-icon btn-secondary" style="border-radius:999px;width:38px;height:38px" onClick="{{ onTheme }}" title="{{ themeTitle }}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="{{ themeD1 }}"></path><path d="{{ themeD2 }}"></path><path d="{{ themeD3 }}"></path></svg>
</button>
<button onClick="{{ onCycleSeason }}" class="tag tag-accent-2" style="border-radius:999px;border:none;cursor:pointer;font-family:var(--font-body)" title="Switch season">{{ seasonShort }}</button>
<button class="btn btn-icon btn-secondary" style="border-radius:999px;width:38px;height:38px" onClick="{{ onUndo }}" disabled="{{ undoDisabled }}" title="Undo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 4 9l5-5"></path><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"></path></svg>
</button>
</span>
</div>
<sc-if value="{{ banner }}" hint-placeholder-val="{{ false }}">
<div style="padding:6px 14px;background:var(--color-accent-2-200);color:var(--color-accent-2-800);font-size:12px;font-weight:600;flex:none">{{ banner }}</div>
</sc-if>
<div style="flex:1;min-height:0;position:relative">
<svg ref="{{ svgRef }}" width="100%" height="100%" style="{{ svgStyle }}" onPointerDown="{{ onCanvasDown }}" onPointerMove="{{ onCanvasMove }}" onPointerUp="{{ onCanvasUp }}" onPointerCancel="{{ onCanvasUp }}" onDragOver="{{ onDragOver }}" onDrop="{{ onDrop }}">
<g style="{{ viewStyle }}">
<rect x="0" y="0" width="{{ gw }}" height="{{ gh }}" rx="18" fill="var(--p-field)" stroke="var(--color-accent-2-500)" stroke-width="{{ borderW }}"></rect>
<path d="{{ gridMinor }}" stroke="var(--p-grid-ink)" stroke-width="{{ hairW }}" opacity="0.06" fill="none"></path>
<path d="{{ gridMajor }}" stroke="var(--p-grid-ink)" stroke-width="{{ hairW }}" opacity="0.12" fill="none"></path>
<sc-for list="{{ objectsR }}" as="o" hint-placeholder-count="4">
<g transform="{{ o.transform }}" opacity="{{ o.opacity }}" onPointerDown="{{ o.onDown }}" onDoubleClick="{{ o.onDbl }}">
<sc-if value="{{ o.isRect }}" hint-placeholder-val="{{ true }}"><rect x="{{ o.x0 }}" y="{{ o.y0 }}" width="{{ o.w }}" height="{{ o.h }}" rx="{{ o.rr }}" fill="{{ o.fill }}" stroke="{{ o.stroke }}" stroke-width="{{ o.sw }}" stroke-dasharray="{{ o.dash }}"></rect></sc-if>
<sc-if value="{{ o.isCircle }}" hint-placeholder-val="{{ false }}"><circle r="{{ o.r }}" fill="{{ o.fill }}" stroke="{{ o.stroke }}" stroke-width="{{ o.sw }}" stroke-dasharray="{{ o.dash }}"></circle></sc-if>
</g>
</sc-for>
<sc-for list="{{ plopsR }}" as="p" hint-placeholder-count="0">
<g transform="{{ p.transform }}" opacity="{{ p.opacity }}" onPointerDown="{{ p.onDown }}">
<circle r="{{ p.r }}" fill="{{ p.fill }}" stroke="{{ p.stroke }}" stroke-width="{{ p.sw }}"></circle>
</g>
</sc-for>
<g style="pointer-events:none">{{ labelsLayer }}</g>
<sc-if value="{{ selO }}" hint-placeholder-val="{{ false }}">
<g transform="{{ selO.transform }}" style="pointer-events:none">
<sc-if value="{{ selO.isRect }}"><rect x="{{ selO.x0 }}" y="{{ selO.y0 }}" width="{{ selO.w }}" height="{{ selO.h }}" rx="{{ selO.rr }}" fill="none" stroke="var(--color-accent)" stroke-width="{{ selO.sw }}" stroke-dasharray="{{ selO.dash }}"></rect></sc-if>
<sc-if value="{{ selO.isCircle }}"><circle r="{{ selO.r }}" fill="none" stroke="var(--color-accent)" stroke-width="{{ selO.sw }}" stroke-dasharray="{{ selO.dash }}"></circle></sc-if>
</g>
</sc-if>
</g>
</svg>
<button class="btn btn-icon" style="position:absolute;bottom:12px;right:12px;border-radius:999px;width:40px;height:40px;background:var(--color-surface);border:1px solid var(--color-divider);box-shadow:var(--shadow-sm)" onClick="{{ zoomFit }}" title="Fit the garden">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3"></path><path d="M21 8V5a2 2 0 0 0-2-2h-3"></path><path d="M3 16v3a2 2 0 0 0 2 2h3"></path><path d="M16 21h3a2 2 0 0 0 2-2v-3"></path></svg>
</button>
</div>
<sc-if value="{{ mPeek }}" hint-placeholder-val="{{ false }}">
<div style="flex:none;max-height:45%;display:flex;flex-direction:column;background:var(--color-neutral-100);border-top:1px solid var(--color-divider);border-radius:22px 22px 0 0;box-shadow:var(--shadow-lg)">
<div style="display:flex;align-items:center;gap:8px;padding:10px 14px 2px">
<span style="font-family:var(--font-heading);font-size:15px">{{ mPeekTitle }}</span>
<button class="btn btn-icon" style="border-radius:999px;margin-left:auto;width:32px;height:32px" style-hover="background:var(--color-accent-100)" onClick="{{ onPeekClose }}" title="Close">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>
</button>
</div>
<div style="flex:1;overflow-y:auto;padding:8px 16px 16px;display:flex;flex-direction:column;gap:10px">
<sc-if value="{{ mPeekInsp }}" hint-placeholder-val="{{ false }}">
<sc-if value="{{ insp.isObj }}">
<input class="input" style="border-radius:999px;font-weight:700;font-size:16px" value="{{ insp.name }}" onChange="{{ insp.onRename }}">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span class="tag tag-neutral" style="border-radius:999px">{{ insp.kindLabel }}</span>
<span style="font-size:13px;color:var(--p-ink-soft);font-weight:600">{{ insp.sizeText }}</span>
</div>
<sc-if value="{{ insp.rosterText }}"><div style="font-size:12.5px;color:var(--p-ink-soft);line-height:1.5">{{ insp.rosterText }}</div></sc-if>
<div style="display:flex;gap:8px">
<sc-if value="{{ insp.plantable }}"><button class="btn btn-primary" style="border-radius:999px;flex:1;min-height:44px" onClick="{{ insp.onOpen }}">Plant this</button></sc-if>
<button class="btn btn-icon btn-secondary" style="border-radius:999px;width:44px;height:44px" onClick="{{ insp.onRotate }}" title="Rotate 90°"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg></button>
<button class="btn btn-icon btn-secondary" style="border-radius:999px;width:44px;height:44px" onClick="{{ insp.onDelete }}" title="Remove"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-700)" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>
</div>
</sc-if>
<sc-if value="{{ insp.isPlop }}">
<div style="display:flex;align-items:center;gap:10px">
<svg width="18" height="18"><circle cx="9" cy="9" r="8" fill="{{ insp.plantColor }}"></circle></svg>
<span style="font-family:var(--font-heading);font-size:17px">{{ insp.plantName }}</span>
<span style="font-size:12.5px;color:var(--p-ink-soft);margin-left:auto">{{ insp.countText }}</span>
</div>
<button class="btn btn-secondary" style="border-radius:999px;min-height:44px" onClick="{{ insp.onDelete }}">Pull it out</button>
</sc-if>
</sc-if>
<sc-if value="{{ mPeekJournal }}" hint-placeholder-val="{{ false }}">
<div style="display:flex;gap:6px">
<input class="input" style="border-radius:999px;font-size:16px" placeholder="{{ jrPlaceholder }}" value="{{ jr }}" onChange="{{ onJr }}" onKeyDown="{{ onJrKey }}">
<button class="btn btn-primary btn-icon" style="border-radius:999px;flex:none;width:44px;height:44px" onClick="{{ onAddJr }}" title="Log it">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg>
</button>
</div>
<sc-for list="{{ entriesR }}" as="e" hint-placeholder-count="3">
<div style="background:var(--color-bg);border:1px solid var(--color-divider);border-radius:var(--radius-md);padding:11px 13px">
<div style="display:flex;gap:6px;align-items:center;margin-bottom:4px">
<span style="font-size:11.5px;font-weight:700;color:var(--color-accent-2-700)">{{ e.obj }}</span>
<span style="font-size:11.5px;color:var(--p-ink-mute);margin-left:auto">{{ e.d }}</span>
</div>
<div style="font-size:13.5px;line-height:1.5">{{ e.txt }}</div>
</div>
</sc-for>
</sc-if>
<sc-if value="{{ mPeekChat }}" hint-placeholder-val="{{ false }}">
<sc-for list="{{ msgsR }}" as="m" hint-placeholder-count="2">
<div style="{{ m.style }}">{{ m.txt }}</div>
</sc-for>
<div style="display:flex;gap:6px">
<input class="input" style="border-radius:999px;font-size:16px" placeholder="Ask about your garden…" value="{{ chat }}" onChange="{{ onChat }}" onKeyDown="{{ onChatKey }}">
<button class="btn btn-primary btn-icon" style="border-radius:999px;flex:none;width:44px;height:44px" onClick="{{ onSend }}" title="Send">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"></path><path d="M22 2 11 13"></path></svg>
</button>
</div>
</sc-if>
</div>
</div>
</sc-if>
<sc-if value="{{ mStrip }}" hint-placeholder-val="{{ false }}">
<div style="flex:none;display:flex;gap:8px;overflow-x:auto;padding:10px 12px;border-top:1px solid var(--color-divider);background:var(--color-bg)">
<sc-if value="{{ mStripDone }}" hint-placeholder-val="{{ false }}">
<button class="btn btn-primary" style="border-radius:999px;flex:none;min-height:44px" onClick="{{ onBack }}">Done</button>
</sc-if>
<sc-if value="{{ mStripKinds }}" hint-placeholder-val="{{ false }}">
<sc-for list="{{ kindsR }}" as="k" hint-placeholder-count="7">
<button onClick="{{ k.onClick }}" style="{{ k.mStyle }}">
<svg width="30" height="24" viewBox="-20 -13 40 26" style="flex:none">
<sc-if value="{{ k.isRect }}"><rect x="{{ k.mx0 }}" y="{{ k.my0 }}" width="{{ k.mw }}" height="{{ k.mh }}" rx="3" fill="{{ k.fill }}" stroke="{{ k.stroke }}" stroke-width="1.5" stroke-dasharray="{{ k.mdash }}"></rect></sc-if>
<sc-if value="{{ k.isCircle }}"><circle r="{{ k.mr }}" fill="{{ k.fill }}" stroke="{{ k.stroke }}" stroke-width="1.5" stroke-dasharray="{{ k.mdash }}"></circle></sc-if>
</svg>
<span style="font-size:13px;font-weight:700;white-space:nowrap">{{ k.label }}</span>
</button>
</sc-for>
</sc-if>
<sc-if value="{{ mStripPlants }}" hint-placeholder-val="{{ false }}">
<sc-for list="{{ plantsR }}" as="c" hint-placeholder-count="8">
<button onClick="{{ c.onClick }}" style="{{ c.mStyle }}">
<svg width="15" height="15" style="flex:none"><circle cx="7.5" cy="7.5" r="7.5" fill="{{ c.color }}"></circle></svg>
<span style="font-size:13px;font-weight:700;white-space:nowrap">{{ c.name }}</span>
</button>
</sc-for>
</sc-if>
</div>
</sc-if>
<div style="flex:none;display:flex;gap:4px;padding:6px 8px calc(6px + env(safe-area-inset-bottom));background:var(--color-surface);border-top:1px solid var(--color-divider)">
<sc-for list="{{ modesR }}" as="md">
<button onClick="{{ md.onClick }}" style="{{ md.style }}">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="{{ md.d1 }}"></path><path d="{{ md.d2 }}"></path><path d="{{ md.d3 }}"></path></svg>
<span style="font-size:11px;font-weight:700">{{ md.label }}</span>
</button>
</sc-for>
</div>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;layout&quot;: {&quot;editor&quot;: &quot;enum&quot;, &quot;options&quot;: [&quot;auto&quot;, &quot;phone&quot;, &quot;desktop&quot;], &quot;default&quot;: &quot;auto&quot;, &quot;tsType&quot;: &quot;'auto' | 'phone' | 'desktop'&quot;, &quot;section&quot;: &quot;Layout&quot;}, &quot;showGrid&quot;: {&quot;editor&quot;: &quot;boolean&quot;, &quot;default&quot;: true, &quot;tsType&quot;: &quot;boolean&quot;, &quot;section&quot;: &quot;Canvas&quot;}, &quot;markers&quot;: {&quot;editor&quot;: &quot;enum&quot;, &quot;options&quot;: [&quot;monogram&quot;, &quot;dot&quot;], &quot;default&quot;: &quot;monogram&quot;, &quot;tsType&quot;: &quot;'monogram' | 'dot'&quot;, &quot;section&quot;: &quot;Canvas&quot;}}">
class Component extends DCLogic {
constructor(props) {
super(props);
this.svgRef = React.createRef(); this.rootRef = React.createRef();
this.GW = 1219; this.GH = 853; this.nid = 100; this.pts = new Map();
this.PLANTS = [
{ id: 'garlic', name: 'Garlic', letter: 'G', color: '#97a97c', spacing: 15 },
{ id: 'tomato', name: 'Tomato', letter: 'T', color: '#c8553d', spacing: 60 },
{ id: 'cucumber', name: 'Cucumber', letter: 'C', color: '#6f8f4f', spacing: 30 },
{ id: 'watermelon', name: 'Watermelon', letter: 'W', color: '#46683c', spacing: 90 },
{ id: 'basil', name: 'Basil', letter: 'B', color: '#5f8f45', spacing: 25 },
{ id: 'pepper', name: 'Pepper', letter: 'P', color: '#b2622d', spacing: 45 },
{ id: 'marigold', name: 'Marigold', letter: 'Ma', color: '#d9912f', spacing: 20 },
{ id: 'melon', name: 'Melon', letter: 'Me', color: '#c2913a', spacing: 90 },
];
this.KINDS = [
{ kind: 'bed', label: 'Bed', shape: 'rect', w: 91, h: 183, plantable: true },
{ kind: 'grow_bag', label: 'Grow bag', shape: 'circle', w: 40, h: 40, plantable: true },
{ kind: 'container', label: 'Container', shape: 'circle', w: 60, h: 60, plantable: true },
{ kind: 'in_ground', label: 'In-ground', shape: 'rect', w: 200, h: 200, plantable: true },
{ kind: 'tree', label: 'Tree', shape: 'circle', w: 300, h: 300, plantable: false },
{ kind: 'path', label: 'Path', shape: 'rect', w: 100, h: 300, plantable: false },
{ kind: 'structure', label: 'Structure', shape: 'rect', w: 200, h: 200, plantable: false },
];
const o = (id, kind, name, shape, x, y, w, h) => ({ id, kind, name, shape, x, y, w, h, rot: 0, plantable: this.KINDS.find(k => k.kind === kind).plantable });
const objects = [
o(10, 'path', 'Walkway', 'rect', 560, 285, 1010, 75),
o(1, 'bed', 'Bed 1', 'rect', 110, 140, 91, 183), o(2, 'bed', 'Bed 2', 'rect', 320, 140, 91, 183),
o(3, 'bed', 'Bed 3', 'rect', 530, 140, 91, 183), o(4, 'bed', 'Bed 4', 'rect', 740, 140, 91, 183),
o(5, 'bed', 'Bed 5', 'rect', 950, 140, 91, 183),
o(6, 'bed', 'Long bed A', 'rect', 190, 392, 244, 61), o(7, 'bed', 'Long bed B', 'rect', 190, 502, 244, 61),
o(8, 'bed', 'Long bed C', 'rect', 190, 612, 244, 61), o(9, 'bed', 'Long bed D', 'rect', 190, 722, 244, 61),
o(11, 'grow_bag', 'Bag 1', 'circle', 1090, 110, 40, 40), o(12, 'grow_bag', 'Bag 2', 'circle', 1090, 165, 40, 40),
o(13, 'grow_bag', 'Bag 3', 'circle', 1090, 220, 40, 40),
o(14, 'container', 'Bucket 1', 'circle', 1160, 110, 30, 30), o(15, 'container', 'Bucket 2', 'circle', 1160, 165, 30, 30),
];
let plops = [];
const grid = (objId, plantId, w, h, sp) => {
const cols = Math.max(1, Math.floor(w / sp)), rows = Math.max(1, Math.floor(h / sp));
for (let i = 0; i < cols; i++) for (let j = 0; j < rows; j++)
plops.push({ id: this.nid++, objId, plantId, lx: (i - (cols - 1) / 2) * sp, ly: (j - (rows - 1) / 2) * sp, r: sp / 2 });
};
grid(6, 'garlic', 244, 61, 15); grid(1, 'tomato', 91, 183, 60); grid(3, 'watermelon', 91, 183, 90);
grid(4, 'basil', 91, 183, 25); grid(7, 'pepper', 244, 61, 45); grid(8, 'marigold', 244, 61, 20);
plops.push({ id: this.nid++, objId: 2, plantId: 'cucumber', lx: 0, ly: -48, r: 38 }, { id: this.nid++, objId: 2, plantId: 'cucumber', lx: 0, ly: 48, r: 38 });
[11, 12, 13].forEach(b => plops.push({ id: this.nid++, objId: b, plantId: 'melon', lx: 0, ly: 0, r: 15 }));
const min = [], maj = [], FT = 30.48;
for (let x = FT; x < this.GW; x += FT) (Math.round(x / FT) % 5 ? min : maj).push(`M${x.toFixed(1)} 0V${this.GH}`);
for (let y = FT; y < this.GH; y += FT) (Math.round(y / FT) % 5 ? min : maj).push(`M0 ${y.toFixed(1)}H${this.GW}`);
this.gridMinor = min.join(''); this.gridMajor = maj.join('');
this.state = {
vw: 0, tx: 40, ty: 40, s: 0.5, anim: false, sel: null, focus: null, armK: null, armP: null, ghost: null,
objects, plops, undo: [], tab: 'plot', mode: 'build', season: '2026', q: '', jr: '', chat: '',
entries: [
{ d: 'Aug 14', obj: 'Long bed A', txt: 'Garlic tips yellowing on the north end — eased off the watering.' },
{ d: 'Aug 9', obj: 'Bed 3', txt: 'First watermelon set! Ordered a sling.' },
{ d: 'Aug 2', obj: 'Bag 2', txt: 'Melon vine escaping the bag — trained it along the fence.' },
],
msgs: [
{ who: 'u', txt: 'What can I follow the garlic with?' },
{ who: 'a', txt: 'Long bed A frees up mid-July. Bush beans, carrots, or fall brassica starts all fit that window. Want me to sketch beans in?' },
],
};
}
layoutProp() { return this.props.layout ?? 'auto'; }
isMobileNow() {
const lp = this.layoutProp();
return lp === 'phone' || (lp !== 'desktop' && this.state.vw > 0 && this.state.vw < 760);
}
styleFor(kind) {
return {
bed: { fill: 'var(--p-bed-fill)', stroke: 'var(--p-bed-stroke)' }, in_ground: { fill: 'var(--p-ing-fill)', stroke: 'var(--p-ing-stroke)', dash: '10 7' },
path: { fill: 'var(--p-path-fill)', stroke: 'var(--p-path-stroke)', dash: '3 8' }, grow_bag: { fill: 'var(--p-bag-fill)', stroke: 'var(--p-bag-stroke)' },
container: { fill: 'var(--p-bkt-fill)', stroke: 'var(--p-bkt-stroke)' }, tree: { fill: 'var(--p-tree-fill)', stroke: 'var(--p-tree-stroke)', dash: '12 8' },
structure: { fill: 'var(--p-str-fill)', stroke: 'var(--p-str-stroke)' },
}[kind] || { fill: '#ddd', stroke: '#999' };
}
plant(id) { return this.PLANTS.find(p => p.id === id); }
obj(id) { return this.state.objects.find(o => o.id === id); }
fmt(cm) { const i = Math.round(cm / 2.54); const ft = Math.floor(i / 12), r = i - ft * 12; return ft ? (r ? ft + '\u2032' + r + '\u2033' : ft + '\u2032') : r + '\u2033'; }
cnt(p) { const sp = this.plant(p.plantId).spacing; return Math.max(1, Math.round(Math.PI * p.r * p.r / (sp * sp))); }
get readOnly() { return this.state.season === '2025'; }
componentDidMount() {
const measure = () => {
const w = this.rootRef.current ? this.rootRef.current.clientWidth : 0;
const wasM = this.isMobileNow();
this.setState({ vw: w }, () => {
const el = this.svgRef.current;
const big = el && (Math.abs(el.clientWidth - (this._fitW || 0)) > 60 || Math.abs(el.clientHeight - (this._fitH || 0)) > 60);
if (this.isMobileNow() !== wasM || big) this.zoomFit();
});
};
measure();
this.ro = new ResizeObserver(() => measure());
this.rootRef.current && this.ro.observe(this.rootRef.current);
setTimeout(() => this.zoomFit(), 30);
this.wheelHooked = null;
this.hookWheel();
this.keyN = (e) => {
if (/input|textarea/i.test(e.target.tagName)) return;
if (e.key === 'Escape') {
const st = this.state;
if (st.armK || st.armP) this.setState({ armK: null, armP: null, ghost: null });
else if (st.sel) this.setState({ sel: null });
else if (st.focus) this.back();
}
if ((e.key === 'Delete' || e.key === 'Backspace') && this.state.sel) this.deleteSel();
};
window.addEventListener('keydown', this.keyN);
this.themeMount();
}
componentDidUpdate() { this.hookWheel(); }
hookWheel() {
const el = this.svgRef.current;
if (!el || this.wheelHooked === el) return;
this.wheelN = (e) => {
e.preventDefault();
const r = el.getBoundingClientRect(), { tx, ty, s } = this.state;
const ns = Math.min(8, Math.max(0.12, s * Math.exp(-e.deltaY * 0.0016)));
const px = e.clientX - r.left, py = e.clientY - r.top;
this.setState({ s: ns, tx: px - (px - tx) / s * ns, ty: py - (py - ty) / s * ns, anim: false });
};
el.addEventListener('wheel', this.wheelN, { passive: false });
this.wheelHooked = el;
}
componentWillUnmount() {
this.ro && this.ro.disconnect();
this._unwatchTheme && this._unwatchTheme();
window.removeEventListener('keydown', this.keyN);
}
world(e) { const r = this.svgRef.current.getBoundingClientRect(); return { x: (e.clientX - r.left - this.state.tx) / this.state.s, y: (e.clientY - r.top - this.state.ty) / this.state.s }; }
snap(v) { return Math.round(v / 7.62) * 7.62; }
thresh(e) { return e && e.pointerType === 'touch' ? 7 : 3; }
pushUndo(label, before) {
const b = before || { objects: this.state.objects, plops: this.state.plops };
this.setState(st => ({ undo: [...st.undo.slice(-29), { label, when: new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), objects: b.objects, plops: b.plops }] }));
}
onUndo = () => { const u = this.state.undo; if (!u.length) return; const last = u[u.length - 1]; this.setState({ objects: last.objects, plops: last.plops, undo: u.slice(0, -1), sel: null, ghost: null }); };
unanim() { clearTimeout(this._at); this._at = setTimeout(() => this.setState({ anim: false }), 520); }
zoomFit = () => {
const el = this.svgRef.current; if (!el) return;
const pad = this.isMobileNow() ? 20 : 40;
const s = Math.min((el.clientWidth - pad * 2) / this.GW, (el.clientHeight - pad * 2) / this.GH);
if (!isFinite(s) || s <= 0) return;
this._fitW = el.clientWidth; this._fitH = el.clientHeight;
this.setState({ s, tx: (el.clientWidth - this.GW * s) / 2, ty: (el.clientHeight - this.GH * s) / 2, anim: true }); this.unanim();
};
zoomBy(f) {
const el = this.svgRef.current, { tx, ty, s } = this.state;
const ns = Math.min(8, Math.max(0.12, s * f)), px = el.clientWidth / 2, py = el.clientHeight / 2;
this.setState({ s: ns, tx: px - (px - tx) / s * ns, ty: py - (py - ty) / s * ns, anim: true }); this.unanim();
}
zoomIn = () => this.zoomBy(1.45); zoomOut = () => this.zoomBy(1 / 1.45);
focusObj(o) {
const el = this.svgRef.current, m = this.isMobileNow();
const bw = o.rot % 180 ? o.h : o.w, bh = o.rot % 180 ? o.w : o.h;
const s = Math.min(6, Math.min(el.clientWidth / (bw * (m ? 1.35 : 2.1)), el.clientHeight / (bh * (m ? 1.45 : 1.7))));
this.setState({ focus: o.id, sel: m ? null : { t: 'obj', id: o.id }, armK: null, ghost: null, tab: 'plot', mode: m ? 'plants' : this.state.mode, anim: true, s, tx: el.clientWidth / 2 - o.x * s, ty: el.clientHeight / 2 - o.y * s + (m ? 0 : 10) }); this.unanim();
}
back = () => { this.setState({ focus: null, armP: null, sel: null, q: '', mode: this.isMobileNow() ? 'build' : this.state.mode }); this.zoomFit(); };
toLocal(o, w) { const a = -o.rot * Math.PI / 180, dx = w.x - o.x, dy = w.y - o.y; return { x: dx * Math.cos(a) - dy * Math.sin(a), y: dx * Math.sin(a) + dy * Math.cos(a) }; }
objAt(w) {
const os = this.state.objects;
for (let i = os.length - 1; i >= 0; i--) {
const o = os[i], l = this.toLocal(o, w);
if (o.shape === 'circle' ? Math.hypot(l.x, l.y) <= o.w / 2 : Math.abs(l.x) <= o.w / 2 && Math.abs(l.y) <= o.h / 2) return o;
}
return null;
}
placeObj(w) {
if (this.readOnly) return;
const kd = this.KINDS.find(k => k.kind === this.state.armK);
this.pushUndo('Added a ' + kd.label.toLowerCase());
const id = this.nid++;
const n = this.state.objects.filter(x => x.kind === kd.kind).length + 1;
const no = { id, kind: kd.kind, name: kd.label + ' ' + n, shape: kd.shape, x: this.snap(w.x), y: this.snap(w.y), w: kd.w, h: kd.h, rot: 0, plantable: kd.plantable };
this.setState(st => ({ objects: [...st.objects, no], armK: null, ghost: null, sel: { t: 'obj', id } }));
}
placePlop(objId, l) {
if (this.readOnly) return;
const o = this.obj(objId), pl = this.plant(this.state.armP || this._dropPlant);
if (!o || !pl) return;
const r = pl.spacing / 2;
const cx = Math.max(-(o.w / 2 - r / 2), Math.min(o.w / 2 - r / 2, l.x)), cy = Math.max(-(o.h / 2 - r / 2), Math.min(o.h / 2 - r / 2, l.y));
this.pushUndo('Planted ' + pl.name.toLowerCase() + ' in ' + o.name);
const id = this.nid++;
this.setState(st => ({ plops: [...st.plops, { id, objId, plantId: pl.id, lx: cx, ly: cy, r }], sel: this.isMobileNow() ? st.sel : { t: 'plop', id } }));
}
pinchStart() {
const [a, b] = [...this.pts.values()];
this.drag = null;
this.pinch = { d0: Math.hypot(a.x - b.x, a.y - b.y), cx0: (a.x + b.x) / 2, cy0: (a.y + b.y) / 2, s0: this.state.s, tx0: this.state.tx, ty0: this.state.ty };
}
onCanvasDown = (e) => {
const r = this.svgRef.current.getBoundingClientRect();
this.pts.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top });
try { this.svgRef.current.setPointerCapture(e.pointerId); } catch (_) {}
if (this.pts.size === 2) { this.pinchStart(); return; }
const st = this.state, w = this.world(e);
if (st.armK) { this.placeObj(w); return; }
if (st.armP) {
const o = st.focus ? this.obj(st.focus) : this.objAt(w);
if (o && o.plantable) this.placePlop(o.id, this.toLocal(o, w));
return;
}
if (!this.drag) this.drag = { t: 'pan', sx: e.clientX, sy: e.clientY, tx: st.tx, ty: st.ty, moved: false };
};
objDown(o) {
return (e) => {
e.stopPropagation();
const r = this.svgRef.current.getBoundingClientRect();
this.pts.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top });
try { this.svgRef.current.setPointerCapture(e.pointerId); } catch (_) {}
if (this.pts.size === 2) { this.pinchStart(); return; }
const st = this.state;
if (st.armK) { this.placeObj(this.world(e)); return; }
if (st.armP) { if (o.plantable) this.placePlop(o.id, this.toLocal(o, this.world(e))); return; }
if (st.focus && o.id !== st.focus) return;
if (this.readOnly) { this.setState({ sel: { t: 'obj', id: o.id } }); return; }
const w = this.world(e);
this.drag = { t: 'obj', id: o.id, ox: o.x, oy: o.y, sx: w.x, sy: w.y, moved: false, th: this.thresh(e), before: { objects: st.objects, plops: st.plops } };
};
}
plopDown(p) {
return (e) => {
const st = this.state;
if (st.armK || st.armP) return;
e.stopPropagation();
if (st.focus !== p.objId) { this.objDown(this.obj(p.objId))(e); return; }
const r = this.svgRef.current.getBoundingClientRect();
this.pts.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top });
if (this.readOnly) { this.setState({ sel: { t: 'plop', id: p.id } }); return; }
const w = this.world(e), o = this.obj(p.objId), l = this.toLocal(o, w);
this.drag = { t: 'plop', id: p.id, ox: p.lx, oy: p.ly, sx: l.x, sy: l.y, moved: false, th: this.thresh(e), before: { objects: st.objects, plops: st.plops } };
};
}
onCanvasMove = (e) => {
const r = this.svgRef.current.getBoundingClientRect();
if (this.pts.has(e.pointerId)) this.pts.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top });
if (this.pinch && this.pts.size >= 2) {
const [a, b] = [...this.pts.values()], p = this.pinch;
const d1 = Math.hypot(a.x - b.x, a.y - b.y), cx = (a.x + b.x) / 2, cy = (a.y + b.y) / 2;
const ns = Math.min(8, Math.max(0.12, p.s0 * (d1 / Math.max(1, p.d0))));
const wx = (p.cx0 - p.tx0) / p.s0, wy = (p.cy0 - p.ty0) / p.s0;
this.setState({ s: ns, tx: cx - wx * ns, ty: cy - wy * ns, anim: false });
return;
}
const st = this.state;
if (!this.drag) { if (st.armK && e.pointerType !== 'touch') { const w = this.world(e); this.setState({ ghost: { x: this.snap(w.x), y: this.snap(w.y) } }); } return; }
const d = this.drag;
if (d.t === 'pan') {
if (Math.hypot(e.clientX - d.sx, e.clientY - d.sy) > (d.th || 3)) d.moved = true;
this.setState({ tx: d.tx + e.clientX - d.sx, ty: d.ty + e.clientY - d.sy, anim: false });
} else if (d.t === 'obj') {
const w = this.world(e), nx = this.snap(d.ox + w.x - d.sx), ny = this.snap(d.oy + w.y - d.sy);
if (Math.hypot(w.x - d.sx, w.y - d.sy) > (d.th || 3)) d.moved = true;
this.setState(s2 => ({ objects: s2.objects.map(o => o.id === d.id ? { ...o, x: nx, y: ny } : o) }));
} else if (d.t === 'plop') {
const p0 = this.state.plops.find(p => p.id === d.id); if (!p0) return;
const o = this.obj(p0.objId), l = this.toLocal(o, this.world(e));
const nx = Math.max(-(o.w / 2 - p0.r / 2), Math.min(o.w / 2 - p0.r / 2, d.ox + l.x - d.sx));
const ny = Math.max(-(o.h / 2 - p0.r / 2), Math.min(o.h / 2 - p0.r / 2, d.oy + l.y - d.sy));
if (Math.hypot(l.x - d.sx, l.y - d.sy) > (d.th || 3)) d.moved = true;
this.setState(s2 => ({ plops: s2.plops.map(p => p.id === d.id ? { ...p, lx: nx, ly: ny } : p) }));
}
};
onCanvasUp = (e) => {
this.pts.delete(e.pointerId);
if (this.pinch) { if (this.pts.size < 2) this.pinch = null; return; }
const d = this.drag; this.drag = null;
if (!d) return;
if (d.t === 'pan') { if (!d.moved) this.setState({ sel: null }); return; }
if (!d.moved) { this.setState({ sel: { t: d.t, id: d.id }, tab: 'plot' }); return; }
this.pushUndo(d.t === 'obj' ? 'Moved ' + (this.obj(d.id) || {}).name : 'Moved a planting', d.before);
};
deleteSel = () => {
if (this.readOnly) return;
const { sel } = this.state; if (!sel) return;
this.pushUndo(sel.t === 'obj' ? 'Removed ' + (this.obj(sel.id) || {}).name : 'Pulled a planting');
if (sel.t === 'obj') this.setState(st => ({ objects: st.objects.filter(o => o.id !== sel.id), plops: st.plops.filter(p => p.objId !== sel.id), sel: null, focus: st.focus === sel.id ? null : st.focus }));
else this.setState(st => ({ plops: st.plops.filter(p => p.id !== sel.id), sel: null }));
};
onDragOver = (e) => e.preventDefault();
onDrop = (e) => {
e.preventDefault();
const data = e.dataTransfer.getData('text/plain'); if (!data) return;
const [t, id] = data.split(':'), w = this.world(e);
if (t === 'kind') this.setState({ armK: id }, () => this.placeObj(w));
if (t === 'plant') { const o = this.objAt(w); if (o && o.plantable) { this._dropPlant = id; this.placePlop(o.id, this.toLocal(o, w)); this._dropPlant = null; } }
};
addJournal = () => {
const txt = this.state.jr.trim(); if (!txt) return;
const sel = this.state.sel, target = sel && sel.t === 'obj' && this.obj(sel.id) ? this.obj(sel.id).name : (this.state.focus ? this.obj(this.state.focus).name : 'Garden');
this.setState(st => ({ entries: [{ d: 'Aug 22', obj: target, txt }, ...st.entries], jr: '' }));
};
sendChat = () => {
const txt = this.state.chat.trim(); if (!txt) return;
this.setState(st => ({ msgs: [...st.msgs, { who: 'u', txt }], chat: '' }));
setTimeout(() => this.setState(st => ({ msgs: [...st.msgs, { who: 'a', txt: '(demo) In the real app I act on your live garden — and each of my turns lands as one undoable step in History.' }] })), 550);
};
themeMount() {
const PT = window.PansyTheme; if (!PT) return;
this.setState({ themePref: PT.get() });
this._unwatchTheme = PT.watch(() => { if ((this.state.themePref || 'system') === 'system') PT.apply(PT.isDark('system')); });
}
cycleTheme = () => { const PT = window.PansyTheme; if (!PT) return; const p = PT.next(this.state.themePref || 'system'); PT.set(p); PT.apply(PT.isDark(p)); this.setState({ themePref: p }); };
themeVals() {
const p = this.state.themePref || 'system';
const d = window.PansyTheme ? window.PansyTheme.icon(p) : ['', '', ''];
return { themeD1: d[0], themeD2: d[1], themeD3: d[2], onTheme: this.cycleTheme, themeTitle: 'Theme: ' + p };
}
renderVals() {
const st = this.state, s = st.s;
const isMobile = this.isMobileNow(), isDesktop = !isMobile;
const showGrid = this.props.showGrid ?? true;
const markers = this.props.markers ?? 'monogram';
const focused = st.focus ? this.obj(st.focus) : null;
const objectsR = st.objects.map(o => {
const sty = this.styleFor(o.kind), isSel = st.sel && st.sel.t === 'obj' && st.sel.id === o.id;
return {
key: o.id, isRect: o.shape === 'rect', isCircle: o.shape === 'circle',
x0: -o.w / 2, y0: -o.h / 2, w: o.w, h: o.h, rr: Math.min(14, o.w * 0.14), r: o.w / 2,
fill: sty.fill, stroke: isSel ? 'var(--color-accent)' : sty.stroke, sw: (isSel ? 3.5 : 2.5) / s, dash: sty.dash,
transform: `translate(${o.x} ${o.y}) rotate(${o.rot})`,
opacity: st.focus && o.id !== st.focus ? 0.3 : 1,
onDown: this.objDown(o), onDbl: o.plantable ? () => this.focusObj(o) : undefined,
};
});
const plopsR = st.plops.map(p => {
const o = this.obj(p.objId); if (!o) return null;
const pl = this.plant(p.plantId), a = o.rot * Math.PI / 180;
const wx = o.x + p.lx * Math.cos(a) - p.ly * Math.sin(a), wy = o.y + p.lx * Math.sin(a) + p.ly * Math.cos(a);
const isSel = st.sel && st.sel.t === 'plop' && st.sel.id === p.id;
return {
key: p.id, transform: `translate(${wx} ${wy})`, r: p.r, fill: pl.color,
stroke: isSel ? '#fffaf1' : 'none', sw: 2.5 / s,
opacity: st.focus && p.objId !== st.focus ? 0.22 : 0.94,
gStyle: { cursor: st.focus === p.objId ? 'grab' : 'inherit' },
onDown: this.plopDown(p),
};
}).filter(Boolean);
const T = (props2, str) => React.createElement('text', props2, str);
const labels = [];
st.objects.forEach(o => {
if (!o.name || Math.max(o.w, o.h) * s <= 54 || (st.focus && st.focus !== o.id) || s <= 0.28) return;
const a = o.rot * Math.PI / 180, bh = (Math.abs(o.w * Math.sin(a)) + Math.abs(o.h * Math.cos(a))) / 2;
labels.push(o.plantable
? T({ key: 'ol' + o.id, x: o.x, y: o.y - bh - 9 / s, textAnchor: 'middle', fontSize: 13 / s, fill: 'var(--p-ink-soft)', style: { fontFamily: 'var(--font-body)', fontWeight: 600, letterSpacing: '0.02em' } }, o.name)
: T({ key: 'ol' + o.id, x: o.x, y: o.y, textAnchor: 'middle', dominantBaseline: 'central', fontSize: 13 / s, fill: 'var(--p-ink-mute)', style: { fontFamily: 'var(--font-body)', fontWeight: 600, letterSpacing: '0.06em' } }, o.name));
});
if (markers === 'monogram') st.plops.forEach(p => {
const o = this.obj(p.objId); if (!o || (st.focus && p.objId !== st.focus)) return;
const pl = this.plant(p.plantId), a = o.rot * Math.PI / 180;
const wx = o.x + p.lx * Math.cos(a) - p.ly * Math.sin(a), wy = o.y + p.lx * Math.sin(a) + p.ly * Math.cos(a);
if (p.r * s >= 9) labels.push(T({ key: 'pl' + p.id, x: wx, y: wy, textAnchor: 'middle', dominantBaseline: 'central', fontSize: p.r * 1.05, fill: '#fffaf1', style: { fontFamily: 'var(--font-heading)' } }, pl.letter));
if (p.r * s >= 34) labels.push(T({ key: 'pn' + p.id, x: wx, y: wy + p.r + 13 / s, textAnchor: 'middle', fontSize: 11 / s, fill: 'var(--p-ink-strong)', style: { fontFamily: 'var(--font-body)', fontWeight: 600 } }, pl.name));
});
let selO = null, insp = null;
if (st.sel && st.sel.t === 'obj') {
const o = this.obj(st.sel.id);
if (o) {
const a = o.rot * Math.PI / 180, bh = (Math.abs(o.w * Math.sin(a)) + Math.abs(o.h * Math.cos(a))) / 2;
labels.push(T({ key: 'sz', x: o.x, y: o.y + bh + 22 / s, textAnchor: 'middle', fontSize: 12.5 / s, fill: 'var(--color-accent-700)', style: { fontFamily: 'var(--font-body)', fontWeight: 700 } }, this.fmt(o.w) + ' × ' + this.fmt(o.h)));
selO = {
transform: `translate(${o.x} ${o.y}) rotate(${o.rot})`, isRect: o.shape === 'rect', isCircle: o.shape === 'circle',
x0: -o.w / 2 - 7 / s, y0: -o.h / 2 - 7 / s, w: o.w + 14 / s, h: o.h + 14 / s, r: o.w / 2 + 7 / s, rr: 16,
sw: 1.8 / s, dash: `${8 / s} ${6 / s}`,
};
const roster = {};
st.plops.filter(p => p.objId === o.id).forEach(p => { const n = this.plant(p.plantId).name; roster[n] = (roster[n] || 0) + this.cnt(p); });
const names = Object.entries(roster).map(([n, c]) => c + ' ' + n.toLowerCase()).join(' · ');
insp = {
isObj: true, name: o.name, kindLabel: this.KINDS.find(k => k.kind === o.kind).label,
sizeText: this.fmt(o.w) + ' × ' + this.fmt(o.h), plantable: o.plantable && st.focus !== o.id,
rosterText: names ? 'Growing: ' + names : (o.plantable ? 'Nothing planted yet.' : ''),
onRename: (e) => this.setState(s2 => ({ objects: s2.objects.map(x => x.id === o.id ? { ...x, name: e.target.value } : x) })),
onRotate: () => { if (this.readOnly) return; this.pushUndo('Rotated ' + o.name); this.setState(s2 => ({ objects: s2.objects.map(x => x.id === o.id ? { ...x, rot: (x.rot + 90) % 360 } : x) })); },
onDelete: this.deleteSel, onOpen: () => this.focusObj(o),
};
}
} else if (st.sel && st.sel.t === 'plop') {
const p = st.plops.find(x => x.id === st.sel.id);
if (p) {
const pl = this.plant(p.plantId), c = this.cnt(p);
insp = { isPlop: true, plantName: pl.name, plantColor: pl.color, countText: c + (c === 1 ? ' plant' : ' plants'), plopSize: this.fmt(p.r * 2), onDelete: this.deleteSel };
}
}
let summary = null;
if (!insp) {
const roster = {};
st.plops.forEach(p => {
const pl = this.plant(p.plantId), o = this.obj(p.objId); if (!o) return;
if (!roster[pl.id]) roster[pl.id] = { name: pl.name, color: pl.color, n: 0, beds: new Set() };
roster[pl.id].n += this.cnt(p); roster[pl.id].beds.add(o.name);
});
const beds = st.objects.filter(o => o.kind === 'bed').length, bags = st.objects.filter(o => o.kind === 'grow_bag').length, bkt = st.objects.filter(o => o.kind === 'container').length;
summary = {
countsText: `${beds} beds · ${bags} grow bags · ${bkt} buckets · ${st.plops.length} plantings`,
roster: Object.values(roster).map((r, i) => ({ key: i, name: r.name, color: r.color, where: r.n + ' in ' + [...r.beds][0] + ([...r.beds].length > 1 ? ' +' + ([...r.beds].length - 1) : '') })),
};
}
const kindsR = this.KINDS.map(k => {
const sty = this.styleFor(k.kind), f = 24 / Math.max(k.w, k.h), armed = st.armK === k.kind;
return {
key: k.kind, label: k.label, sizeText: this.fmt(k.w) + ' × ' + this.fmt(k.h), isRect: k.shape === 'rect', isCircle: k.shape === 'circle',
mx0: -k.w * f / 2, my0: -k.h * f / 2, mw: k.w * f, mh: k.h * f, mr: k.w * f / 2,
fill: sty.fill, stroke: sty.stroke, mdash: sty.dash ? '3 3' : undefined,
btnStyle: { display: 'flex', alignItems: 'center', gap: '10px', padding: '7px 10px', cursor: 'pointer', textAlign: 'left', borderRadius: '999px', fontFamily: 'var(--font-body)', background: armed ? 'var(--color-accent-200)' : 'transparent', border: armed ? '1px solid var(--color-accent-400)' : '1px solid transparent' },
mStyle: { display: 'flex', alignItems: 'center', gap: '8px', padding: '10px 14px', minHeight: '44px', flex: 'none', cursor: 'pointer', borderRadius: '999px', fontFamily: 'var(--font-body)', background: armed ? 'var(--color-accent-200)' : 'var(--color-neutral-100)', border: armed ? '1px solid var(--color-accent-400)' : '1px solid var(--color-divider)' },
onClick: () => this.setState({ armK: armed ? null : k.kind, armP: null, sel: null, ghost: null }),
onDrag: (e) => e.dataTransfer.setData('text/plain', 'kind:' + k.kind),
};
});
const ql = st.q.trim().toLowerCase();
const plantsR = this.PLANTS.filter(pl => !ql || pl.name.toLowerCase().includes(ql)).map(pl => {
const armed = st.armP === pl.id;
return {
key: pl.id, name: pl.name, color: pl.color, spacingText: this.fmt(pl.spacing) + ' apart',
btnStyle: { display: 'flex', alignItems: 'center', gap: '9px', padding: '8px 12px', cursor: 'pointer', textAlign: 'left', borderRadius: '999px', fontFamily: 'var(--font-body)', background: armed ? 'var(--color-accent-200)' : 'var(--color-bg)', border: armed ? '1px solid var(--color-accent-400)' : '1px solid var(--color-divider)' },
mStyle: { display: 'flex', alignItems: 'center', gap: '8px', padding: '10px 14px', minHeight: '44px', flex: 'none', cursor: 'pointer', borderRadius: '999px', fontFamily: 'var(--font-body)', background: armed ? 'var(--color-accent-200)' : 'var(--color-neutral-100)', border: armed ? '1px solid var(--color-accent-400)' : '1px solid var(--color-divider)' },
onClick: () => this.setState({ armP: armed ? null : pl.id, armK: null, sel: null }),
onDrag: (e) => e.dataTransfer.setData('text/plain', 'plant:' + pl.id),
};
});
let ghostR = null;
if (st.ghost && st.armK) {
const kd = this.KINDS.find(k => k.kind === st.armK), sty = this.styleFor(kd.kind);
ghostR = { transform: `translate(${st.ghost.x} ${st.ghost.y})`, isRect: kd.shape === 'rect', isCircle: kd.shape === 'circle', x0: -kd.w / 2, y0: -kd.h / 2, w: kd.w, h: kd.h, r: kd.w / 2, fill: sty.fill, sw: 2 / s, dash: `${7 / s} ${5 / s}` };
}
const tabs = [['plot', 'Plot'], ['journal', 'Journal'], ['history', 'History'], ['chat', 'Assistant']];
const tabsR = tabs.map(([id, label]) => ({
key: id, label,
style: { flex: 1, padding: '8px 4px', cursor: 'pointer', border: 'none', borderRadius: '999px', fontFamily: 'var(--font-body)', fontSize: '12.5px', fontWeight: 700, background: st.tab === id ? 'var(--color-accent-200)' : 'transparent', color: st.tab === id ? 'var(--color-accent-800)' : 'var(--p-ink-soft)' },
onClick: () => this.setState({ tab: id }),
}));
const seasonsR = ['2025', '2026', '2027 plan'].map(sn => ({
key: sn, label: sn,
style: { padding: '5px 13px', cursor: 'pointer', border: 'none', borderRadius: '999px', whiteSpace: 'nowrap', fontFamily: 'var(--font-body)', fontSize: '12.5px', fontWeight: 700, background: st.season === sn ? 'var(--color-neutral-100)' : 'transparent', color: st.season === sn ? 'var(--color-text)' : 'var(--p-ink-soft)', boxShadow: st.season === sn ? 'var(--shadow-sm)' : 'none' },
onClick: () => this.setState({ season: sn }),
}));
const msgsR = st.msgs.map((m, i) => ({
key: i, txt: m.txt,
style: m.who === 'u'
? { alignSelf: 'flex-end', maxWidth: '85%', background: 'var(--color-accent-200)', color: 'var(--color-accent-900)', borderRadius: '18px 18px 4px 18px', padding: '9px 13px', fontSize: '13px', lineHeight: 1.45 }
: { alignSelf: 'flex-start', maxWidth: '90%', background: 'var(--color-bg)', border: '1px solid var(--color-divider)', borderRadius: '18px 18px 18px 4px', padding: '9px 13px', fontSize: '13px', lineHeight: 1.45 },
}));
const banner = st.season === '2025' ? '2025 is a past season — read-only.'
: st.season === '2027 plan' ? 'Editing the 2027 plan — a separate copy. 2026 stays untouched.' : null;
const modes = [
['build', 'Build', 'M2 22v-5l5-5 5 5-5 5z', 'M9.5 14.5 16 8', 'm17 2 5 5-.5.5a3.53 3.53 0 0 1-5 0v0a3.53 3.53 0 0 1 0-5L17 2'],
['plants', 'Plants', 'M7 20h10', 'M10 20c5.5-2.5.8-6.4 3-10', 'M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z'],
['journal', 'Journal', 'M2 6h4', 'M2 10h4M2 14h4M2 18h4', 'M8 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z'],
['chat', 'Assistant', 'M7.9 20A9 9 0 1 0 4 16.1L2 22Z', '', ''],
];
const modesR = modes.map(([id, label, d1, d2, d3]) => {
const active = st.mode === id && !st.sel;
return {
key: id, label, d1, d2, d3,
style: { flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '3px', padding: '8px 4px', minHeight: '52px', cursor: 'pointer', border: 'none', borderRadius: '16px', fontFamily: 'var(--font-body)', background: active ? 'var(--color-accent-200)' : 'transparent', color: active ? 'var(--color-accent-800)' : 'var(--p-ink-soft)' },
onClick: () => this.setState(s2 => ({ mode: (s2.mode === id && (id === 'journal' || id === 'chat')) ? 'build' : id, sel: null, armK: null, armP: id === 'plants' ? s2.armP : null })),
};
});
const mPeekInsp = isMobile && !!insp;
const mPeekJournal = isMobile && !insp && st.mode === 'journal';
const mPeekChat = isMobile && !insp && st.mode === 'chat';
const mPeek = mPeekInsp || mPeekJournal || mPeekChat;
const mStripKinds = isMobile && !mPeek && st.mode === 'build';
const mStripPlants = isMobile && !mPeek && st.mode === 'plants';
return {
...this.themeVals(),
rootRef: this.rootRef, svgRef: this.svgRef, isMobile, isDesktop,
gw: this.GW, gh: this.GH, gridMinor: showGrid ? this.gridMinor : '', gridMajor: showGrid ? this.gridMajor : '',
borderW: 3 / s, hairW: 1 / s, labelsLayer: labels,
svgStyle: { display: 'block', touchAction: 'none', cursor: st.armK || st.armP ? 'crosshair' : 'default' },
viewStyle: { transform: `translate(${st.tx}px, ${st.ty}px) scale(${s})`, transformOrigin: '0 0', transition: st.anim ? 'transform .48s cubic-bezier(.22,.85,.3,1)' : 'none' },
objectsR, plopsR, selO, ghostR, kindsR, plantsR, insp, summary,
onCanvasDown: this.onCanvasDown, onCanvasMove: this.onCanvasMove, onCanvasUp: this.onCanvasUp,
onDragOver: this.onDragOver, onDrop: this.onDrop,
notFocus: !st.focus, focusName: focused ? focused.name : null, onBack: this.back,
q: st.q, onQ: (e) => this.setState({ q: e.target.value }),
zoomIn: this.zoomIn, zoomOut: this.zoomOut, zoomFit: this.zoomFit,
onUndo: this.onUndo, undoDisabled: !st.undo.length,
tabsR, seasonsR, banner,
tabPlot: st.tab === 'plot', tabJournal: st.tab === 'journal', tabHistory: st.tab === 'history', tabChat: st.tab === 'chat',
entriesR: st.entries.map((e, i) => ({ key: i, ...e })),
jr: st.jr, onJr: (e) => this.setState({ jr: e.target.value }), onAddJr: this.addJournal,
onJrKey: (e) => { if (e.key === 'Enter') this.addJournal(); },
jrPlaceholder: focused ? 'Note something about ' + focused.name + '…' : 'Note something…',
historyR: [...st.undo].reverse().map((h, i) => ({ key: i, label: h.label, when: h.when })),
historyEmpty: !st.undo.length,
msgsR, chat: st.chat, onChat: (e) => this.setState({ chat: e.target.value }), onSend: this.sendChat,
onChatKey: (e) => { if (e.key === 'Enter') this.sendChat(); },
mTitle: focused ? focused.name : 'Home Garden',
seasonShort: st.season === '2027 plan' ? '27 plan' : st.season.slice(2),
onCycleSeason: () => { const order = ['2025', '2026', '2027 plan']; this.setState(s2 => ({ season: order[(order.indexOf(s2.season) + 1) % 3] })); },
mPeek, mPeekInsp, mPeekJournal, mPeekChat,
mPeekTitle: mPeekInsp ? (insp.isObj ? 'Selected' : 'Planting') : mPeekJournal ? 'Journal' : 'Assistant',
onPeekClose: () => this.setState(s2 => ({ sel: null, mode: (s2.mode === 'journal' || s2.mode === 'chat') ? 'build' : s2.mode })),
mStrip: mStripKinds || mStripPlants, mStripKinds, mStripPlants, mStripDone: mStripPlants && !!st.focus,
modesR,
};
}
}
</script>
</body>
</html>