added claude design's ui framework
@@ -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="{"layout": {"editor": "enum", "options": ["auto", "phone", "desktop"], "default": "auto", "tsType": "'auto' | 'phone' | 'desktop'", "section": "Layout"}, "showGrid": {"editor": "boolean", "default": true, "tsType": "boolean", "section": "Canvas"}, "markers": {"editor": "enum", "options": ["monogram", "dot"], "default": "monogram", "tsType": "'monogram' | 'dot'", "section": "Canvas"}}">
|
||||
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>
|
||||
@@ -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, 16–28px 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 100–900 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 ~16–19px Caprasimo, UI labels 12.5–13.5px Figtree 600–700, metadata 11.5–12.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 + 1–2 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, 50–55% 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.12–8). 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((w−2p)/GW, (h−2p)/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′` / `1′6″` / `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; }
|
||||
|
||||
@@ -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()));
|
||||
})();
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |