added claude design's ui framework

This commit is contained in:
2026-08-22 18:13:36 -04:00
parent cac26286b1
commit 18b36870d4
23 changed files with 2446 additions and 0 deletions
@@ -0,0 +1,901 @@
<!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>
@@ -0,0 +1,236 @@
<!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">
<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{margin:0;min-height:100%}
a{color:var(--color-accent)} a:hover{color:var(--color-accent-600)}
</style>
</helmet>
<div data-screen-label="Gardens" style="min-height:100vh;background:var(--color-bg);font-family:var(--font-body);color:var(--color-text)">
<nav class="nav">
<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="max-width:1080px;margin:0 auto;padding:28px 24px 56px">
<div style="display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:20px">
<h2 style="margin:0">Gardens</h2>
<span style="font-size:14px;color:var(--p-ink-mute);font-weight:600">Every plot you tend — and the ones you're scheming</span>
<button class="btn btn-primary" style="border-radius:999px;margin-left:auto" onClick="{{ onNew }}">
<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>
New garden
</button>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:18px">
<sc-for list="{{ cards }}" as="g" hint-placeholder-count="3">
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);overflow:hidden;display:flex;flex-direction:column" style-hover="box-shadow:var(--shadow-md)">
<a href="Pansy Editor.dc.html" style="display:block;background:var(--p-field);border-bottom:1px solid var(--color-divider);text-decoration:none">
<svg viewBox="{{ g.vb }}" style="display:block;width:100%;height:150px">
<sc-for list="{{ g.thumb }}" as="t" hint-placeholder-count="4">
<sc-if value="{{ t.isRect }}" hint-placeholder-val="{{ true }}"><rect x="{{ t.x }}" y="{{ t.y }}" width="{{ t.w }}" height="{{ t.h }}" rx="{{ t.rr }}" fill="{{ t.fill }}" stroke="{{ t.stroke }}" stroke-width="{{ t.sw }}" stroke-dasharray="{{ t.dash }}"></rect></sc-if>
<sc-if value="{{ t.isCircle }}" hint-placeholder-val="{{ false }}"><circle cx="{{ t.cx }}" cy="{{ t.cy }}" r="{{ t.r }}" fill="{{ t.fill }}" stroke="{{ t.stroke }}" stroke-width="{{ t.sw }}"></circle></sc-if>
</sc-for>
</svg>
</a>
<div style="padding:16px 18px;display:flex;flex-direction:column;gap:8px;flex:1">
<div style="display:flex;align-items:center;gap:8px">
<span style="font-family:var(--font-heading);font-size:18px">{{ g.name }}</span>
<sc-if value="{{ g.planTag }}" hint-placeholder-val="{{ false }}"><span class="tag tag-accent" style="border-radius:999px">{{ g.planTag }}</span></sc-if>
<span style="font-size:12.5px;color:var(--p-ink-mute);font-weight:600;margin-left:auto">{{ g.sizeText }}</span>
</div>
<div style="font-size:13px;color:var(--p-ink-soft);line-height:1.5">{{ g.meta }}</div>
<sc-if value="{{ g.sharedText }}" hint-placeholder-val="{{ false }}">
<div style="font-size:12px;color:var(--color-accent-2-700);font-weight:600">{{ g.sharedText }}</div>
</sc-if>
<div style="display:flex;gap:8px;margin-top:auto;padding-top:8px">
<a href="Pansy Editor.dc.html" class="btn btn-primary" style="border-radius:999px;flex:1;text-decoration:none">Open</a>
<button class="btn btn-icon btn-secondary" style="border-radius:999px" title="Share" onClick="{{ g.onShare }}">
<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="18" cy="5" r="3"></circle><circle cx="6" cy="12" r="3"></circle><circle cx="18" cy="19" r="3"></circle><path d="m8.6 10.6 6.8-3.9"></path><path d="m8.6 13.4 6.8 3.9"></path></svg>
</button>
<button class="btn btn-icon btn-secondary" style="border-radius:999px" title="Copy — plan a season from it" onClick="{{ g.onCopy }}">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg>
</button>
<button class="btn btn-icon btn-secondary" style="border-radius:999px" title="Delete" onClick="{{ g.onDelete }}">
<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>
</div>
</div>
</sc-for>
</div>
</div>
<sc-if value="{{ dlgNew }}" hint-placeholder-val="{{ false }}">
<div class="dialog-backdrop" style="position:fixed;inset:0;background:color-mix(in srgb, #201e1d 40%, transparent);display:flex;align-items:center;justify-content:center;padding:20px;z-index:50" onClick="{{ onCloseDlg }}">
<div class="dialog" style="background:var(--color-neutral-100);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);padding:26px;width:min(420px,100%);display:flex;flex-direction:column;gap:14px" onClick="{{ stop }}">
<h3 style="margin:0;font-size:22px">A new garden</h3>
<div class="field"><label>Name</label><input class="input" style="border-radius:999px" value="{{ nName }}" onChange="{{ onNName }}" placeholder="Back forty"></div>
<div style="display:flex;gap:10px">
<div class="field" style="flex:1"><label>Width (ft)</label><input class="input" style="border-radius:999px" type="number" value="{{ nW }}" onChange="{{ onNW }}"></div>
<div class="field" style="flex:1"><label>Depth (ft)</label><input class="input" style="border-radius:999px" type="number" value="{{ nH }}" onChange="{{ onNH }}"></div>
</div>
<div style="font-size:12px;color:var(--p-ink-mute)">Stored in centimeters under the hood — feet are just how you talk.</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn btn-secondary" style="border-radius:999px" onClick="{{ onCloseDlg }}">Never mind</button>
<button class="btn btn-primary" style="border-radius:999px" onClick="{{ onCreate }}">Break ground</button>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ dlgShare }}" hint-placeholder-val="{{ false }}">
<div class="dialog-backdrop" style="position:fixed;inset:0;background:color-mix(in srgb, #201e1d 40%, transparent);display:flex;align-items:center;justify-content:center;padding:20px;z-index:50" onClick="{{ onCloseDlg }}">
<div class="dialog" style="background:var(--color-neutral-100);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);padding:26px;width:min(440px,100%);display:flex;flex-direction:column;gap:14px" onClick="{{ stop }}">
<h3 style="margin:0;font-size:22px">Share {{ shareName }}</h3>
<div style="display:flex;gap:8px">
<input class="input" style="border-radius:999px" placeholder="[email protected]" value="{{ shEmail }}" onChange="{{ onShEmail }}">
<button class="btn btn-primary" style="border-radius:999px;flex:none" onClick="{{ onInvite }}">Invite</button>
</div>
<sc-for list="{{ shares }}" as="sh" hint-placeholder-count="1">
<div style="display:flex;align-items:center;gap:10px;background:var(--color-bg);border:1px solid var(--color-divider);border-radius:999px;padding:8px 8px 8px 16px">
<span style="font-size:13px;font-weight:600">{{ sh.email }}</span>
<button onClick="{{ sh.onRole }}" class="tag tag-accent-2" style="border-radius:999px;border:none;cursor:pointer;margin-left:auto;font-family:var(--font-body)" title="Click to switch role">{{ sh.role }}</button>
</div>
</sc-for>
<div class="hr" style="margin:2px 0"></div>
<div style="display:flex;align-items:center;gap:10px">
<span style="font-size:13px;font-weight:600">Read-only link</span>
<button onClick="{{ onToggleLink }}" style="{{ linkToggleStyle }}">{{ linkToggleLabel }}</button>
</div>
<sc-if value="{{ linkOn }}" hint-placeholder-val="{{ false }}">
<div style="font-size:12px;color:var(--p-ink-soft);background:var(--color-bg);border:1px dashed var(--color-divider);border-radius:999px;padding:8px 14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">pansy.example.com/p/gd_7Kx2mQ…</div>
</sc-if>
<div style="display:flex;justify-content:flex-end">
<button class="btn btn-secondary" style="border-radius:999px" onClick="{{ onCloseDlg }}">Done</button>
</div>
</div>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script>
class Component extends DCLogic {
constructor(props) {
super(props);
const bedS = { fill: 'var(--p-bed-fill)', stroke: 'var(--p-bed-stroke)' };
const th = (isRect, x, y, w, h, sty, dash) => isRect
? { isRect: true, x: x - w / 2, y: y - h / 2, w, h, rr: Math.min(14, w * 0.14), fill: sty.fill, stroke: sty.stroke, sw: 5, dash }
: { isCircle: true, cx: x, cy: y, r: w / 2, fill: sty.fill, stroke: sty.stroke, sw: 5 };
const dot = (x, y, r, c) => ({ isCircle: true, cx: x, cy: y, r, fill: c, stroke: 'none', sw: 0 });
const homeThumb = [
{ isRect: true, x: 8, y: 8, w: 1203, h: 837, rr: 18, fill: 'var(--p-field)', stroke: 'var(--p-tree-stroke)', sw: 8 },
th(true, 560, 285, 1010, 75, { fill: 'var(--p-path-fill)', stroke: 'var(--p-path-stroke)' }, '6 10'),
th(true, 110, 140, 91, 183, bedS), th(true, 320, 140, 91, 183, bedS), th(true, 530, 140, 91, 183, bedS), th(true, 740, 140, 91, 183, bedS), th(true, 950, 140, 91, 183, bedS),
th(true, 190, 392, 244, 61, bedS), th(true, 190, 502, 244, 61, bedS), th(true, 190, 612, 244, 61, bedS), th(true, 190, 722, 244, 61, bedS),
th(false, 1090, 110, 40, 0, { fill: 'var(--p-bag-fill)', stroke: 'var(--p-bag-stroke)' }), th(false, 1090, 165, 40, 0, { fill: 'var(--p-bag-fill)', stroke: 'var(--p-bag-stroke)' }), th(false, 1090, 220, 40, 0, { fill: 'var(--p-bag-fill)', stroke: 'var(--p-bag-stroke)' }),
dot(110, 90, 26, '#c8553d'), dot(110, 150, 26, '#c8553d'), dot(110, 200, 26, '#c8553d'),
dot(300, 110, 32, '#6f8f4f'), dot(340, 180, 32, '#6f8f4f'),
dot(510, 105, 38, '#46683c'), dot(550, 180, 38, '#46683c'),
dot(740, 140, 38, '#5f8f45'),
{ isRect: true, x: 80, y: 374, w: 220, h: 36, rr: 18, fill: '#97a97c', stroke: 'none', sw: 0 },
{ isRect: true, x: 80, y: 484, w: 220, h: 36, rr: 18, fill: '#b2622d', stroke: 'none', sw: 0 },
{ isRect: true, x: 80, y: 594, w: 220, h: 36, rr: 18, fill: '#d9912f', stroke: 'none', sw: 0 },
dot(1090, 110, 13, '#c2913a'), dot(1090, 165, 13, '#c2913a'), dot(1090, 220, 13, '#c2913a'),
];
const balconyThumb = [
{ isRect: true, x: 4, y: 4, w: 358, h: 175, rr: 12, fill: 'var(--p-field)', stroke: 'var(--p-tree-stroke)', sw: 4 },
th(false, 70, 90, 40, 0, { fill: 'var(--p-bag-fill)', stroke: 'var(--p-bag-stroke)' }), th(false, 130, 90, 40, 0, { fill: 'var(--p-bag-fill)', stroke: 'var(--p-bag-stroke)' }),
th(false, 195, 90, 55, 0, { fill: 'var(--p-bkt-fill)', stroke: 'var(--p-bkt-stroke)' }),
th(true, 295, 92, 100, 50, bedS),
dot(70, 90, 12, '#c8553d'), dot(130, 90, 12, '#5f8f45'), dot(195, 90, 14, '#b2622d'),
];
this.nid = 10;
this.state = {
cards: [
{ id: 1, name: 'Home Garden', vb: '0 0 1219 853', thumb: homeThumb, sizeText: '40\u2032 \u00d7 28\u2032', meta: '9 beds \u00b7 3 bags \u00b7 2 buckets \u00b7 136 plantings \u00b7 tended since 2024', sharedText: 'Shared with lauren@ \u00b7 editor', planTag: null },
{ id: 2, name: 'Home Garden \u2014 2027', vb: '0 0 1219 853', thumb: homeThumb, sizeText: '40\u2032 \u00d7 28\u2032', meta: 'A copy to scheme next season in \u2014 rearrange freely, 2026 stays put.', sharedText: null, planTag: 'plan' },
{ id: 3, name: 'Balcony', vb: '0 0 366 183', thumb: balconyThumb, sizeText: '12\u2032 \u00d7 6\u2032', meta: '2 bags \u00b7 1 bucket \u00b7 1 rail bed \u00b7 the overflow department', sharedText: null, planTag: null },
],
dlg: null, nName: '', nW: 20, nH: 12, shEmail: '', shareId: null, linkOn: true,
shares: [{ email: '[email protected]', role: 'editor' }],
};
}
componentDidMount() { this.themeMount(); }
componentWillUnmount() { this._unwatchTheme && this._unwatchTheme(); }
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;
const stop = (e) => e.stopPropagation();
return {
...this.themeVals(),
cards: st.cards.map(g => ({
...g, key: g.id,
thumb: g.thumb.map((t, i) => ({ key: i, ...t })),
onShare: () => this.setState({ dlg: 'share', shareId: g.id }),
onCopy: () => this.setState(s2 => {
const n = { ...g, id: this.nid++, name: g.name.replace(/ — \d{4}$/, '') + ' — copy', planTag: 'plan', sharedText: null, meta: 'A fresh copy — objects and active plantings came along; shares didn\u2019t.' };
return { cards: [...s2.cards, n] };
}),
onDelete: () => this.setState(s2 => ({ cards: s2.cards.filter(c => c.id !== g.id) })),
})),
dlgNew: st.dlg === 'new', dlgShare: st.dlg === 'share',
shareName: (st.cards.find(c => c.id === st.shareId) || {}).name || '',
onNew: () => this.setState({ dlg: 'new', nName: '', nW: 20, nH: 12 }),
onCloseDlg: () => this.setState({ dlg: null }),
stop,
nName: st.nName, onNName: (e) => this.setState({ nName: e.target.value }),
nW: st.nW, onNW: (e) => this.setState({ nW: e.target.value }),
nH: st.nH, onNH: (e) => this.setState({ nH: e.target.value }),
onCreate: () => this.setState(s2 => ({
dlg: null,
cards: [...s2.cards, {
id: this.nid++, name: s2.nName.trim() || 'New garden', vb: '0 0 366 183',
thumb: [{ key: 0, isRect: true, x: 4, y: 4, w: 358, h: 175, rr: 12, fill: 'var(--p-field)', stroke: 'var(--p-tree-stroke)', sw: 4 }],
sizeText: s2.nW + '\u2032 \u00d7 ' + s2.nH + '\u2032', meta: 'Bare ground \u2014 drag your first bed on.', sharedText: null, planTag: null,
}],
})),
shEmail: st.shEmail, onShEmail: (e) => this.setState({ shEmail: e.target.value }),
onInvite: () => { const em = st.shEmail.trim(); if (!em) return; this.setState(s2 => ({ shares: [...s2.shares, { email: em, role: 'viewer' }], shEmail: '' })); },
shares: st.shares.map((sh, i) => ({
key: i, email: sh.email, role: sh.role,
onRole: () => this.setState(s2 => ({ shares: s2.shares.map((x, j) => j === i ? { ...x, role: x.role === 'viewer' ? 'editor' : 'viewer' } : x) })),
})),
linkOn: st.linkOn,
onToggleLink: () => this.setState(s2 => ({ linkOn: !s2.linkOn })),
linkToggleLabel: st.linkOn ? 'On' : 'Off',
linkToggleStyle: { marginLeft: 'auto', cursor: 'pointer', border: 'none', borderRadius: '999px', padding: '5px 16px', fontFamily: 'var(--font-body)', fontSize: '12.5px', fontWeight: 700, background: st.linkOn ? 'var(--color-accent-2-300)' : 'var(--color-neutral-300)', color: st.linkOn ? 'var(--color-accent-2-800)' : 'var(--p-ink-soft)' },
};
}
}
</script>
</body>
</html>
@@ -0,0 +1,69 @@
<!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">
<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{margin:0;min-height:100%}
a{color:var(--color-accent)} a:hover{color:var(--color-accent-600)}
</style>
</helmet>
<div data-screen-label="Login" style="min-height:100vh;background:var(--color-bg);font-family:var(--font-body);color:var(--color-text);display:flex;align-items:center;justify-content:center;padding:24px;position:relative;overflow:hidden">
<div style="position:absolute;width:520px;height:520px;border-radius:999px;background:var(--color-accent-2-200);top:-180px;right:-140px;opacity:0.55"></div>
<div style="position:absolute;width:340px;height:340px;border-radius:999px;background:var(--color-accent-200);bottom:-140px;left:-100px;opacity:0.5"></div>
<button class="btn btn-icon btn-secondary" style="position:absolute;top:18px;right:18px;border-radius:999px;background:var(--color-neutral-100)" 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>
<div style="width:min(400px,100%);display:flex;flex-direction:column;gap:22px;position:relative">
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center">
<svg width="44" height="44" 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>
<h1 style="margin:0;font-size:40px">pansy</h1>
<p style="margin:0;font-size:14.5px;color:var(--p-ink-soft)">Plan the plot. Keep the notes. Grow the thing.</p>
</div>
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);box-shadow:var(--shadow-md);padding:26px;display:flex;flex-direction:column;gap:14px">
<div class="field"><label>Email</label><input class="input" style="border-radius:999px;font-size:16px" type="email" placeholder="[email protected]"></div>
<div class="field"><label>Password</label><input class="input" style="border-radius:999px;font-size:16px" type="password" placeholder="••••••••••"></div>
<a href="Pansy Gardens.dc.html" class="btn btn-primary btn-block" style="border-radius:999px;text-decoration:none;margin-top:2px">Into the garden</a>
<div style="display:flex;align-items:center;gap:12px;margin:2px 0">
<span style="flex:1;height:1px;background:var(--color-divider)"></span>
<span style="font-size:12px;color:var(--p-ink-mute)">or</span>
<span style="flex:1;height:1px;background:var(--color-divider)"></span>
</div>
<a href="Pansy Gardens.dc.html" class="btn btn-secondary btn-block" style="border-radius:999px;text-decoration:none;gap:8px">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
Sign in with Authentik
</a>
</div>
<p style="margin:0;text-align:center;font-size:13px;color:var(--p-ink-soft)">New here? <a href="Pansy Gardens.dc.html" style="font-weight:700">Create an account</a> — the first one becomes admin.</p>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script>
class Component extends DCLogic {
state = { themePref: 'system' };
componentDidMount() {
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')); });
}
componentWillUnmount() { this._unwatchTheme && this._unwatchTheme(); }
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 }); };
renderVals() {
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 };
}
}
</script>
</body>
</html>
@@ -0,0 +1,33 @@
<!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">
<link rel="stylesheet" href="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/styles.css">
<script src="pansy-theme.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{margin:0;min-height:100%}
a{color:var(--color-accent)} a:hover{color:var(--color-accent-600)}
</style>
</helmet>
<div data-screen-label="Phone preview" style="min-height:100vh;background:var(--color-bg);font-family:var(--font-body);color:var(--color-text);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;padding:28px">
<div style="display:flex;align-items:baseline;gap:12px">
<h3 style="margin:0">pansy in your pocket</h3>
<span style="font-size:13px;color:var(--p-ink-mute);font-weight:600">same editor, one column — pinch to zoom, tap a bed, plant from the strip</span>
</div>
<x-import component-from-global-scope="IOSDevice" from="./ios-frame.jsx" hint-size="430px,880px">
<div style="height:100%;padding-top:60px;box-sizing:border-box">
<dc-import name="Pansy Editor" layout="phone" hint-size="100%,100%" style="width:100%;height:100%"></dc-import>
</div>
</x-import>
</div>
</x-dc>
</body>
</html>
@@ -0,0 +1,254 @@
<!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">
<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{margin:0;min-height:100%}
a{color:var(--color-accent)} a:hover{color:var(--color-accent-600)}
</style>
</helmet>
<div data-screen-label="Plants" style="min-height:100vh;background:var(--color-bg);font-family:var(--font-body);color:var(--color-text)">
<nav class="nav">
<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">Gardens</a>
<a href="Pansy Plants.dc.html" aria-current="page">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="max-width:1080px;margin:0 auto;padding:28px 24px 56px">
<div style="display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:18px">
<h2 style="margin:0">Plants</h2>
<span style="font-size:14px;color:var(--p-ink-mute);font-weight:600">The built-ins plus everything you've added</span>
<span style="margin-left:auto;display:flex;gap:8px">
<button class="btn btn-secondary" style="border-radius:999px" onClick="{{ onScan }}">
<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="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"></path><circle cx="12" cy="13" r="3"></circle></svg>
Scan a packet
</button>
<button class="btn btn-primary" style="border-radius:999px" onClick="{{ onAdd }}">
<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>
Add a plant
</button>
</span>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:20px">
<input class="input" style="border-radius:999px;max-width:260px" placeholder="Find a plant…" value="{{ q }}" onChange="{{ onQ }}">
<sc-for list="{{ cats }}" as="c">
<button onClick="{{ c.onClick }}" style="{{ c.style }}">{{ c.label }}</button>
</sc-for>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px">
<sc-for list="{{ cardsR }}" as="p" hint-placeholder-count="8">
<div onClick="{{ p.onClick }}" style="{{ p.cardStyle }}" style-hover="box-shadow:var(--shadow-md)">
<div style="display:flex;align-items:center;gap:12px">
<svg width="40" height="40" style="flex:none"><circle cx="20" cy="20" r="20" fill="{{ p.color }}"></circle></svg>
<span style="position:absolute;width:40px;text-align:center;color:#fffaf1;font-family:var(--font-heading);font-size:17px;pointer-events:none">{{ p.letter }}</span>
<span style="display:flex;flex-direction:column;gap:1px;min-width:0">
<span style="font-family:var(--font-heading);font-size:16.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ p.name }}</span>
<span style="font-size:12px;color:var(--p-ink-mute);font-weight:600">{{ p.sub }}</span>
</span>
<sc-if value="{{ p.builtin }}" hint-placeholder-val="{{ false }}"><span class="tag tag-neutral" style="border-radius:999px;margin-left:auto;flex:none">built-in</span></sc-if>
</div>
<div style="font-size:12.5px;color:var(--p-ink-soft);margin-top:10px">{{ p.lotText }}</div>
<sc-if value="{{ p.open }}" hint-placeholder-val="{{ false }}">
<div style="margin-top:10px;display:flex;flex-direction:column;gap:8px">
<sc-for list="{{ p.lots }}" as="l" hint-placeholder-count="1">
<div style="background:var(--color-bg);border:1px solid var(--color-divider);border-radius:var(--radius-md);padding:10px 13px">
<div style="display:flex;gap:6px;align-items:center">
<span style="font-size:12.5px;font-weight:700">{{ l.vendor }}</span>
<span style="font-size:11.5px;color:var(--p-ink-mute);margin-left:auto">packed for {{ l.year }}</span>
</div>
<div style="font-size:12px;color:var(--p-ink-soft);margin-top:3px">{{ l.detail }}</div>
</div>
</sc-for>
<sc-if value="{{ p.noLots }}"><div style="font-size:12.5px;color:var(--p-ink-mute)">No seed lots yet — scan a packet to add one.</div></sc-if>
</div>
</sc-if>
</div>
</sc-for>
</div>
</div>
<sc-if value="{{ dlgScan }}" hint-placeholder-val="{{ false }}">
<div style="position:fixed;inset:0;background:color-mix(in srgb, #201e1d 40%, transparent);display:flex;align-items:center;justify-content:center;padding:20px;z-index:50" onClick="{{ onCloseDlg }}">
<div style="background:var(--color-neutral-100);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);padding:26px;width:min(560px,100%);display:flex;flex-direction:column;gap:14px" onClick="{{ stop }}">
<h3 style="margin:0;font-size:22px">Scan a seed packet</h3>
<sc-if value="{{ scanStep0 }}" hint-placeholder-val="{{ true }}">
<div style="border:2px dashed var(--color-neutral-400);border-radius:var(--radius-lg);padding:36px 20px;display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center">
<svg width="30" height="30" 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="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"></path><circle cx="12" cy="13" r="3"></circle></svg>
<div style="font-size:14px;font-weight:600">Photograph the packet — front is enough</div>
<div style="font-size:12.5px;color:var(--p-ink-mute);max-width:36ch">The vision model reads it into fields. It only reads; nothing is saved until you confirm.</div>
<button class="btn btn-primary" style="border-radius:999px;margin-top:4px" onClick="{{ onScanGo }}">Use a sample photo</button>
</div>
</sc-if>
<sc-if value="{{ scanStep1 }}" hint-placeholder-val="{{ false }}">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div class="field"><label>Variety</label><input class="input" style="border-radius:999px" value="Garlic — Music"></div>
<div class="field"><label>Vendor</label><input class="input" style="border-radius:999px" value="Keene Organics"></div>
<div class="field"><label>Packed for</label><input class="input" style="border-radius:999px" value="2026"></div>
<div class="field"><label>Quantity</label><input class="input" style="border-radius:999px" value="50 cloves"></div>
</div>
<div style="font-size:12.5px;font-weight:700;color:var(--p-ink-soft);margin-top:2px">Match it to your catalog — nothing is auto-created:</div>
<sc-for list="{{ matches }}" as="m">
<button onClick="{{ m.onClick }}" style="{{ m.style }}">
<span style="font-size:13.5px;font-weight:700">{{ m.label }}</span>
<span style="font-size:12px;color:var(--p-ink-mute);margin-left:auto">{{ m.sub }}</span>
</button>
</sc-for>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:4px">
<button class="btn btn-secondary" style="border-radius:999px" onClick="{{ onCloseDlg }}">Cancel</button>
<button class="btn btn-primary" style="border-radius:999px" onClick="{{ onConfirmScan }}">Add the lot</button>
</div>
</sc-if>
</div>
</div>
</sc-if>
<sc-if value="{{ dlgAdd }}" hint-placeholder-val="{{ false }}">
<div style="position:fixed;inset:0;background:color-mix(in srgb, #201e1d 40%, transparent);display:flex;align-items:center;justify-content:center;padding:20px;z-index:50" onClick="{{ onCloseDlg }}">
<div style="background:var(--color-neutral-100);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);padding:26px;width:min(420px,100%);display:flex;flex-direction:column;gap:14px" onClick="{{ stop }}">
<h3 style="margin:0;font-size:22px">A new plant</h3>
<div class="field"><label>Name</label><input class="input" style="border-radius:999px" value="{{ aName }}" onChange="{{ onAName }}" placeholder="Delicata squash"></div>
<div style="display:flex;gap:10px">
<div class="field" style="flex:1"><label>Category</label>
<select class="input" style="border-radius:999px" value="{{ aCat }}" onChange="{{ onACat }}">
<option value="vegetable">Vegetable</option><option value="herb">Herb</option><option value="flower">Flower</option><option value="fruit">Fruit</option>
</select>
</div>
<div class="field" style="flex:1"><label>Spacing (in)</label><input class="input" style="border-radius:999px" type="number" value="{{ aSp }}" onChange="{{ onASp }}"></div>
</div>
<div class="field"><label>Marker color</label>
<div style="display:flex;gap:8px">
<sc-for list="{{ swatches }}" as="sw">
<button onClick="{{ sw.onClick }}" style="{{ sw.style }}" title="{{ sw.hex }}"></button>
</sc-for>
</div>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn btn-secondary" style="border-radius:999px" onClick="{{ onCloseDlg }}">Never mind</button>
<button class="btn btn-primary" style="border-radius:999px" onClick="{{ onCreatePlant }}">Add it</button>
</div>
</div>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script>
class Component extends DCLogic {
constructor(props) {
super(props);
this.state = {
q: '', cat: 'all', openId: null, dlg: null, scanStep: 0, matchSel: 1,
aName: '', aCat: 'vegetable', aSp: 12, aColor: '#97a97c',
plants: [
{ id: 1, name: 'Garlic', letter: 'G', color: '#97a97c', cat: 'vegetable', spacing: '6″', days: 240, builtin: true, lots: [{ vendor: 'Johnny\u2019s \u2014 Music', year: 2025, detail: '50 cloves \u00b7 ~14 left after this season\u2019s beds' }] },
{ id: 2, name: 'Tomato — Cherokee Purple', letter: 'T', color: '#c8553d', cat: 'vegetable', spacing: '24″', days: 80, builtin: false, lots: [{ vendor: 'Baker Creek', year: 2026, detail: '25 seeds \u00b7 95% germination \u00b7 ~22 left' }] },
{ id: 3, name: 'Cucumber — Marketmore', letter: 'C', color: '#6f8f4f', cat: 'vegetable', spacing: '12″', days: 65, builtin: false, lots: [] },
{ id: 4, name: 'Watermelon — Sugar Baby', letter: 'W', color: '#46683c', cat: 'fruit', spacing: '36″', days: 78, builtin: false, lots: [{ vendor: 'Ferry-Morse', year: 2025, detail: '20 seeds \u00b7 ~16 left' }] },
{ id: 5, name: 'Basil — Genovese', letter: 'B', color: '#5f8f45', cat: 'herb', spacing: '10″', days: 60, builtin: true, lots: [] },
{ id: 6, name: 'Pepper — Jalapeño', letter: 'P', color: '#b2622d', cat: 'vegetable', spacing: '18″', days: 75, builtin: false, lots: [] },
{ id: 7, name: 'Marigold', letter: 'Ma', color: '#d9912f', cat: 'flower', spacing: '8″', days: 50, builtin: true, lots: [] },
{ id: 8, name: 'Melon — Hale\u2019s Best', letter: 'Me', color: '#c2913a', cat: 'fruit', spacing: '36″', days: 85, builtin: false, lots: [] },
{ id: 9, name: 'Carrot — Danvers', letter: 'Cr', color: '#d07a2e', cat: 'vegetable', spacing: '3″', days: 70, builtin: true, lots: [] },
{ id: 10, name: 'Bush bean — Provider', letter: 'Bn', color: '#7c9a55', cat: 'vegetable', spacing: '6″', days: 55, builtin: true, lots: [] },
{ id: 11, name: 'Zinnia', letter: 'Z', color: '#c65a4e', cat: 'flower', spacing: '10″', days: 60, builtin: true, lots: [] },
{ id: 12, name: 'Thyme', letter: 'Th', color: '#6d7f5a', cat: 'herb', spacing: '8″', days: 90, builtin: true, lots: [] },
],
};
this.nid = 100;
}
componentDidMount() { this.themeMount(); }
componentWillUnmount() { this._unwatchTheme && this._unwatchTheme(); }
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;
const ql = st.q.trim().toLowerCase();
const catList = [['all', 'All'], ['vegetable', 'Vegetables'], ['herb', 'Herbs'], ['flower', 'Flowers'], ['fruit', 'Fruit']];
const shown = st.plants.filter(p => (st.cat === 'all' || p.cat === st.cat) && (!ql || p.name.toLowerCase().includes(ql)));
return {
...this.themeVals(),
q: st.q, onQ: (e) => this.setState({ q: e.target.value }),
cats: catList.map(([id, label]) => ({
key: id, label,
style: { cursor: 'pointer', border: '1px solid ' + (st.cat === id ? 'var(--color-accent-400)' : 'var(--color-divider)'), borderRadius: '999px', padding: '7px 16px', fontFamily: 'var(--font-body)', fontSize: '13px', fontWeight: 700, background: st.cat === id ? 'var(--color-accent-200)' : 'transparent', color: st.cat === id ? 'var(--color-accent-800)' : 'var(--p-ink-soft)' },
onClick: () => this.setState({ cat: id }),
})),
cardsR: shown.map(p => ({
key: p.id, name: p.name, letter: p.letter, color: p.color, builtin: p.builtin,
sub: p.cat[0].toUpperCase() + p.cat.slice(1) + ' · ' + p.spacing + ' spacing · ' + p.days + ' days',
lotText: p.lots.length ? p.lots.length + (p.lots.length === 1 ? ' seed lot' : ' seed lots') + ' · click to see' : 'No seed lots · click to expand',
open: st.openId === p.id,
lots: p.lots.map((l, i) => ({ key: i, ...l })),
noLots: !p.lots.length,
cardStyle: { background: 'var(--color-neutral-100)', border: '1px solid ' + (st.openId === p.id ? 'var(--color-accent-400)' : 'var(--color-divider)'), borderRadius: 'var(--radius-lg)', padding: '16px 18px', cursor: 'pointer', position: 'relative' },
onClick: () => this.setState(s2 => ({ openId: s2.openId === p.id ? null : p.id })),
})),
dlgScan: st.dlg === 'scan', dlgAdd: st.dlg === 'add',
scanStep0: st.scanStep === 0, scanStep1: st.scanStep === 1,
onScan: () => this.setState({ dlg: 'scan', scanStep: 0, matchSel: 1 }),
onAdd: () => this.setState({ dlg: 'add', aName: '', aCat: 'vegetable', aSp: 12, aColor: '#97a97c' }),
onCloseDlg: () => this.setState({ dlg: null }),
stop: (e) => e.stopPropagation(),
onScanGo: () => this.setState({ scanStep: 1 }),
matches: [
{ label: 'Garlic (built-in)', sub: 'existing plant \u2014 lot attaches to it' },
{ label: 'Garlic \u2014 Music (yours)', sub: 'best match \u00b7 spacing 6\u2033' },
{ label: 'Create a new plant', sub: 'from the extracted fields' },
].map((m, i) => ({
key: i, ...m,
style: { display: 'flex', alignItems: 'center', gap: '8px', textAlign: 'left', cursor: 'pointer', borderRadius: '999px', padding: '10px 16px', fontFamily: 'var(--font-body)', background: st.matchSel === i ? 'var(--color-accent-200)' : 'var(--color-bg)', border: st.matchSel === i ? '1px solid var(--color-accent-400)' : '1px solid var(--color-divider)' },
onClick: () => this.setState({ matchSel: i }),
})),
onConfirmScan: () => this.setState(s2 => ({
dlg: null,
plants: s2.plants.map(p => p.id === 1 ? { ...p, lots: [...p.lots, { vendor: 'Keene Organics \u2014 Music', year: 2026, detail: '50 cloves \u00b7 untouched' }] } : p),
openId: 1,
})),
aName: st.aName, onAName: (e) => this.setState({ aName: e.target.value }),
aCat: st.aCat, onACat: (e) => this.setState({ aCat: e.target.value }),
aSp: st.aSp, onASp: (e) => this.setState({ aSp: e.target.value }),
swatches: ['#97a97c', '#c8553d', '#5f8f45', '#d9912f', '#b2622d', '#6d7f5a'].map(hex => ({
key: hex, hex,
style: { width: '30px', height: '30px', borderRadius: '999px', cursor: 'pointer', background: hex, border: st.aColor === hex ? '3px solid var(--color-accent)' : '3px solid transparent' },
onClick: () => this.setState({ aColor: hex }),
})),
onCreatePlant: () => this.setState(s2 => ({
dlg: null,
plants: [...s2.plants, { id: this.nid++, name: s2.aName.trim() || 'New plant', letter: (s2.aName.trim()[0] || 'N').toUpperCase(), color: s2.aColor, cat: s2.aCat, spacing: s2.aSp + '″', days: 70, builtin: false, lots: [] }],
})),
};
}
}
</script>
</body>
</html>
@@ -0,0 +1,158 @@
<!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">
<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{margin:0;min-height:100%}
a{color:var(--color-accent)} a:hover{color:var(--color-accent-600)}
</style>
</helmet>
<div data-screen-label="Settings" style="min-height:100vh;background:var(--color-bg);font-family:var(--font-body);color:var(--color-text)">
<nav class="nav">
<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">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" aria-current="page" class="btn btn-icon btn-secondary" style="border-radius:999px;background:var(--color-accent-100)" 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="max-width:720px;margin:0 auto;padding:28px 24px 56px;display:flex;flex-direction:column;gap:18px">
<div style="display:flex;align-items:baseline;gap:12px">
<h2 style="margin:0">Settings</h2>
<span class="tag tag-accent-2" style="border-radius:999px">admin</span>
<span style="font-size:13px;color:var(--p-ink-mute);font-weight:600">Instance-wide · everyone on this pansy</span>
</div>
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);padding:22px 24px;display:flex;flex-direction:column;gap:14px">
<h5 style="margin:0">Appearance</h5>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<span style="display:flex;flex-direction:column;gap:1px;flex:1;min-width:160px">
<span style="font-size:13.5px;font-weight:600">Theme</span>
<span style="font-size:12px;color:var(--p-ink-mute)">System follows your device</span>
</span>
<span style="display:flex;background:var(--color-neutral-200);border-radius:999px;padding:3px">
<sc-for list="{{ themeOpts }}" as="t"><button onClick="{{ t.onClick }}" style="{{ t.style }}">{{ t.label }}</button></sc-for>
</span>
</div>
</div>
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);padding:22px 24px;display:flex;flex-direction:column;gap:14px">
<h5 style="margin:0">Who gets in</h5>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<span style="font-size:13.5px;font-weight:600;flex:1;min-width:160px">Self-service signup</span>
<span style="display:flex;background:var(--color-neutral-200);border-radius:999px;padding:3px">
<sc-for list="{{ regOpts }}" as="r"><button onClick="{{ r.onClick }}" style="{{ r.style }}">{{ r.label }}</button></sc-for>
</span>
</div>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<span style="display:flex;flex-direction:column;gap:1px;flex:1;min-width:160px">
<span style="font-size:13.5px;font-weight:600">Local passwords</span>
<span style="font-size:12px;color:var(--p-ink-mute)">Turn off for pure-Authentik sign-in</span>
</span>
<button onClick="{{ onLocalAuth }}" style="{{ localAuthStyle }}">{{ localAuthLabel }}</button>
</div>
<div class="field"><label>OIDC issuer</label>
<input class="input" style="border-radius:999px" value="https://auth.dudenhoeffer.casa/application/o/pansy/" readOnly>
</div>
<div style="font-size:12px;color:var(--p-ink-mute);line-height:1.5">Client ID and secret live in the environment, not here. The first account ever registered became admin — that's you.</div>
</div>
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);padding:22px 24px;display:flex;flex-direction:column;gap:14px">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<h5 style="margin:0">Garden assistant</h5>
<span class="tag tag-accent-2" style="border-radius:999px">{{ agentStatus }}</span>
</div>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<span style="display:flex;flex-direction:column;gap:1px;flex:1;min-width:160px">
<span style="font-size:13.5px;font-weight:600">Assistant</span>
<span style="font-size:12px;color:var(--p-ink-mute)">Swaps live — no restart</span>
</span>
<button onClick="{{ onAgent }}" style="{{ agentStyle }}">{{ agentLabel }}</button>
</div>
<div class="field"><label>Chat model — blank inherits the environment default</label>
<input class="input" style="border-radius:999px" value="{{ model }}" onChange="{{ onModel }}" placeholder="ollama-cloud/glm-5.2:cloud">
</div>
<div class="field"><label>Vision model — reads seed packets; must be vision-capable</label>
<input class="input" style="border-radius:999px" value="{{ vModel }}" onChange="{{ onVModel }}" placeholder="empty — packet scanning off">
</div>
<div style="font-size:12px;color:var(--p-ink-mute);line-height:1.5">The API key stays in the environment on purpose — a secret in the database would ride along in every backup. Precedence: this page → env → default.</div>
</div>
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);padding:22px 24px;display:flex;flex-direction:column;gap:14px">
<h5 style="margin:0">You</h5>
<div style="display:flex;gap:10px;flex-wrap:wrap">
<div class="field" style="flex:1;min-width:180px"><label>Display name</label><input class="input" style="border-radius:999px" value="{{ dName }}" onChange="{{ onDName }}"></div>
<div class="field" style="flex:1;min-width:180px"><label>Email</label><input class="input" style="border-radius:999px" value="[email protected]" readOnly></div>
</div>
<div style="display:flex;justify-content:flex-end">
<a href="Pansy Login.dc.html" class="btn btn-ghost" style="border-radius:999px;text-decoration:none">Sign out</a>
</div>
</div>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script>
class Component extends DCLogic {
state = { reg: 'closed', localAuth: true, agent: true, model: 'ollama-cloud/glm-5.2:cloud', vModel: 'ollama-cloud/qwen3.5-vl:cloud', dName: 'Steve Dudenhoeffer' };
componentDidMount() { this.themeMount(); }
componentWillUnmount() { this._unwatchTheme && this._unwatchTheme(); }
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;
const toggle = (on) => ({ cursor: 'pointer', border: 'none', borderRadius: '999px', padding: '6px 18px', fontFamily: 'var(--font-body)', fontSize: '12.5px', fontWeight: 700, background: on ? 'var(--color-accent-2-300)' : 'var(--color-neutral-300)', color: on ? 'var(--color-accent-2-800)' : 'var(--p-ink-soft)' });
return {
...this.themeVals(),
themeOpts: ['system', 'light', 'dark'].map(v => ({
key: v, label: v[0].toUpperCase() + v.slice(1),
style: { padding: '5px 16px', cursor: 'pointer', border: 'none', borderRadius: '999px', fontFamily: 'var(--font-body)', fontSize: '12.5px', fontWeight: 700, background: (st.themePref || 'system') === v ? 'var(--color-neutral-100)' : 'transparent', color: (st.themePref || 'system') === v ? 'var(--color-text)' : 'var(--p-ink-soft)', boxShadow: (st.themePref || 'system') === v ? 'var(--shadow-sm)' : 'none' },
onClick: () => { const PT = window.PansyTheme; if (!PT) return; PT.set(v); PT.apply(PT.isDark(v)); this.setState({ themePref: v }); },
})),
regOpts: [['open', 'Open'], ['closed', 'Closed']].map(([id, label]) => ({
key: id, label,
style: { padding: '5px 16px', cursor: 'pointer', border: 'none', borderRadius: '999px', fontFamily: 'var(--font-body)', fontSize: '12.5px', fontWeight: 700, background: st.reg === id ? 'var(--color-neutral-100)' : 'transparent', color: st.reg === id ? 'var(--color-text)' : 'var(--p-ink-soft)', boxShadow: st.reg === id ? 'var(--shadow-sm)' : 'none' },
onClick: () => this.setState({ reg: id }),
})),
onLocalAuth: () => this.setState(s => ({ localAuth: !s.localAuth })),
localAuthStyle: toggle(st.localAuth), localAuthLabel: st.localAuth ? 'On' : 'Off',
onAgent: () => this.setState(s => ({ agent: !s.agent })),
agentStyle: toggle(st.agent), agentLabel: st.agent ? 'On' : 'Off',
agentStatus: st.agent ? 'key present · glm-5.2 live' : 'off — key still in env',
model: st.model, onModel: (e) => this.setState({ model: e.target.value }),
vModel: st.vModel, onVModel: (e) => this.setState({ vModel: e.target.value }),
dName: st.dName, onDName: (e) => this.setState({ dName: e.target.value }),
};
}
}
</script>
</body>
</html>
+125
View File
@@ -0,0 +1,125 @@
# Handoff: Pansy UI redesign
## Overview
A full UI/UX replacement for **pansy**, the self-hosted garden planner (Go backend + JSON API, see repo `DESIGN.md`). This package covers every screen: the garden editor (desktop workspace + phone layout), gardens list, plants catalog with seed lots and packet scanning, instance settings, and login — plus a light/dark theme system defaulting to the OS preference.
## About the design files
The `.dc.html` files in this bundle are **design references created in HTML** — working prototypes showing intended look and behavior, not production code to copy. The task is to **recreate these designs in the pansy codebase**. The existing frontend (React 19 + TS + Vite + Tailwind 4) is explicitly up for replacement: keep it, or choose anything that renders SVG and talks JSON. Everything here was deliberately built with framework-agnostic primitives (plain SVG canvas, pointer events, CSS custom properties) so it ports anywhere. The Go backend and its API are the fixed contract — this design maps 1:1 onto the existing endpoints.
The prototypes carry in-memory demo data (the real 9-bed garden: five 3'×6' beds, four 2'×8' beds, bags, buckets). Wherever demo state exists, the matching API call is named below.
## Fidelity
**High-fidelity.** Colors, typography, spacing, radii, and interactions are final. Recreate pixel-perfectly. The visual system is "Organic": warm cream ground, terracotta + sage accents, Caprasimo display over Figtree body, everything over-rounded (pills, 1628px radii). Fonts are Google Fonts: `Caprasimo:wght@400` and `Figtree:wght@400;600;700`.
## Files
| File | What it is |
| --- | --- |
| `Pansy Editor.dc.html` | The editor — BOTH desktop and phone layouts in one file (breakpoint 760px on container width) |
| `Pansy Gardens.dc.html` | Gardens list + new-garden and share dialogs |
| `Pansy Plants.dc.html` | Plant catalog + seed lots + scan-packet and add-plant dialogs |
| `Pansy Settings.dc.html` | Instance settings (admin) + appearance |
| `Pansy Login.dc.html` | Login |
| `Pansy Phone Preview.dc.html` | The editor mounted in an iPhone frame (`ios-frame.jsx`) — preview aid only, do not implement |
| `pansy-theme.js` | The theme mechanism: dark-mode token overrides + system-pref logic. Port this pattern directly |
| `_ds/organic-…/styles.css` | The design-token stylesheet (CSS custom properties + component classes). The single source of visual truth |
Each `.dc.html` is template + logic in one file; read the markup for exact inline styles and the `class Component` script for behavior math.
## Screenshots
`screenshots/` holds visual ground truth for every screen in both modes: `editor-desktop`, `editor-phone` (in the device frame), `gardens`, `plants`, `settings`, `login` — each as `-light.png` and `-dark.png`. When a spec and a screenshot disagree, flag it rather than guessing; the live HTML file is the tiebreaker.
## Design tokens
### Core (light) — from `styles.css`
- Ground `--color-bg: #f5ead8` · surface `#ebddc5` · text `#201e1d`
- Accent (terracotta) `#c67139` with 100900 ramp (`#fff2eb → #402310`); accent-2 (sage) `#7a8a5e` with ramp (`#f0fae1 → #272e1b`)
- Neutral ramp `#f9f4ed → #2e2b25`
- Divider: `color-mix(in srgb, #201e1d 16%, transparent)`
- Radii: sm 8 / md 16 / lg 28 / pills `999px` (all controls are pills)
- Shadows: `--shadow-sm/md/lg` (ink-tinted; see styles.css)
- Type: h-font Caprasimo 400; body Figtree; base 15px/1.55. Page titles h2 32px, card titles ~1619px Caprasimo, UI labels 12.513.5px Figtree 600700, metadata 11.512.5px at `--p-ink-mute`
### Pansy canvas + ink tokens (light values; declared per-page in `:root`)
```
--p-field:#efe3c9 garden field fill --p-grid-ink:#201e1d (grid lines @ .06/.12 opacity)
--p-ink-strong:#474238 primary secondary text --p-ink-soft:#645c50 --p-ink-mute:#82796a
--p-bed-fill:#e3d0ac / --p-bed-stroke:#b5a37f raised bed
--p-ing-fill:#d6bf98 / #b1a07c (dash 10 7) in-ground plot
--p-path-fill:#ece1cb / #cabfa6 (dash 3 8) path
--p-bag-fill:#d8c5a2 / #a99677 grow bag
--p-bkt-fill:#cfc3ad / #9a8d76 container/bucket
--p-tree-fill:#dce9c6 / #8fa073 (dash 12 8) tree canopy
--p-str-fill:#d9cfbc / #a3947c structure
```
### Dark mode
`pansy-theme.js` holds the full override map — dark is implemented **only** by overriding these custom properties on `<html>` (plus `color-scheme`). Key values: bg `#252220`, surface `#33302a`, text `#f1e9da`, cards `#2e2b25`, field `#2b2823`, grid ink flips to `#f5ead8`, accent lifts to `#d67f48`, tag/tint pairs flip (e.g. accent-2-100 → `#2d3520` with accent-2-800 → `#e1eecc`). Plant marker colors do NOT change between modes.
- Preference: `'system' | 'light' | 'dark'`, persisted (`localStorage['pansy-theme']` in the prototype; per-user setting in production). Default **system**, live-updates on `prefers-color-scheme` change.
- UI: icon button in every nav cycling system→light→dark (monitor/sun/moon, Lucide, stroke 2.75) + a System/Light/Dark segmented control in Settings → Appearance.
### Plant markers (demo palette)
Solid circle in the plant's color + 12 letter monogram in Caprasimo `#fffaf1`: Garlic `#97a97c` G · Tomato `#c8553d` T · Cucumber `#6f8f4f` C · Watermelon `#46683c` W · Basil `#5f8f45` B · Pepper `#b2622d` P · Marigold `#d9912f` Ma · Melon `#c2913a` Me. In production the color comes from `plants.color`; derive the monogram from the name. This replaces the old emoji icons.
## Screens
### 1. Editor — desktop (≥760px container)
Three-region workspace under a nav bar; every region is a `--color-neutral-100` card, `--radius-lg`, 1px divider border, in a 14px-gap grid: `216px | minmax(0,1fr) | 336px`.
- **Nav**: brand (sprout icon, sage, stroke 2.75 + "pansy" Caprasimo 18), links Gardens/Plants (active = accent), right cluster: theme toggle, settings gear, avatar circle (32px, sage-300 bg).
- **Left card — Toolkit**: 7 draggable object kinds (Bed 3'×6', Grow bag 1'4", Container 2', In-ground 6'7", Tree 9'10", Path 3'3"×9'10", Structure 6'7"), each a pill row: mini shape swatch (SVG, true proportions, kind's fill/stroke) + bold 13px label + 11px size in ink-mute. Click arms (accent-200 bg + accent-400 border, crosshair cursor, click canvas to place); drag-drop onto canvas also places. When a bed is **focused** the card swaps to: back chevron + bed name (Caprasimo 16), search input, plant list rows (14px color dot + name + spacing), same arm/drag behavior.
- **Center card — canvas**: header row (18px padding): "Home Garden" Caprasimo 19 + size `40 × 28` ink-mute + focus crumb (`/ Bed 2` accent-700) + right cluster (wraps at narrow widths): season segmented pill control [2025 | 2026 | 2027 plan] on neutral-200 track (active = neutral-100 pill + shadow-sm) and an Undo pill button (disabled at 45% opacity when stack empty). A status banner strip (accent-2-200 bg / accent-2-800 text, 13px) appears under the header when season ≠ 2026: 2025 = read-only; 2027 plan = "separate copy" note.
- **Canvas (SVG)**: field = rounded rect (rx 18) `--p-field`, stroke accent-2-500 3px/scale; 1ft grid minor / 5ft major lines. Objects positioned by center + rotation (`translate(x y) rotate(deg)`), stroke width 2.5/scale (3.5 accent when selected). Bed names in 13px/scale above plantable objects; centered inside non-plantable ones. Plops (planting patches) = circles in plant color, opacity .94, monogram shown when `r·scale ≥ 9px`, name below when `≥ 34px` (semantic zoom). Selection = dashed accent outline offset 7/scale + size label (`3 × 6`) under the object. Ghost preview follows cursor when armed, 55% opacity dashed.
- **Zoom pill** floating bottom-right of canvas: / fit / +.
- **Right card — rail** with 4 pill tabs: **Plot** (garden summary: counts line + full plant roster with dots and "64 in Long bed A"-style locations; or, with a selection, the inspector: name input, kind tag + size, "Growing:" roster, actions [Plant this · rotate 90° · delete]; plop selection: plant swatch + count + patch size + "Pull it out"), **Journal** (entry cards: object tag accent-2-700 + date, 13px body; input + add pill, Enter submits, attaches to selection/focus/garden), **History** ("Every change — yours or the assistant's — is one undoable step." + newest-first op pills with timestamps + "Undo the last step"), **Assistant** (chat bubbles: user right-aligned accent-200/accent-900 radius 18/18/4/18, assistant left bg/divider 18/18/18/4; input + send).
### 2. Editor — phone (<760px container)
Same file, same state, different chrome (matches repo DESIGN.md #99/#101):
- **Header** 10px padding: back circle button (38px; exits focus first, else → Gardens), garden/bed name Caprasimo 17 (ellipsis), right: theme button, season chip (tap cycles seasons, shows "26"/"27 plan"), undo circle button.
- **Canvas** fills the middle; floating fit button bottom-right. **Pinch to zoom** (two-pointer), one-finger pan/drag, tap = select, touch drag threshold 7px (mouse 3px).
- **Peek panel** (inspector on selection, or Journal/Assistant modes): docked between canvas and mode bar, max-height 45%, radius 22px top corners, shadow-lg, title + close X; canvas stays visible above it. Inputs inside are 16px font (prevents iOS zoom). All hit targets ≥ 44px.
- **Tool strip** (Build or Plants mode, hidden while peek is open): horizontal scroll row of pill chips (min-height 44) above the mode bar. In focus + Plants mode the first chip is a primary "Done".
- **Mode bar**, always visible, surface bg, 4 items (icon 19px + 11px label): Build, Plants, Journal, Assistant; active = accent-200 pill. Bottom padding `calc(6px + env(safe-area-inset-bottom))`.
- Focusing a bed on phone auto-switches to Plants mode; plants can also be tapped straight into any bed without focusing.
### 3. Gardens list
Nav + `max-width 1080px` page. Title row: "Gardens" h2 + tagline ink-mute + "New garden" primary pill. Card grid `repeat(auto-fill, minmax(300px,1fr))`, 18px gap. Each card: SVG plot thumbnail (150px band, `--p-field` bg, real object layout + plant-colored dots/pills), name Caprasimo 18 (+ optional `plan` accent tag), size right-aligned, 13px meta line, optional "Shared with lauren@ · editor" in accent-2-700, footer: Open (primary pill, flex 1) + share / copy / delete icon buttons. Dialogs: **New garden** (name, width/height in ft — note: API stores cm; "Break ground" primary) and **Share** (invite by email + role chips toggling viewer/editor on tap, read-only-link toggle + dashed link pill). Deep-copy card behavior mirrors `POST /gardens/:id/copy`.
### 4. Plants catalog
Title row + two actions: "Scan a packet" (secondary, camera icon) and "Add a plant" (primary). Filter row: search input (260px) + category pill chips (All/Vegetables/Herbs/Flowers/Fruit), live filtering. Card grid `minmax(240px,1fr)`: 40px color swatch with Caprasimo monogram, name Caprasimo 16.5 (ellipsis), sub line `Category · spacing · days`, `built-in` neutral tag for seeded plants, seed-lot summary line; clicking expands the card (accent border) to show lot cards (vendor bold + "packed for YYYY" + detail line with derived remaining) — `remaining` is **derived** server-side, never stored.
**Scan-packet dialog** (the #81 flow): step 1 = dashed drop zone + camera icon + copy "The vision model reads it into fields. It only reads; nothing is saved until you confirm." Step 2 = extracted fields grid (Variety/Vendor/Packed for/Quantity) + ranked match list as radio pills ("Garlic (built-in)" / "Garlic — Music (yours) · best match" / "Create a new plant") + "Add the lot" primary. **Never auto-create — the user confirms the match.**
**Add-plant dialog**: name, category select, spacing (in), marker color swatch row (6 curated swatches, accent ring on selection).
### 5. Settings (admin)
`max-width 720px` column of cards: **Appearance** (theme seg), **Who gets in** (signup Open/Closed seg, Local passwords toggle pill with "pure-Authentik" note, read-only OIDC issuer input, first-user-is-admin note), **Garden assistant** (live status tag e.g. "key present · glm-5.2 live", on/off toggle, chat model + vision model inputs with inherit-from-env placeholders, key-stays-in-env note — precedence Settings → env → default), **You** (display name, email read-only, Sign out ghost). Maps to `GET/PATCH /settings` + `/capabilities`.
### 6. Login
Centered 400px column over two soft blurred accent circles (decoration, 5055% opacity): sprout 44px + "pansy" Caprasimo 40 + tagline "Plan the plot. Keep the notes. Grow the thing." Card: email + password fields (16px font), "Into the garden" primary block pill, "or" divider, "Sign in with Authentik" secondary block (lock icon; label from `PANSY_OIDC_BUTTON_LABEL`; hide per `GET /auth/providers`), footer "New here? Create an account — the first one becomes admin."
## Interactions & behavior (editor core)
**Viewport**: world = garden cm, screen = `translate(tx,ty) scale(s)` on one SVG group (s = px/cm, clamp 0.128). Wheel zoom to cursor (`s · e^(deltaY·0.0016)`, non-passive listener). Pinch: scale by finger-distance ratio, keep world point under the centroid fixed. Fit: `s = min((w2p)/GW, (h2p)/GH)`, pad 40 desktop / 20 phone, centered. Camera animates on fit/focus with `transform .48s cubic-bezier(.22,.85,.3,1)`; drags/zooms are transition-free. Refit when container size changes >60px (ResizeObserver) or mobile/desktop flips.
**Placement & drag**: object drags snap center to a 3in (7.62cm) grid; drag commits ONE change (PATCH on drop, not per frame). Plops live in the parent object's local frame (rotate/move the bed moves its plants); drag clamps so a patch may overhang the bed edge by up to r/2 (spacing rule from DESIGN.md). Placing a plop: radius = spacing/2, count derived `max(1, round(πr²/spacing²))`. Click vs drag disambiguated by movement threshold. Escape ladder: disarm → deselect → unfocus. Delete/Backspace deletes selection (never while typing in inputs).
**Focus (plant a bed)**: double-click (desktop) or inspector "Plant this" — camera zooms to the bed (≈2.1× margin desktop, 1.35× phone, cap s=6), siblings dim to 30% opacity (their plops 22%), palette/strip swaps to plants. Same canvas, no separate view; mirror to `?focus=objectId`.
**Undo/History**: every operation (add, move, rotate, delete, plant, pull, clear) is one undoable step with a human label ("Planted garlic in Long bed A"). Maps directly to the change-set API (`GET /gardens/:id/history`, `POST /change-sets/:id/revert`); the prototype's snapshot stack is a stand-in.
**Seasons**: the seg switches `?year=` on `GET /gardens/:id/full`. Past seasons render read-only (edit handlers guard + banner). "2027 plan" opens the copied plan garden (from `/copy`) — visually identical editing with a persistent banner.
**Read-only viewers** (share role viewer / public token): same guard path as the 2025 season — canvas renders, all mutation entry points disabled.
## State management (suggested shape)
- Server state: `GET /gardens/:id/full` (garden + objects + plantings + plants) keyed per garden+year; optimistic mutations with `version` guard — on 409 roll back and refetch.
- Ephemeral UI state: `{tx, ty, s}`, `selection {type: 'object'|'plop', id} | null`, `focusId | null`, `armedKind | null`, `armedPlant | null`, season/year, rail tab, phone mode ('build'|'plants'|'journal'|'chat'), theme pref.
- Journal (`/gardens/:id/journal`) and assistant thread (`/gardens/:id/agent/history`, `POST /agent/chat` SSE) load per tab/peek.
## Assets
No binary assets. Icons are Lucide (https://lucide.dev) inlined at **stroke-width 2.75**, round caps/joins: sprout (brand + Plants mode), shovel (Build), notebook (Journal), message-circle (Assistant), settings, undo-2, rotate-cw, trash-2, plus, minus, maximize (fit), chevron-left/right, x, camera, share-2, copy, lock, monitor/sun/moon (theme), search, send. Object-kind "icons" are not glyphs — they're mini SVG swatches of the kind's actual shape, fill, and stroke.
## Implementation notes for pansy specifically
- Keep rendering as **plain SVG** — the prototypes prove tens of objects + low-hundreds of plops need nothing heavier; native hit-testing does all picking.
- The editor's desktop/phone split is **container-width-driven (760px)**, one component tree, two chromes — don't build two apps.
- Semantic zoom thresholds that felt right: monogram at `r·s ≥ 9px`, plop name at `≥ 34px`, object labels when `max(w,h)·s > 54px`.
- Imperial display is presentation-only: cm → nearest inch, shown as `3` / `16″` / `8″`; API stays metric.
- Theme: implement exactly as `pansy-theme.js` does — token overrides on the root element, `color-scheme` set, system watcher. No second stylesheet, no class swapping on every node.
@@ -0,0 +1,11 @@
/* @ds-bundle: {"format":4,"namespace":"Organic_organi","components":[],"sourceHashes":{},"inlinedExternals":[],"unexposedExports":[]} */
(() => {
const __ds_ns = (window.Organic_organi = window.Organic_organi || {});
const __ds_scope = {};
(__ds_ns.__errors = __ds_ns.__errors || []);
})();
@@ -0,0 +1,257 @@
/* Organic — design-system tokens and component classes. This file is the source of truth for the system's look; retune it here and see readme.md. */
@import url('https://fonts.googleapis.com/css2?family=Caprasimo:wght@400&family=Figtree:wght@400;600;700&display=swap');
:root {
--color-bg: #f5ead8;
--color-surface: #ebddc5;
--color-text: #201e1d;
--color-accent: #c67139;
--color-accent-2: #7a8a5e;
--color-divider: color-mix(in srgb, #201e1d 16%, transparent);
/* Tonal ramps — generated in OKLCH on one shared lightness scale, so the
same step of any role matches the others in visual value. */
--color-neutral-100: #f9f4ed;
--color-neutral-200: #eee7db;
--color-neutral-300: #dcd3c4;
--color-neutral-400: #c0b6a5;
--color-neutral-500: #a19786;
--color-neutral-600: #82796a;
--color-neutral-700: #645c50;
--color-neutral-800: #474238;
--color-neutral-900: #2e2b25;
--color-accent-100: #fff2eb;
--color-accent-200: #ffe1d0;
--color-accent-300: #ffc6a5;
--color-accent-400: #f6a06b;
--color-accent-500: #d67f48;
--color-accent-600: #b2622d;
--color-accent-700: #8c491a;
--color-accent-800: #643312;
--color-accent-900: #402310;
--color-accent-2-100: #f0fae1;
--color-accent-2-200: #e1eecc;
--color-accent-2-300: #ccdbb2;
--color-accent-2-400: #aebf92;
--color-accent-2-500: #8fa073;
--color-accent-2-600: #728157;
--color-accent-2-700: #56633f;
--color-accent-2-800: #3d472b;
--color-accent-2-900: #272e1b;
--font-heading: "Caprasimo", system-ui, sans-serif;
--font-heading-weight: 400;
--font-body: "Figtree", system-ui, sans-serif;
--space-1: 4.4px;
--space-2: 8.8px;
--space-3: 13.2px;
--space-4: 17.6px;
--space-6: 26.4px;
--space-8: 35.2px;
--radius-sm: 8px;
--radius-md: 16px;
--radius-lg: 28px;
/* Elevation — derived from the ground: soft ink-tinted shadows on a
light theme, a hairline edge + ambient darkness on a dark one. */
--shadow-sm: 0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent);
--shadow-md: 0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent);
--shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent);
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
}
h1, h2, h3, h4 { font-family: var(--font-heading); font-weight: var(--font-heading-weight); }
.washed{filter:saturate(0.6) contrast(0.85) brightness(1.1) opacity(0.94)}
/* ══════════════════════════════════════════════════════════════════════════
Components — built with the tokens above. Plain CSS
on plain HTML: no JavaScript, no build step. Each class is documented in
readme.md and demonstrated in foundations/ and components/.
══════════════════════════════════════════════════════════════════════ */
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-size: 15px; line-height: 1.55; font-weight: 400; }
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
line-height: 1.12; letter-spacing: -0.015em; margin: 0 0 var(--space-2);
}
h1 { font-size: 42px; }
h2 { font-size: 32px; }
h3 { font-size: 25px; }
h4 { font-size: 20px; }
h5 { font-size: 16px; }
h6 { font-size: 13px; }
h6 { letter-spacing: 0.08em; text-transform: uppercase; }
p { margin: 0 0 var(--space-3); }
a { color: var(--color-accent); text-underline-offset: 3px; }
img { display: block; max-width: 100%; }
figure { margin: 0; }
figcaption {
font-size: 11px; margin-top: var(--space-1);
color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
.text-muted { color: color-mix(in srgb, var(--color-text) 55%, transparent); }
:focus { outline: none; }
:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); }
/* — rules — */
.hr {
height: 1px; border: 0; margin: var(--space-4) 0;
background: var(--color-divider);
}
/* — buttons — */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
cursor: pointer; text-decoration: none;
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 14px; line-height: 1.2; color: var(--color-text); /* matches the .input's 14px —
the pair sits side by side in sign-up rows */
background: transparent; border: 1px solid transparent;
padding: var(--space-2) calc(var(--space-3) * 1.2);
border-radius: var(--radius-md);
}
.btn svg { display: block; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary { background: var(--color-accent); color: var(--color-bg); }
.btn-primary:hover { background: var(--color-accent-600); }
.btn-primary:active { background: var(--color-accent-700); }
.btn-secondary { border-color: var(--color-divider); }
.btn-secondary:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
.btn-secondary:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); }
.btn-ghost { color: var(--color-accent); padding-inline: var(--space-1); }
.btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
.btn-ghost:active { background: color-mix(in srgb, var(--color-accent) 18%, transparent); }
.btn-icon { width: 36px; height: 36px; padding: 0; }
.btn-block { width: 100%; margin-top: var(--space-2); }
/* — forms — */
.field > label {
display: block; font-size: 12px; margin-bottom: 5px;
color: color-mix(in srgb, var(--color-text) 70%, transparent);
}
.input {
width: 100%; min-height: 36px; padding: 6px 10px; font: inherit;
font-size: 14px; color: var(--color-text); caret-color: var(--color-accent);
background: var(--color-surface);
border: 1px solid var(--color-divider); border-radius: var(--radius-md);
}
.input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); }
.input:focus-visible { border-color: var(--color-accent); outline-offset: 0; }
textarea.input { min-height: 90px; resize: vertical; }
.radio { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; }
.radio input, .seg-opt input {
position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none;
}
.radio .dot {
width: 16px; height: 16px; flex: none; border-radius: 50%;
border: 1.5px solid var(--color-divider);
}
.radio:hover .dot { border-color: var(--color-accent); }
.radio input:checked + .dot {
border-color: var(--color-accent); background: var(--color-accent);
box-shadow: inset 0 0 0 4px var(--color-bg);
}
.radio input:focus-visible + .dot { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.seg {
display: inline-flex; overflow: hidden;
border: 1px solid var(--color-divider); border-radius: var(--radius-md);
}
.seg-opt {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 12px; font-size: 13px; cursor: pointer;
}
.seg-opt + .seg-opt { border-left: 1px solid var(--color-divider); }
.seg-opt:has(input:checked) { background: var(--color-accent); color: var(--color-bg); }
.seg-opt:not(:has(input:checked)):hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
.seg-opt:has(input:focus-visible) { outline: 2px solid var(--color-accent); outline-offset: -2px; }
/* — cards — */
.card {
display: flex; flex-direction: column; gap: var(--space-2);
padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-surface);
}
.card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); }
.card-title {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 17px; line-height: 1.2;
}
.card-body { margin: 0; font-size: 13px; opacity: 0.8; flex: 1; }
.card-meta {
display: flex; align-items: center; gap: 6px; font-size: 11px;
color: color-mix(in srgb, var(--color-text) 50%, transparent);
}
.elev-sm { box-shadow: var(--shadow-sm); }
.elev-md { box-shadow: var(--shadow-md); }
.elev-lg { box-shadow: var(--shadow-lg); }
/* — tags — */
.tag {
display: inline-flex; align-items: center; font-size: 11px;
letter-spacing: 0.02em; padding: 3px 10px;
border-radius: calc(var(--radius-md) * 0.75);
}
.tag-accent { background: var(--color-accent-100); color: var(--color-accent-800); }
.tag-accent-2 { background: var(--color-accent-2-100); color: var(--color-accent-2-800); }
.tag-neutral { background: var(--color-neutral-100); color: var(--color-neutral-800); }
.tag-outline { border: 1px solid var(--color-accent); color: var(--color-accent); }
/* — navigation — */
.nav {
display: flex; align-items: center; gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border-bottom: none;
}
.nav-brand {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 18px; margin-right: auto;
}
.nav a { color: inherit; text-decoration: none; font-size: 14px; }
.nav a:hover, .nav a[aria-current='page'] { color: var(--color-accent); }
/* — tables — */
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
.table th {
text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
color: color-mix(in srgb, var(--color-text) 60%, transparent);
padding: var(--space-2); border-bottom: 1px solid var(--color-divider);
}
.table td {
padding: var(--space-2);
border-bottom: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent);
}
.table tbody tr:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); }
/* — dialog — */
.dialog-backdrop {
position: fixed; inset: 0; display: grid; place-items: center;
padding: var(--space-4);
background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent);
}
.dialog {
width: min(440px, 100%); display: flex; flex-direction: column; gap: var(--space-3);
padding: var(--space-4); border-radius: var(--radius-lg);
background: var(--color-surface); box-shadow: var(--shadow-lg);
}
.dialog-title {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 20px;
}
.dialog-body { font-size: 14px; opacity: 0.85; }
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-2); }
/* — rounded frame: everything softens, small controls go pill — */
.card, .dialog { border-radius: calc(var(--radius-lg) * 1.15); }
.btn, .tag, .seg, .input { border-radius: 999px; }
.input { padding-inline: 14px; }
+352
View File
@@ -0,0 +1,352 @@
// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected).
/* BEGIN USAGE */
// iOS.jsx — Simplified iOS 26 (Liquid Glass) device frame
// Based on the iOS 26 UI Kit + Figma status bar spec. No assets, no deps.
// Exports (to window): IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard
//
// Usage — wrap your screen content in <IOSDevice> to get the bezel, status bar
// and home indicator (props: title, dark, keyboard):
//
// <IOSDevice title="Settings">
// ...your screen content...
// </IOSDevice>
// <IOSDevice dark title="Search" keyboard>…</IOSDevice>
/* END USAGE */
// ─────────────────────────────────────────────────────────────
// Status bar
// ─────────────────────────────────────────────────────────────
function IOSStatusBar({ dark = false, time = '9:41' }) {
const c = dark ? '#fff' : '#000';
return (
<div style={{
display: 'flex', gap: 154, alignItems: 'center', justifyContent: 'center',
padding: '21px 24px 19px', boxSizing: 'border-box',
position: 'relative', zIndex: 20, width: '100%',
}}>
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', paddingTop: 1.5 }}>
<span style={{
fontFamily: '-apple-system, "SF Pro", system-ui', fontWeight: 590,
fontSize: 17, lineHeight: '22px', color: c,
}}>{time}</span>
</div>
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, paddingTop: 1, paddingRight: 1 }}>
<svg width="19" height="12" viewBox="0 0 19 12">
<rect x="0" y="7.5" width="3.2" height="4.5" rx="0.7" fill={c}/>
<rect x="4.8" y="5" width="3.2" height="7" rx="0.7" fill={c}/>
<rect x="9.6" y="2.5" width="3.2" height="9.5" rx="0.7" fill={c}/>
<rect x="14.4" y="0" width="3.2" height="12" rx="0.7" fill={c}/>
</svg>
<svg width="17" height="12" viewBox="0 0 17 12">
<path d="M8.5 3.2C10.8 3.2 12.9 4.1 14.4 5.6L15.5 4.5C13.7 2.7 11.2 1.5 8.5 1.5C5.8 1.5 3.3 2.7 1.5 4.5L2.6 5.6C4.1 4.1 6.2 3.2 8.5 3.2Z" fill={c}/>
<path d="M8.5 6.8C9.9 6.8 11.1 7.3 12 8.2L13.1 7.1C11.8 5.9 10.2 5.1 8.5 5.1C6.8 5.1 5.2 5.9 3.9 7.1L5 8.2C5.9 7.3 7.1 6.8 8.5 6.8Z" fill={c}/>
<circle cx="8.5" cy="10.5" r="1.5" fill={c}/>
</svg>
<svg width="27" height="13" viewBox="0 0 27 13">
<rect x="0.5" y="0.5" width="23" height="12" rx="3.5" stroke={c} strokeOpacity="0.35" fill="none"/>
<rect x="2" y="2" width="20" height="9" rx="2" fill={c}/>
<path d="M25 4.5V8.5C25.8 8.2 26.5 7.2 26.5 6.5C26.5 5.8 25.8 4.8 25 4.5Z" fill={c} fillOpacity="0.4"/>
</svg>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Liquid glass pill — blur + tint + shine
// ─────────────────────────────────────────────────────────────
function IOSGlassPill({ children, dark = false, style = {} }) {
return (
<div style={{
height: 44, minWidth: 44, borderRadius: 9999,
position: 'relative', overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: dark
? '0 2px 6px rgba(0,0,0,0.35), 0 6px 16px rgba(0,0,0,0.2)'
: '0 1px 3px rgba(0,0,0,0.07), 0 3px 10px rgba(0,0,0,0.06)',
...style,
}}>
{/* blur + tint */}
<div style={{
position: 'absolute', inset: 0, borderRadius: 9999,
backdropFilter: 'blur(12px) saturate(180%)',
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
background: dark ? 'rgba(120,120,128,0.28)' : 'rgba(255,255,255,0.5)',
}} />
{/* shine */}
<div style={{
position: 'absolute', inset: 0, borderRadius: 9999,
boxShadow: dark
? 'inset 1.5px 1.5px 1px rgba(255,255,255,0.15), inset -1px -1px 1px rgba(255,255,255,0.08)'
: 'inset 1.5px 1.5px 1px rgba(255,255,255,0.7), inset -1px -1px 1px rgba(255,255,255,0.4)',
border: dark ? '0.5px solid rgba(255,255,255,0.15)' : '0.5px solid rgba(0,0,0,0.06)',
}} />
<div style={{ position: 'relative', zIndex: 1, display: 'flex', alignItems: 'center', padding: '0 4px' }}>
{children}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Navigation bar — glass pills + large title
// ─────────────────────────────────────────────────────────────
function IOSNavBar({ title = 'Title', dark = false, trailingIcon = true }) {
const muted = dark ? 'rgba(255,255,255,0.6)' : '#404040';
const text = dark ? '#fff' : '#000';
const pillIcon = (content) => (
<IOSGlassPill dark={dark}>
<div style={{ width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{content}
</div>
</IOSGlassPill>
);
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 10,
paddingTop: 62, paddingBottom: 10, position: 'relative', zIndex: 5,
}}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 16px',
}}>
{/* back chevron */}
{pillIcon(
<svg width="12" height="20" viewBox="0 0 12 20" fill="none" style={{ marginLeft: -1 }}>
<path d="M10 2L2 10l8 8" stroke={muted} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{/* trailing ellipsis */}
{trailingIcon && pillIcon(
<svg width="22" height="6" viewBox="0 0 22 6">
<circle cx="3" cy="3" r="2.5" fill={muted}/>
<circle cx="11" cy="3" r="2.5" fill={muted}/>
<circle cx="19" cy="3" r="2.5" fill={muted}/>
</svg>
)}
</div>
{/* large title */}
<div style={{
padding: '0 16px',
fontFamily: '-apple-system, system-ui',
fontSize: 34, fontWeight: 700, lineHeight: '41px',
color: text, letterSpacing: 0.4,
}}>{title}</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Grouped list (inset card, r:26) + row (52px)
// ─────────────────────────────────────────────────────────────
function IOSListRow({ title, detail, icon, chevron = true, isLast = false, dark = false }) {
const text = dark ? '#fff' : '#000';
const sec = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
const ter = dark ? 'rgba(235,235,245,0.3)' : 'rgba(60,60,67,0.3)';
const sep = dark ? 'rgba(84,84,88,0.65)' : 'rgba(60,60,67,0.12)';
return (
<div style={{
display: 'flex', alignItems: 'center', minHeight: 52,
padding: '0 16px', position: 'relative',
fontFamily: '-apple-system, system-ui', fontSize: 17,
letterSpacing: -0.43,
}}>
{icon && (
<div style={{
width: 30, height: 30, borderRadius: 7, background: icon,
marginRight: 12, flexShrink: 0,
}} />
)}
<div style={{ flex: 1, color: text }}>{title}</div>
{detail && <span style={{ color: sec, marginRight: 6 }}>{detail}</span>}
{chevron && (
<svg width="8" height="14" viewBox="0 0 8 14" style={{ flexShrink: 0 }}>
<path d="M1 1l6 6-6 6" stroke={ter} strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{!isLast && (
<div style={{
position: 'absolute', bottom: 0, right: 0,
left: icon ? 58 : 16, height: 0.5, background: sep,
}} />
)}
</div>
);
}
function IOSList({ header, children, dark = false }) {
const hc = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
const bg = dark ? '#1C1C1E' : '#fff';
return (
<div>
{header && (
<div style={{
fontFamily: '-apple-system, system-ui', fontSize: 13,
color: hc, textTransform: 'uppercase',
padding: '8px 36px 6px', letterSpacing: -0.08,
}}>{header}</div>
)}
<div style={{
background: bg, borderRadius: 26,
margin: '0 16px', overflow: 'hidden',
}}>{children}</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Device frame
// ─────────────────────────────────────────────────────────────
function IOSDevice({
children, width = 402, height = 874, dark = false,
title, keyboard = false,
}) {
return (
// data-om-starter: inert presence marker — Claude Design's starter-usage
// probe reads it; it renders nothing. Keep it on this root element.
<div data-om-starter="ios-frame" style={{
width, height, borderRadius: 48, overflow: 'hidden',
position: 'relative', background: dark ? '#000' : '#F2F2F7',
boxShadow: '0 40px 80px rgba(0,0,0,0.18), 0 0 0 1px rgba(0,0,0,0.12)',
fontFamily: '-apple-system, system-ui, sans-serif',
WebkitFontSmoothing: 'antialiased',
}}>
{/* dynamic island */}
<div style={{
position: 'absolute', top: 11, left: '50%', transform: 'translateX(-50%)',
width: 126, height: 37, borderRadius: 24, background: '#000', zIndex: 50,
}} />
{/* status bar (absolute) */}
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10 }}>
<IOSStatusBar dark={dark} />
</div>
{/* nav + content */}
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{title !== undefined && <IOSNavBar title={title} dark={dark} />}
<div style={{ flex: 1, overflow: 'auto' }}>{children}</div>
{keyboard && <IOSKeyboard dark={dark} />}
</div>
{/* home indicator — always on top */}
<div style={{
position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 60,
height: 34, display: 'flex', justifyContent: 'center', alignItems: 'flex-end',
paddingBottom: 8, pointerEvents: 'none',
}}>
<div style={{
width: 139, height: 5, borderRadius: 100,
background: dark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.25)',
}} />
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Keyboard — iOS 26 liquid glass
// ─────────────────────────────────────────────────────────────
function IOSKeyboard({ dark = false }) {
const glyph = dark ? 'rgba(255,255,255,0.7)' : '#595959';
const sugg = dark ? 'rgba(255,255,255,0.6)' : '#333';
const keyBg = dark ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.85)';
// special-key icons
const icons = {
shift: <svg width="19" height="17" viewBox="0 0 19 17"><path d="M9.5 1L1 9.5h4.5V16h8V9.5H18L9.5 1z" fill={glyph}/></svg>,
del: <svg width="23" height="17" viewBox="0 0 23 17"><path d="M7 1h13a2 2 0 012 2v11a2 2 0 01-2 2H7l-6-7.5L7 1z" fill="none" stroke={glyph} strokeWidth="1.6" strokeLinejoin="round"/><path d="M10 5l7 7M17 5l-7 7" stroke={glyph} strokeWidth="1.6" strokeLinecap="round"/></svg>,
ret: <svg width="20" height="14" viewBox="0 0 20 14"><path d="M18 1v6H4m0 0l4-4M4 7l4 4" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>,
};
const key = (content, { w, flex, ret, fs = 25, k } = {}) => (
<div key={k} style={{
height: 42, borderRadius: 8.5,
flex: flex ? 1 : undefined, width: w, minWidth: 0,
background: ret ? '#08f' : keyBg,
boxShadow: '0 1px 0 rgba(0,0,0,0.075)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: '-apple-system, "SF Compact", system-ui',
fontSize: fs, fontWeight: 458, color: ret ? '#fff' : glyph,
}}>{content}</div>
);
const row = (keys, pad = 0) => (
<div style={{ display: 'flex', gap: 6.5, justifyContent: 'center', padding: `0 ${pad}px` }}>
{keys.map(l => key(l, { flex: true, k: l }))}
</div>
);
return (
<div style={{
position: 'relative', zIndex: 15, borderRadius: 27, overflow: 'hidden',
padding: '11px 0 2px',
display: 'flex', flexDirection: 'column', alignItems: 'center',
boxShadow: dark
? '0 -2px 20px rgba(0,0,0,0.09)'
: '0 -1px 6px rgba(0,0,0,0.018), 0 -3px 20px rgba(0,0,0,0.012)',
}}>
{/* liquid glass bg — same recipe as nav pills */}
<div style={{
position: 'absolute', inset: 0, borderRadius: 27,
backdropFilter: 'blur(12px) saturate(180%)',
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
background: dark ? 'rgba(120,120,128,0.14)' : 'rgba(255,255,255,0.25)',
}} />
<div style={{
position: 'absolute', inset: 0, borderRadius: 27,
boxShadow: dark
? 'inset 1.5px 1.5px 1px rgba(255,255,255,0.15)'
: 'inset 1.5px 1.5px 1px rgba(255,255,255,0.7), inset -1px -1px 1px rgba(255,255,255,0.4)',
border: dark ? '0.5px solid rgba(255,255,255,0.15)' : '0.5px solid rgba(0,0,0,0.06)',
pointerEvents: 'none',
}} />
{/* autocorrect bar */}
<div style={{
display: 'flex', gap: 20, alignItems: 'center',
padding: '8px 22px 13px', width: '100%', boxSizing: 'border-box',
position: 'relative',
}}>
{['"The"', 'the', 'to'].map((w, i) => (
<React.Fragment key={i}>
{i > 0 && <div style={{ width: 1, height: 25, background: '#ccc', opacity: 0.3 }} />}
<div style={{
flex: 1, textAlign: 'center',
fontFamily: '-apple-system, system-ui', fontSize: 17,
color: sugg, letterSpacing: -0.43, lineHeight: '22px',
}}>{w}</div>
</React.Fragment>
))}
</div>
{/* key layout */}
<div style={{
display: 'flex', flexDirection: 'column', gap: 13,
padding: '0 6.5px', width: '100%', boxSizing: 'border-box',
position: 'relative',
}}>
{row(['q','w','e','r','t','y','u','i','o','p'])}
{row(['a','s','d','f','g','h','j','k','l'], 20)}
<div style={{ display: 'flex', gap: 14.25, alignItems: 'center' }}>
{key(icons.shift, { w: 45, k: 'shift' })}
<div style={{ display: 'flex', gap: 6.5, flex: 1 }}>
{['z','x','c','v','b','n','m'].map(l => key(l, { flex: true, k: l }))}
</div>
{key(icons.del, { w: 45, k: 'del' })}
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
{key('ABC', { w: 92.25, fs: 18, k: 'abc' })}
{key('', { flex: true, k: 'space' })}
{key(icons.ret, { w: 92.25, ret: true, k: 'ret' })}
</div>
</div>
{/* bottom spacer (emoji+mic area, icons omitted) */}
<div style={{ height: 56, width: '100%', position: 'relative' }} />
</div>
);
}
Object.assign(window, {
IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard,
});
@@ -0,0 +1,50 @@
// Pansy theme — light is the stylesheet default; dark overrides the tokens on <html>.
// Pref ('system'|'light'|'dark') persists in localStorage and is shared by every page.
(function () {
const KEY = 'pansy-theme';
const DARK = {
'--color-bg': '#252220', '--color-surface': '#33302a', '--color-text': '#f1e9da',
'--color-divider': 'color-mix(in srgb, #f5ead8 16%, transparent)',
'--color-accent': '#d67f48',
'--color-neutral-100': '#2e2b25', '--color-neutral-200': '#3a362f', '--color-neutral-300': '#474238',
'--color-neutral-400': '#645c50', '--color-neutral-500': '#82796a', '--color-neutral-800': '#dcd3c4',
'--color-accent-100': '#3d2c1d', '--color-accent-200': '#59331a', '--color-accent-300': '#8c491a',
'--color-accent-400': '#d67f48', '--color-accent-700': '#f6a06b', '--color-accent-800': '#ffd9bd', '--color-accent-900': '#ffe9da',
'--color-accent-2-200': '#333d24', '--color-accent-2-100': '#2d3520', '--color-accent-2-300': '#3d472b', '--color-accent-2-500': '#728157',
'--color-accent-2-600': '#aebf92', '--color-accent-2-700': '#ccdbb2', '--color-accent-2-800': '#e1eecc',
'--shadow-sm': '0 1px 2px rgba(0,0,0,0.4)', '--shadow-md': '0 3px 10px rgba(0,0,0,0.45)', '--shadow-lg': '0 12px 32px rgba(0,0,0,0.55)',
'--p-field': '#2b2823', '--p-grid-ink': '#f5ead8',
'--p-ink-strong': '#d9d0bf', '--p-ink-soft': '#b3a992', '--p-ink-mute': '#8f8674',
'--p-bed-fill': '#4a4131', '--p-bed-stroke': '#6d5f47', '--p-ing-fill': '#3f382c', '--p-ing-stroke': '#5c5343',
'--p-path-fill': '#312e28', '--p-path-stroke': '#4d473c', '--p-bag-fill': '#463d2f', '--p-bag-stroke': '#6a5d49',
'--p-bkt-fill': '#3e382e', '--p-bkt-stroke': '#5f574a', '--p-tree-fill': '#333a28', '--p-tree-stroke': '#56633f',
'--p-str-fill': '#3b362e', '--p-str-stroke': '#5f574a',
};
const P = {
get() { try { return localStorage.getItem(KEY) || 'system'; } catch (e) { return 'system'; } },
set(v) { try { localStorage.setItem(KEY, v); } catch (e) {} },
sysDark() { return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches); },
isDark(pref) { return pref === 'dark' || (pref === 'system' && P.sysDark()); },
apply(dark) {
const r = document.documentElement.style;
Object.keys(DARK).forEach(k => { dark ? r.setProperty(k, DARK[k]) : r.removeProperty(k); });
r.colorScheme = dark ? 'dark' : 'light';
},
watch(cb) {
if (!window.matchMedia) return () => {};
const m = window.matchMedia('(prefers-color-scheme: dark)');
m.addEventListener('change', cb);
return () => m.removeEventListener('change', cb);
},
next(p) { return p === 'system' ? 'light' : p === 'light' ? 'dark' : 'system'; },
icon(p) {
return {
system: ['M4 3h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z', 'M8 21h8', 'M12 17v4'],
light: ['M12 8a4 4 0 1 0 0 8 4 4 0 1 0 0-8', 'M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M6.3 17.7l-1.4 1.4M19.1 4.9l-1.4 1.4', ''],
dark: ['M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z', '', ''],
}[p] || ['', '', ''];
},
};
window.PansyTheme = P;
P.apply(P.isDark(P.get()));
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB