Tape measure, area templates, map markers with notes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
2026-10-25 15:43:26 +00:00
co-authored by Claude Sonnet 5.5
parent 0f97129243
commit b31936235d
7 changed files with 358 additions and 9 deletions
+12 -5
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
import { sound, playRoll, playPing, preload, DEFAULT_ROLL } from './sound.js'
import { useTableTools } from './Tools.jsx'
import { MapView, MapLibrary, NpcPicker, PropForm, PropPicker, RadialMenu, RevealLayer, StatusForm, TokenForm, WallLayer } from './MapView.jsx'
const J = { 'Content-Type': 'application/json' }
@@ -725,8 +726,10 @@ function LobbyView({ id, rules, user }) {
const [sheetTick, setSheetTick] = useState({ cid: '', n: 0 })
const [pings, setPings] = useState([])
const [pingMode, setPingMode] = useState(false)
const tools = useTableTools({ id, lobby, mapState })
const up = useSocket(id, {
ping: (n) => sock.current.ping?.(n),
measure: (n) => sock.current.measure?.(n),
open: () => { sock.current.load?.(); sock.current.poll?.(); sock.current.loadMap?.() },
map: () => sock.current.loadMap?.(),
messages: () => sock.current.poll?.(),
@@ -764,7 +767,7 @@ function LobbyView({ id, rules, user }) {
setTimeout(() => setPings((l) => l.filter((p) => p.key !== key)), 3500)
playPing(sfx.current)
}
sock.current = { load, poll: feed.poll, loadMap, ping: showPing }
sock.current = { load, poll: feed.poll, loadMap, ping: showPing, measure: tools.receive }
const act = async (url, method, body, done) => {
if (!url) return load()
@@ -790,6 +793,8 @@ function LobbyView({ id, rules, user }) {
useEffect(() => () => document.documentElement.style.removeProperty('--chatw'), [])
useEffect(() => { store('aw_sheetw', String(sheetW)) }, [sheetW])
const [rv, setRv] = useState(null) // DM "show an area" tool: {origin, target, hover, fov, to}
useEffect(() => { if (rv || pingMode) tools.stop() }, [rv, pingMode]) // eslint-disable-line
useEffect(() => { if (tools.mode) { setRv(null); setPingMode(false) } }, [tools.mode])
const rvShape = (() => {
if (!rv?.origin) return null
const t = rv.target || rv.hover
@@ -917,11 +922,11 @@ function LobbyView({ id, rules, user }) {
onMove={moveToken} selected={mapSel} onSelect={setMapSel}
onMenu={lobby.is_dm && !viewAs ? (t, x, y) => setMenu({ t, x, y }) : undefined}
isTurn={(t) => { const e = lobby.combat?.entries.find((x) => x.id === lobby.combat.current); return !!e && (e.kind === 'npc' ? e.token === t.id : e.user === t.username) }}
tool={rv ? 'reveal' : pingMode ? 'ping' : undefined}
onClick={rv ? (p) => setRv((s) => (s.origin && !s.target ? { ...s, target: p } : s.origin ? { ...s, target: p } : { ...s, origin: p })) : pingMode ? (p) => { setPingMode(false); send(`/api/lobbies/${id}/ping`, 'POST', { x: p.x, y: p.y }).catch(() => null) } : undefined}
onHover={rv && rv.origin && !rv.target ? (p) => setRv((s) => (s ? { ...s, hover: p } : s)) : undefined}
tool={rv ? 'reveal' : pingMode ? 'ping' : tools.tool}
onClick={rv ? (p) => setRv((s) => (s.origin && !s.target ? { ...s, target: p } : s.origin ? { ...s, target: p } : { ...s, origin: p })) : pingMode ? (p) => { setPingMode(false); send(`/api/lobbies/${id}/ping`, 'POST', { x: p.x, y: p.y }).catch(() => null) } : tools.active ? tools.onClick : undefined}
onHover={rv && rv.origin && !rv.target ? (p) => setRv((s) => (s ? { ...s, hover: p } : s)) : tools.active ? tools.onHover : undefined}
overlay={<>{walls.length ? <WallLayer map={mapState.map} walls={walls} onDoor={(w) => act(`/api/lobbies/${id}/doors/${w.id}`, 'PUT', { open: !w.open }, () => loadMap())} /> : null}
{full && <RevealLayer map={mapState.map} zones={mapState.reveals} draft={rv && rvShape && rv.origin ? rvShape : null} />}</>}>
{full && <RevealLayer map={mapState.map} zones={mapState.reveals} draft={rv && rvShape && rv.origin ? rvShape : null} />}{tools.overlay}</>}>
{mapState.vision?.empty && <div className="mapempty">{lobby.is_dm ? 'This player is not on the map yet. Switch to the full view and drag their token onto it.' : 'Waiting for the DM to put your token on the map.'}</div>}
<div className="maptools mapui">
{lobby.is_dm && (
@@ -940,6 +945,7 @@ function LobbyView({ id, rules, user }) {
{lobby.is_dm && <button className={mapState.los_on ? 'on' : 'ghost'} aria-pressed={!!mapState.los_on} onClick={() => act(`/api/lobbies/${id}/sight`, 'PUT', { on: !mapState.los_on }, () => loadMap())} title="Players only see what their character can see: limited by walls and by the sight range (needs one of them)">Sight {mapState.los_on ? 'on' : 'off'}</button>}
{lobby.is_dm && mapState.wall_count > 0 && <button disabled={!full} className={showWalls ? 'on' : 'ghost'} aria-pressed={showWalls} onClick={() => setShowWalls(!showWalls)} title="Show walls and doors (click a door to open or close it)">Walls</button>}
<button className={showGrid ? 'on' : 'ghost'} aria-pressed={showGrid} onClick={() => setShowGrid(!showGrid)}>Grid</button>
{tools.buttons}
<button className={pingMode ? 'on' : 'ghost'} aria-pressed={pingMode} onClick={() => { setPingMode(!pingMode); setRv(null) }} title="Point at a spot for everybody: click here, then the map (or just double-click the map)">📍 Ping</button>
{lobby.is_dm && <button className="ghost" onClick={addToken}>+ Token</button>}
{lobby.is_dm && <button className={npcPick ? 'on' : 'ghost'} onClick={() => { setNpcPick(!npcPick); setPropPick(false) }}>+ NPC</button>}
@@ -969,6 +975,7 @@ function LobbyView({ id, rules, user }) {
</>}
</div>
)}
{tools.panel}
{npcPick && <div className="mapui npcpickwrap"><NpcPicker onPick={addNpcToken} onClose={() => setNpcPick(false)} /></div>}
{propPick && <div className="mapui npcpickwrap"><PropPicker onPick={addPropToken} onClose={() => setPropPick(false)} /></div>}
{selProp && <div className="tokenpanel mapui"><PropForm t={selProp} onChange={(p) => patchToken(selProp.id, p)} onDelete={() => { send(`/api/lobbies/${id}/tokens/${selProp.id}`, 'DELETE'); setMapSel(null) }} /></div>}
+2 -2
View File
@@ -47,7 +47,7 @@ export function MapView({ map, image, fog, portraitUrl, propSrc, pings, onPing,
}, [])
const down = (e) => {
if (e.target.closest('.maptoken, .mapui, .mapdoor')) return
if (e.target.closest('.maptoken, .mapui, .mapdoor, .mappin')) return
box.current.setPointerCapture(e.pointerId)
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY })
if (pointers.current.size === 1) onSelect?.(null)
@@ -114,7 +114,7 @@ export function MapView({ map, image, fog, portraitUrl, propSrc, pings, onPing,
const d = (t) => t.size * map.grid
return (
<div className={`mapbox ${tool && tool !== 'select' ? 'drawing' : ''}`} ref={box} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up}
onDoubleClick={(e) => { if (onPing && !e.target.closest('.maptoken, .mapui, .mapdoor')) { const p = toWorld(e); if (p.inside) onPing(p.x, p.y) } }}>
onDoubleClick={(e) => { if (onPing && !e.target.closest('.maptoken, .mapui, .mapdoor, .mappin')) { const p = toWorld(e); if (p.inside) onPing(p.x, p.y) } }}>
<div className="mapworld" style={{ width: map.w, height: map.h, transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.s})` }}>
{fog && <img className="mapfog" src={fog} width={map.w} height={map.h} alt="" draggable={false} />}
{image && <img src={image.src} style={{ position: 'absolute', left: image.x, top: image.y }} width={image.w} height={image.h} alt="" draggable={false} />}
+182
View File
@@ -0,0 +1,182 @@
import { useEffect, useRef, useState } from 'react'
// Table tools for everybody: tape measure, area templates (spells) and map markers with notes.
const J = { 'Content-Type': 'application/json' }
const post = (url, body) => fetch(url, { method: 'POST', headers: J, body: JSON.stringify(body) }).catch(() => null)
const del = (url) => fetch(url, { method: 'DELETE' }).catch(() => null)
const rad = (d) => (d * Math.PI) / 180
const half = (v) => Math.round(v * 2) / 2
const KINDS = [['circle', '◯ Circle'], ['cone', '◔ Cone'], ['line', '▭ Line'], ['square', '▢ Square']]
const ICONS = ['📌', '⚠️', '💰', '🔒', '🚪', '❓', '⭐', '☠️', '🏕️', '👁️']
// shape (in map pixels) of a template: {kind, x, y, dir, size (squares), width}
function Shape({ t, g, color, ghost }) {
const L = t.size * g
const common = { fill: color, fillOpacity: ghost ? 0.18 : 0.3, stroke: color, strokeWidth: 3, strokeDasharray: ghost ? '8 6' : undefined }
if (t.kind === 'circle') return <circle cx={t.x} cy={t.y} r={L} {...common} />
if (t.kind === 'cone') {
const a1 = rad(t.dir - t.width / 2), a2 = rad(t.dir + t.width / 2)
return <path d={`M${t.x} ${t.y} L${t.x + L * Math.cos(a1)} ${t.y + L * Math.sin(a1)} A${L} ${L} 0 0 1 ${t.x + L * Math.cos(a2)} ${t.y + L * Math.sin(a2)} Z`} {...common} />
}
if (t.kind === 'line') return <rect x={t.x} y={t.y - (t.width * g) / 2} width={L} height={t.width * g} transform={`rotate(${t.dir} ${t.x} ${t.y})`} {...common} />
return <rect x={t.x - L / 2} y={t.y - L / 2} width={L} height={L} transform={`rotate(${t.dir} ${t.x} ${t.y})`} {...common} />
}
// where the label of a shape goes (its far end)
const tip = (t, g) => {
const L = t.size * g
if (t.kind === 'circle' || t.kind === 'square') return { x: t.x, y: t.y }
return { x: t.x + (L / 2) * Math.cos(rad(t.dir)), y: t.y + (L / 2) * Math.sin(rad(t.dir)) }
}
const sizeLabel = (t) => (t.kind === 'circle' ? `r ${t.size}` : t.kind === 'square' ? `${t.size}×${t.size}` : `${t.size}`) + ' sq'
export function useTableTools({ id, lobby: lobbyIn, mapState }) {
const lobby = lobbyIn || { members: [], palette: [], dm_color: '#d4af37' }
const [mode, setMode] = useState(null) // 'ruler' | 'tpl' | 'mark'
const [kind, setKind] = useState('cone')
const [angle, setAngle] = useState(60)
const [lineW, setLineW] = useState(1)
const [origin, setOrigin] = useState(null)
const [hover, setHover] = useState(null)
const [draft, setDraft] = useState(null) // new marker being written
const [selMark, setSelMark] = useState(null)
const [measures, setMeasures] = useState([])
const map = mapState.map
const g = map?.grid || 50
const colorOf = (user, dm) => (dm ? lobby.dm_color : lobby.palette.find((p) => p.id === lobby.members.find((m) => m.username === user)?.color)?.hex || '#ffffff')
const me = lobby.members.find((m) => m.me)
const myColor = lobby.is_dm ? lobby.dm_color : lobby.palette.find((p) => p.id === me?.color)?.hex || '#ffffff'
useEffect(() => {
const k = (e) => { if (e.key === 'Escape') { setOrigin(null); setDraft(null); setMode(null) } }
window.addEventListener('keydown', k)
return () => window.removeEventListener('keydown', k)
}, [])
useEffect(() => { setOrigin(null); setHover(null); if (mode !== 'mark') setDraft(null) }, [mode, kind])
const shapeFrom = (p) => {
const dx = p.x - origin.x, dy = p.y - origin.y
const dist = Math.hypot(dx, dy) / g
const size = Math.max(0.5, half(kind === 'square' ? dist * 2 : dist))
return { kind, x: origin.x, y: origin.y, dir: (Math.atan2(dy, dx) * 180) / Math.PI, size, width: kind === 'cone' ? angle : kind === 'line' ? lineW : 1 }
}
const onClick = (raw) => {
const p = { x: Math.min(Math.max(raw.x, 0), map.w), y: Math.min(Math.max(raw.y, 0), map.h) } // clicks next to the map count as its edge
if (mode === 'ruler') {
if (!origin) return setOrigin(p)
post(`/api/lobbies/${id}/measure`, { x1: origin.x, y1: origin.y, x2: p.x, y2: p.y })
setOrigin(null)
} else if (mode === 'tpl') {
if (!origin) return setOrigin(p)
post(`/api/lobbies/${id}/templates`, shapeFrom(p))
setOrigin(null); setHover(null)
} else if (mode === 'mark') {
setSelMark(null)
setDraft({ x: p.x, y: p.y, icon: '📌', title: '', note: '', shared: false })
}
}
const onHover = origin ? (p) => setHover({ x: Math.min(Math.max(p.x, 0), map.w), y: Math.min(Math.max(p.y, 0), map.h) }) : undefined
const receive = (n) => { // a measurement somebody shared
const key = `${Date.now()}${Math.random()}`
setMeasures((l) => [...l.slice(-4), { key, ...n, color: colorOf(n.user, n.dm) }])
setTimeout(() => setMeasures((l) => l.filter((x) => x.key !== key)), 6000)
}
const templates = mapState.templates || []
const marks = mapState.marks || []
const dist = (a, b) => half(Math.hypot(b.x - a.x, b.y - a.y) / g)
const overlay = !map ? null : (
<>
<svg className="toollayer" width={map.w} height={map.h} aria-hidden="true">
{templates.map((t) => {
const c = colorOf(t.user, t.dm), e = tip(t, g)
return <g key={t.id}><Shape t={t} g={g} color={c} /><circle cx={t.x} cy={t.y} r="5" fill={c} />
<text x={e.x} y={e.y} className="toolabel" fontSize={Math.max(12, g * 0.32)} textAnchor="middle">{sizeLabel(t)}</text></g>
})}
{mode === 'tpl' && origin && hover && (() => { const t = shapeFrom(hover); const e = tip(t, g)
return <g><Shape t={t} g={g} color={myColor} ghost /><text x={e.x} y={e.y} className="toolabel" fontSize={Math.max(12, g * 0.32)} textAnchor="middle">{sizeLabel(t)}</text></g> })()}
{mode === 'ruler' && origin && hover && <Line a={origin} b={hover} g={g} color={myColor} label={`${dist(origin, hover)} sq`} />}
{measures.map((m) => <Line key={m.key} a={{ x: m.x1, y: m.y1 }} b={{ x: m.x2, y: m.y2 }} g={g} color={m.color} label={`${dist({ x: m.x1, y: m.y1 }, { x: m.x2, y: m.y2 })} sq${m.dm ? '' : ` · ${m.user}`}`} />)}
</svg>
{marks.map((k) => (
<button key={k.id} className={`mappin ${selMark === k.id ? 'sel' : ''} ${k.shared ? 'shared' : ''}`} style={{ left: k.x, top: k.y, fontSize: Math.max(18, g * 0.7) }}
title={`${k.title}${k.note ? `\n${k.note}` : ''}${k.mine ? '' : `\n(${k.user})`}`}
onPointerDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); setDraft(null); setSelMark(selMark === k.id ? null : k.id) }}>{k.icon}</button>
))}
{draft && <span className="mappin draft" style={{ left: draft.x, top: draft.y, fontSize: Math.max(18, g * 0.7) }}>{draft.icon}</span>}
</>
)
const buttons = (
<>
<button className={mode === 'ruler' ? 'on' : 'ghost'} aria-pressed={mode === 'ruler'} onClick={() => setMode(mode === 'ruler' ? null : 'ruler')} title="Tape measure: click the start, then the end. Everybody sees the distance for a moment.">📏 Measure</button>
<button className={mode === 'tpl' ? 'on' : 'ghost'} aria-pressed={mode === 'tpl'} onClick={() => setMode(mode === 'tpl' ? null : 'tpl')} title="Area templates for spells: circle, cone, line, square">⬡ Area</button>
<button className={mode === 'mark' ? 'on' : 'ghost'} aria-pressed={mode === 'mark'} onClick={() => setMode(mode === 'mark' ? null : 'mark')} title="Place a marker with a note">📌 Marker</button>
</>
)
const cur = marks.find((k) => k.id === selMark)
const panel = !map ? null : (
<>
{mode === 'ruler' && <div className="tokenpanel toolspanel mapui"><b className="small">Tape measure</b><p className="small muted">{origin ? 'Click the end point. Esc cancels.' : 'Click the start point.'} 1 square = 1 grid cell. The result is shown to everybody for a few seconds.</p></div>}
{mode === 'tpl' && (
<div className="tokenpanel toolspanel mapui">
<b className="small">Area template</b>
<div className="toolrow" role="group" aria-label="Shape">{KINDS.map(([k, l]) => <button key={k} className={kind === k ? 'on mini' : 'ghost mini'} onClick={() => setKind(k)}>{l}</button>)}</div>
{kind === 'cone' && <label className="small">Angle {angle}°<input type="range" min="30" max="180" step="10" value={angle} onChange={(e) => setAngle(Number(e.target.value))} /></label>}
{kind === 'line' && <label className="small">Width {lineW} sq<input type="range" min="1" max="6" step="1" value={lineW} onChange={(e) => setLineW(Number(e.target.value))} /></label>}
<p className="small muted">{!origin ? (kind === 'cone' || kind === 'line' ? 'Click where it starts.' : 'Click the centre.') : 'Click to set direction and size.'} It stays for everybody until removed.</p>
</div>
)}
{(mode === 'tpl' || templates.length > 0) && templates.length > 0 && (
<div className={`tokenpanel toolspanel tpllist mapui ${mode ? 'low' : ''}`}>
<b className="small">Areas on the map</b>
{templates.map((t) => (
<div key={t.id} className="row small"><i className="dot" style={{ background: colorOf(t.user, t.dm) }} /> {KINDS.find((k) => k[0] === t.kind)?.[1]} {sizeLabel(t)} · {t.dm ? 'DM' : t.user}
{(t.mine || lobby.is_dm) && <button className="ghost mini danger" aria-label="Remove area" onClick={() => del(`/api/lobbies/${id}/templates/${t.id}`)}>✕</button>}</div>
))}
{lobby.is_dm && templates.length > 1 && <button className="ghost mini" onClick={() => del(`/api/lobbies/${id}/templates`)}>Remove all</button>}
</div>
)}
{mode === 'mark' && !draft && <div className="tokenpanel toolspanel mapui"><b className="small">Marker</b><p className="small muted">Click the map to place a marker with a note. Markers are private unless you share them with the table.</p></div>}
{draft && <MarkForm key="new" init={draft} title="New marker" onSave={async (v) => { await post(`/api/lobbies/${id}/marks`, { ...draft, ...v }); setDraft(null); setMode(null) }} onCancel={() => setDraft(null)} />}
{cur && !draft && (cur.mine
? <MarkForm key={cur.id} init={cur} title="Your marker" onSave={async (v) => { await fetch(`/api/lobbies/${id}/marks/${cur.id}`, { method: 'PUT', headers: J, body: JSON.stringify(v) }).catch(() => null); setSelMark(null) }}
onCancel={() => setSelMark(null)} onDelete={() => { del(`/api/lobbies/${id}/marks/${cur.id}`); setSelMark(null) }} />
: <div className="tokenpanel toolspanel mapui"><div className="row"><b>{cur.icon} {cur.title}</b><button className="ghost mini" onClick={() => setSelMark(null)} aria-label="Close">×</button></div>
<p className="small notetext">{cur.note || <span className="muted">No note.</span>}</p><p className="small muted">Shared by {cur.user}</p>
{lobby.is_dm && <button className="ghost mini danger" onClick={() => { del(`/api/lobbies/${id}/marks/${cur.id}`); setSelMark(null) }}>Remove</button>}</div>)}
</>
)
return { mode, active: !!mode, tool: mode ? 'draw' : undefined, onClick, onHover, overlay, buttons, panel, receive, stop: () => setMode(null) }
}
function Line({ a, b, g, color, label }) {
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2
return (
<g>
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke="#000" strokeWidth="6" strokeLinecap="round" opacity=".5" />
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke={color} strokeWidth="3" strokeLinecap="round" strokeDasharray="10 7" />
<circle cx={a.x} cy={a.y} r="6" fill={color} /><circle cx={b.x} cy={b.y} r="6" fill={color} />
<text x={mx} y={my - 8} className="toolabel" fontSize={Math.max(13, g * 0.34)} textAnchor="middle">{label}</text>
</g>
)
}
function MarkForm({ init, title, onSave, onCancel, onDelete }) {
const [v, setV] = useState({ icon: init.icon, title: init.title, note: init.note, shared: !!init.shared })
const busy = useRef(false)
const save = async () => { if (busy.current) return; busy.current = true; await onSave(v) }
return (
<div className="tokenpanel toolspanel mapui">
<div className="row"><b className="small">{title}</b><button className="ghost mini" onClick={onCancel} aria-label="Close">×</button></div>
<div className="iconrow" role="group" aria-label="Icon">{ICONS.map((i) => <button key={i} className={v.icon === i ? 'on mini' : 'ghost mini'} onClick={() => setV({ ...v, icon: i })}>{i}</button>)}</div>
<input value={v.title} maxLength={40} placeholder="Title" aria-label="Marker title" onChange={(e) => setV({ ...v, title: e.target.value })} autoFocus />
<textarea rows="4" maxLength={1000} placeholder="Your note ..." aria-label="Marker note" value={v.note} onChange={(e) => setV({ ...v, note: e.target.value })} />
<label className="small chk"><input type="checkbox" checked={v.shared} onChange={(e) => setV({ ...v, shared: e.target.checked })} /> Show to everybody at the table</label>
<div className="row"><button className="on" onClick={save}>Save</button>{onDelete && <button className="ghost danger" onClick={onDelete}>Delete</button>}</div>
</div>
)
}
+19
View File
@@ -652,3 +652,22 @@ textarea.pwrite:focus { box-shadow: 0 0 0 1px var(--pg); }
@keyframes pingring { 0% { transform: scale(.1); opacity: .95; } 100% { transform: scale(1.4); opacity: 0; } }
@keyframes pingname { 0%, 80% { opacity: 1; } 100% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .mapping i { animation-duration: 3s; animation-iteration-count: 1; } }
/* table tools: measure, areas, markers */
.toollayer { position: absolute; left: 0; top: 0; pointer-events: none; overflow: visible; }
.toolabel { fill: #fff; stroke: #000; stroke-width: 3px; paint-order: stroke; font-weight: 700; }
.mappin { position: absolute; transform: translate(-50%, -92%); padding: 0; margin: 0; min-height: 0; background: none; border: 0; line-height: 1; cursor: pointer; filter: drop-shadow(0 2px 2px rgba(0,0,0,.8)); z-index: 4; }
.mappin.shared { filter: drop-shadow(0 0 4px #fff) drop-shadow(0 2px 2px rgba(0,0,0,.8)); }
.mappin.sel { outline: 2px solid var(--accent, gold); border-radius: 6px; }
.mappin.draft { opacity: .6; pointer-events: none; }
.toolspanel { right: auto; left: .5rem; top: .5rem; bottom: auto; display: grid; gap: .4rem; max-width: 18rem; }
.toolspanel.tpllist { top: .5rem; left: .5rem; }
.toolspanel.tpllist.low { left: 15.8rem; }
.toolspanel textarea, .toolspanel input:not([type=range]):not([type=checkbox]) { width: 100%; font: inherit; }
.toolspanel .toolrow, .toolspanel .iconrow { display: flex; flex-wrap: wrap; gap: .25rem; }
.toolspanel .dot { display: inline-block; width: .7rem; height: .7rem; border-radius: 50%; }
.toolspanel .notetext { white-space: pre-wrap; margin: 0; }
.toolspanel { padding: .45rem .55rem; max-width: 15rem; gap: .3rem; font-size: .8rem; }
.toolspanel button { padding: .15rem .5rem; font-size: .78rem; min-height: 0; width: auto; }
.toolspanel p { margin: 0; }
.toolspanel input[type=range] { width: 100%; }
.toolspanel input:not([type=range]):not([type=checkbox]), .toolspanel textarea { padding: .25rem .4rem; font-size: .82rem; }