Waiter PWA fixes, and extra feautures. Also added Emergency Mode, search etc

This commit is contained in:
2026-05-02 21:08:53 +03:00
parent 8e27b7666e
commit c9ad78ec71
50 changed files with 4441 additions and 643 deletions

View File

@@ -1 +1 @@
{"sections":{"v1":{"labels":{"v1-grid":"Grid of 8 tables — mixed statuses"}}}}
{"sections":{"v1":{"labels":{"v1-grid":"Grid of 8 tables — mixed statuses"}},"desktop":{"labels":{"desktop-main":"1440×900 — full operational view, mid-shift"}}}}

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Table Grid Densities — SimplePOS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700;800&family=Geist+Mono:wght@500;600;700;800&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
background: #f4f4f2;
color: #111315;
font-family: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-feature-settings: 'ss01', 'cv11';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root { width: 100vw; height: 100vh; }
*::-webkit-scrollbar { width: 8px; height: 8px; }
*::-webkit-scrollbar-thumb { background: #dfe2e6; border-radius: 4px; }
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="ios-frame.jsx"></script>
<script type="text/babel" src="tables-data.jsx"></script>
<script type="text/babel" src="table-cards-densities.jsx"></script>
<script type="text/babel" src="tables-app.jsx"></script>
</body>
</html>

View File

@@ -1,10 +1,10 @@
// DesignCanvas.jsx — Figma-ish design canvas wrapper
// Warm gray grid bg + Sections + Artboards + PostIt notes.
// Artboards are reorderable (grip-drag), labels/titles are inline-editable,
// and any artboard can be opened in a fullscreen focus overlay (←/→/Esc).
// State persists to a .design-canvas.state.json sidecar via the host
// bridge. No assets, no deps.
// Artboards are reorderable (grip-drag), deletable, labels/titles are
// inline-editable, and any artboard can be opened in a fullscreen focus
// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
// via the host bridge. No assets, no deps.
//
// Usage:
// <DesignCanvas>
@@ -39,17 +39,58 @@ if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
'.dc-card{transition:box-shadow .15s,transform .15s}',
'.dc-card *{scrollbar-width:none}',
'.dc-card *::-webkit-scrollbar{display:none}',
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px}',
'.dc-grip{cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s}',
// Per-artboard header: grip + label on the left, delete/expand on the
// right. Single flex row; when the artboard's on-screen width is too
// narrow for both the label yields (ellipsis, then hidden entirely below
// ~4ch via the container query) and the buttons stay on the row.
'.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
' display:flex;align-items:center;container-type:inline-size}',
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
'.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
'.dc-grip:active{cursor:grabbing}',
'.dc-labeltext{cursor:pointer;border-radius:4px;padding:3px 6px;display:flex;align-items:center;transition:background .12s}',
'.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
// Below ~4ch of label room: hide the label entirely, and drop the grip to
// hover-only (same reveal rule as .dc-btns) so a narrow header is clean
// until the card is moused.
'@container (max-width: 110px){',
' .dc-labeltext{display:none}',
' .dc-grip{opacity:0}',
' [data-dc-slot]:hover .dc-grip{opacity:1}',
'}',
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
'.dc-expand{position:absolute;bottom:100%;right:0;margin-bottom:5px;z-index:2;opacity:0;transition:opacity .12s,background .12s;',
' width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center}',
'.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
'.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
'.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
'[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-confirm){opacity:1}',
'.dc-expand,.dc-delete{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
' font:inherit;transition:background .12s,color .12s}',
'.dc-expand:hover{background:rgba(0,0,0,.06);color:#2a251f}',
'[data-dc-slot]:hover .dc-expand{opacity:1}',
'.dc-delete:hover{background:rgba(201,100,66,.12);color:#c96442}',
'.dc-delete.dc-confirm{width:auto;padding:0 7px;gap:5px;background:#c96442;color:#fff;',
' font-size:12px;font-weight:500}',
'.dc-delete.dc-confirm:hover{background:#b5563a}',
// Chrome (titles / labels / buttons) counter-scales against the viewport
// zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
// DCViewport on every transform update and inherits to all descendants —
// any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
// it the same way.
//
// The header uses transform:scale (out-of-flow, so layout impact doesn't
// matter) with its world-space width set to card-width / inv-zoom so that
// after counter-scaling its on-screen width exactly matches the card's —
// that's what lets the container query + text-overflow behave against the
// card's visible edge at every zoom level.
//
// The section head uses CSS zoom instead of transform so its layout box
// grows with the counter-scale, pushing the card row down — otherwise the
// constant-screen-size title would overflow into the (shrinking) world-
// space gap and overlap the artboard headers at low zoom.
'.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
'.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
].join('\n');
document.head.appendChild(s);
}
@@ -58,8 +99,9 @@ const DCCtx = React.createContext(null);
// ─────────────────────────────────────────────────────────────
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
// Owns runtime state (per-section order, renamed titles/labels, focused
// artboard). Order/titles/labels persist to a .design-canvas.state.json
// Owns runtime state (per-section order, renamed titles/labels, hidden
// artboards, focused artboard). Order/titles/labels/hidden persist to a
// .design-canvas.state.json
// sidecar next to the HTML. Reads go via plain fetch() so the saved
// arrangement is visible anywhere the HTML + sidecar are served together
// (omelette preview, direct link, downloaded zip). Writes go through the
@@ -115,11 +157,19 @@ function DesignCanvas({ children, minScale, maxScale, style }) {
if (!sid) return;
sectionOrder.push(sid);
const persisted = state.sections[sid] || {};
const srcIds = [];
const abs = [];
React.Children.forEach(sec.props.children, (ab) => {
if (!ab || ab.type !== DCArtboard) return;
const aid = ab.props.id ?? ab.props.label;
if (!aid) return;
if (aid) abs.push([aid, ab]);
});
// hidden is scoped to one source revision — when the agent regenerates
// (artboard-ID set changes), prior deletes don't apply to new content.
const srcKey = abs.map(([k]) => k).join('\x1f');
const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
const srcIds = [];
abs.forEach(([aid, ab]) => {
if (hidden.includes(aid)) return;
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
srcIds.push(aid);
});
@@ -183,11 +233,48 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
const vpRef = React.useRef(null);
const worldRef = React.useRef(null);
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
// Persist viewport across reloads so the user lands back where they were
// after an agent edit or browser refresh. The sandbox origin is already
// per-project; pathname keeps multiple canvas files in one project apart.
const tfKey = 'dc-viewport:' + location.pathname;
const saveT = React.useRef(0);
const lastPostedScale = React.useRef();
const apply = React.useCallback(() => {
const { x, y, scale } = tf.current;
const el = worldRef.current;
if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
if (!el) return;
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
// Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
el.style.setProperty('--dc-inv-zoom', String(1 / scale));
// Keep the host toolbar's % readout in sync with the canvas scale. Pan
// ticks leave scale unchanged — skip the cross-frame post for those.
if (lastPostedScale.current !== scale) {
lastPostedScale.current = scale;
window.parent.postMessage({ type: '__dc_zoom', scale }, '*');
}
clearTimeout(saveT.current);
saveT.current = setTimeout(() => {
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
}, 200);
}, [tfKey]);
React.useLayoutEffect(() => {
const flush = () => {
clearTimeout(saveT.current);
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
};
try {
const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
apply();
}
} catch {}
// Flush on pagehide and unmount so a reload within the 200ms debounce
// window doesn't drop the last pan/zoom.
window.addEventListener('pagehide', flush);
return () => { window.removeEventListener('pagehide', flush); flush(); };
}, []);
React.useEffect(() => {
@@ -272,6 +359,36 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
vp.style.cursor = '';
};
// Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
// visible midpoint stays fixed — matching the host's iframe-zoom feel.
const onHostMsg = (e) => {
const d = e.data;
if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') {
const r = vp.getBoundingClientRect();
zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
} else if (d && d.type === '__dc_probe') {
// Host's [readyGen] reset asks whether a canvas is present; it
// fires on the iframe's native 'load', which for canvases with
// images/fonts is after our mount-time announce, so re-announce.
// Clear the pan-tick guard so apply() re-posts the current scale
// even if it's unchanged — the host just reset dcScale to 1.
window.parent.postMessage({ type: '__dc_present' }, '*');
lastPostedScale.current = undefined;
apply();
}
};
window.addEventListener('message', onHostMsg);
// Announce canvas mode so the host toolbar proxies its % control here
// instead of scaling the iframe element (which would just shrink the
// viewport window of an infinite canvas). The apply() that follows emits
// the initial __dc_zoom so the toolbar % is correct before first pinch.
// lastPostedScale reset mirrors the __dc_probe handler: the layout
// effect's restore-path apply() may already have posted the restored
// scale (before __dc_present), so clear the guard to re-post it in order.
window.parent.postMessage({ type: '__dc_present' }, '*');
lastPostedScale.current = undefined;
apply();
vp.addEventListener('wheel', onWheel, { passive: false });
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
@@ -281,6 +398,7 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
vp.addEventListener('pointerup', onPointerUp);
vp.addEventListener('pointercancel', onPointerUp);
return () => {
window.removeEventListener('message', onHostMsg);
vp.removeEventListener('wheel', onWheel);
vp.removeEventListener('gesturestart', onGestureStart);
vp.removeEventListener('gesturechange', onGestureChange);
@@ -336,8 +454,13 @@ function DCSection({ id, title, subtitle, children, gap = 48 }) {
const all = React.Children.toArray(children);
const artboards = all.filter((c) => c && c.type === DCArtboard);
const rest = all.filter((c) => !(c && c.type === DCArtboard));
const srcOrder = artboards.map((a) => a.props.id ?? a.props.label);
const sec = (ctx && sid && ctx.section(sid)) || {};
// Must match DesignCanvas's srcKey computation exactly (it filters falsy
// IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
const srcKey = allIds.join('\x1f');
const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
const srcOrder = allIds.filter((k) => !hidden.includes(k));
const order = React.useMemo(() => {
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
@@ -346,13 +469,22 @@ function DCSection({ id, title, subtitle, children, gap = 48 }) {
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
// marginBottom counter-scales so the on-screen gap between sections stays
// constant — otherwise at low zoom the (world-space) gap collapses while
// the screen-constant sectionhead below it doesn't, and the title reads as
// belonging to the section above. paddingBottom below is just enough for
// the 24px artboard-header (abs-positioned above each card) plus ~8px, so
// the title sits tight against its own row at every zoom.
return (
<div data-dc-section={sid} style={{ marginBottom: 80, position: 'relative' }}>
<div style={{ padding: '0 60px 56px' }}>
<DCEditable tag="div" value={sec.title ?? title}
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
<div data-dc-section={sid}
style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
<div style={{ padding: '0 60px' }}>
<div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
<DCEditable tag="div" value={sec.title ?? title}
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
</div>
</div>
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
{order.map((k) => (
@@ -360,6 +492,10 @@ function DCSection({ id, title, subtitle, children, gap = 48 }) {
label={(sec.labels || {})[k] ?? byId[k].props.label}
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
srcKey,
}))}
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
))}
</div>
@@ -371,10 +507,22 @@ function DCSection({ id, title, subtitle, children, gap = 48 }) {
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
function DCArtboard() { return null; }
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus }) {
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
const id = rawId ?? rawLabel;
const ref = React.useRef(null);
const delRef = React.useRef(null);
const [confirming, setConfirming] = React.useState(false);
// Two-click delete: first click arms the button (turns into an inline
// "Delete?" pill), second click commits. Any pointerdown outside the
// button disarms.
React.useEffect(() => {
if (!confirming) return;
const off = (e) => { if (!delRef.current || !delRef.current.contains(e.target)) setConfirming(false); };
document.addEventListener('pointerdown', off, true);
return () => document.removeEventListener('pointerdown', off, true);
}, [confirming]);
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
// their would-be slots in real time via transforms. DOM order only
@@ -440,18 +588,32 @@ function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorde
return (
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
<div className="dc-labelrow" style={{ position: 'absolute', bottom: '100%', left: -4, marginBottom: 4, color: DC.label }}>
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
<div className="dc-header" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
<div className="dc-labelrow">
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
</div>
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
</div>
</div>
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
<div className="dc-btns">
<button ref={delRef} className={'dc-delete' + (confirming ? ' dc-confirm' : '')}
onClick={() => { if (confirming) onDelete(); else setConfirming(true); }}
title={confirming ? 'Click again to delete' : 'Delete'}>
{confirming
? <>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M2 3.5h8M4.5 3.5v-1a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1M3 3.5v6a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-6"/></svg>
Delete?
</>
: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M2 3.5h8M4.5 3.5v-1a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1M3 3.5v6a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-6M5 5.5v3M7 5.5v3"/></svg>}
</button>
<button className="dc-expand" onClick={onFocus} title="Focus">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
</button>
</div>
</div>
<button className="dc-expand" onClick={onFocus} onPointerDown={(e) => e.stopPropagation()} title="Focus">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
</button>
<div className="dc-card"
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
@@ -489,9 +651,14 @@ function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
const goSection = (d) => {
const ns = sectionOrder[(secIdx + d + sectionOrder.length) % sectionOrder.length];
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
if (first) ctx.setFocus(`${ns}/${first}`);
// Sections whose artboards are all deleted have slotIds:[] — step past
// them to the next non-empty section so ↑/↓ doesn't dead-end.
const n = sectionOrder.length;
for (let i = 1; i < n; i++) {
const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
if (first) { ctx.setFocus(`${ns}/${first}`); return; }
}
};
React.useEffect(() => {
@@ -548,7 +715,7 @@ function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
{ddOpen && (
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
{sectionOrder.map((sid) => (
{sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',

View File

@@ -0,0 +1,375 @@
// Table cards at 5 densities. All share the same data model — each card type
// just renders a subset, sized for fast reading at-a-glance.
const { TABLE_STATUS, TABLE_BADGES } = window;
// ---------- shared bits ----------------------------------------------------
function fmtAmount(n) {
if (n == null || n === 0) return '0.00';
return n.toFixed(2);
}
// Splits "12.34" into ["12", ".34"] so we can typeset cents smaller
function splitAmount(n) {
const s = fmtAmount(n);
const [whole, cents] = s.split('.');
return [whole, '.' + cents];
}
function avatarHash(name) {
const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775', '#1d6f3a'];
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return palette[h % palette.length];
}
function WaiterDot({ name, size = 22, ring }) {
const initials = name.split(' ').map(p => p[0]).slice(0, 2).join('').toUpperCase();
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: avatarHash(name),
color: 'white', fontSize: size * 0.42, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
boxShadow: ring ? `0 0 0 2px ${ring}` : 'none',
}}>{initials}</div>
);
}
function StackedAvatars({ waiters, size = 22, ring }) {
if (!waiters?.length) return null;
if (waiters.length >= 3) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 0 }}>
{waiters.slice(0, 2).map((w, i) => (
<div key={i} style={{ marginLeft: i === 0 ? 0 : -size * 0.35 }}>
<WaiterDot name={w} size={size} ring={ring} />
</div>
))}
<div style={{
marginLeft: -size * 0.35,
height: size, padding: '0 8px',
borderRadius: size,
background: ring || 'rgba(255,255,255,0.9)',
color: '#1a1a1f', fontSize: 11, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: ring ? `0 0 0 2px ${ring}` : 'none',
}}>+{waiters.length - 2}</div>
</div>
);
}
return (
<div style={{ display: 'flex' }}>
{waiters.map((w, i) => (
<div key={i} style={{ marginLeft: i === 0 ? 0 : -size * 0.3 }}>
<WaiterDot name={w} size={size} ring={ring} />
</div>
))}
</div>
);
}
function StatusPill({ status, size = 'md' }) {
const s = TABLE_STATUS[status];
const sizes = {
sm: { h: 18, px: 7, fs: 10 },
md: { h: 22, px: 9, fs: 11 },
lg: { h: 26, px: 11, fs: 12 },
};
const z = sizes[size];
return (
<span style={{
display: 'inline-flex', alignItems: 'center', height: z.h, padding: `0 ${z.px}px`,
borderRadius: 4,
background: s.pillBg, color: s.pillFg,
fontSize: z.fs, fontWeight: 800,
letterSpacing: 0.5, textTransform: 'uppercase',
whiteSpace: 'nowrap',
}}>{s.label}</span>
);
}
function BadgeChip({ kind, size = 'md' }) {
const b = TABLE_BADGES[kind];
if (!b) return null;
const sizes = {
sm: { h: 20, fs: 11, ic: 12 },
md: { h: 24, fs: 12, ic: 14 },
lg: { h: 28, fs: 13, ic: 16 },
};
const z = sizes[size];
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
height: z.h, padding: '0 8px',
borderRadius: z.h / 2,
background: 'rgba(255,255,255,0.95)',
color: b.tone,
fontSize: z.fs, fontWeight: 700,
}}>
<span style={{ fontSize: z.ic, lineHeight: 1 }}>{b.icon}</span>
{b.label}
</span>
);
}
function BadgeDot({ kind, size = 16 }) {
const b = TABLE_BADGES[kind];
if (!b) return null;
return (
<div title={b.label} style={{
width: size, height: size,
borderRadius: '50%',
background: 'rgba(255,255,255,0.95)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.65,
lineHeight: 1,
}}>{b.icon}</div>
);
}
function Amount({ value, size = 22, color }) {
const [w, c] = splitAmount(value);
return (
<div style={{
fontFamily: "'Geist Mono', monospace",
fontWeight: 700,
lineHeight: 1,
color: color || 'inherit',
letterSpacing: -0.5,
}}>
<span style={{ fontSize: size }}>{w}</span>
<span style={{ fontSize: size * 0.55, opacity: 0.85 }}>{c}</span>
</div>
);
}
// ---------- card shell -----------------------------------------------------
// All densities share this shell — just different content + dimensions.
function CardShell({ status, w, h, children, padding }) {
const s = TABLE_STATUS[status];
return (
<div style={{
width: w, height: h,
background: s.bg, color: s.fg,
borderRadius: 14,
padding: padding,
boxShadow: '0 1px 2px rgba(16,20,24,0.05)',
position: 'relative',
overflow: 'hidden',
display: 'flex', flexDirection: 'column',
cursor: 'pointer',
transition: 'transform 100ms ease',
}}>{children}</div>
);
}
// ===========================================================================
// 1×1 — tiniest. Just NAME. Status is purely the card color.
// ===========================================================================
function Card1x1({ table, w, h }) {
const t = table;
// Show one badge dot if present (very subtle, top-right)
const badge = t.badges[0];
return (
<CardShell status={t.status} w={w} h={h} padding={10}>
<div style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: "'Geist Mono', monospace",
fontWeight: 800, fontSize: 26,
letterSpacing: -1,
}}>{t.name}</div>
{badge && (
<div style={{ position: 'absolute', top: 6, right: 6 }}>
<BadgeDot kind={badge} size={14} />
</div>
)}
</CardShell>
);
}
// ===========================================================================
// 2×1 — wider. NAME + status PILL + maybe one badge dot.
// ===========================================================================
function Card2x1({ table, w, h }) {
const t = table;
return (
<CardShell status={t.status} w={w} h={h} padding={12}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', height: '100%', gap: 10 }}>
<div style={{
fontFamily: "'Geist Mono', monospace",
fontWeight: 800, fontSize: 26,
letterSpacing: -1, lineHeight: 1,
}}>{t.name}</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 4 }}>
<StatusPill status={t.status} size="sm" />
{t.badges.length > 0 && (
<div style={{ display: 'flex', gap: 3 }}>
{t.badges.slice(0, 2).map(b => <BadgeDot key={b} kind={b} size={14} />)}
</div>
)}
</div>
</div>
</CardShell>
);
}
// ===========================================================================
// 2×2 — square. NAME big + status pill + amount + waiter dots + badges
// ===========================================================================
function Card2x2({ table, w, h }) {
const t = table;
const showAmount = t.amount > 0 || t.status === 'paid' || t.status === 'partial';
return (
<CardShell status={t.status} w={w} h={h} padding={12}>
<div style={{ display: 'flex', height: '100%', gap: 8 }}>
{/* left column: name + pill (top), amount (bottom) */}
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{
fontFamily: "'Geist Mono', monospace",
fontWeight: 800, fontSize: 30,
letterSpacing: -1, lineHeight: 1,
}}>{t.name}</div>
<div style={{ marginTop: 6 }}>
<StatusPill status={t.status} size="sm" />
</div>
<div style={{ marginTop: 'auto', minHeight: 24 }}>
{showAmount && <Amount value={t.amount} size={22} />}
</div>
</div>
{/* right column: badges stacked vertically, bottom-aligned */}
{t.badges.length > 0 && (
<div style={{
display: 'flex', flexDirection: 'column-reverse',
gap: 4, alignItems: 'flex-end',
justifyContent: 'flex-start',
}}>
{t.badges.slice(0, 3).map(b => <BadgeDot key={b} kind={b} size={20} />)}
</div>
)}
</div>
</CardShell>
);
}
// ===========================================================================
// 4×1 — wide horizontal. NAME · AMOUNT · status pill + waiter dots
// ===========================================================================
function Card4x1({ table, w, h }) {
const t = table;
const showAmount = t.amount > 0 || t.status === 'paid' || t.status === 'partial';
return (
<CardShell status={t.status} w={w} h={h} padding={14}>
<div style={{ display: 'flex', alignItems: 'center', height: '100%', gap: 14 }}>
{/* name */}
<div style={{
fontFamily: "'Geist Mono', monospace",
fontWeight: 800, fontSize: 30,
letterSpacing: -1, lineHeight: 1,
minWidth: 70,
}}>{t.name}</div>
{/* amount (or spacer) */}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 10 }}>
{showAmount && <Amount value={t.amount} size={22} />}
</div>
{/* badges */}
{t.badges.length > 0 && (
<div style={{ display: 'flex', gap: 4 }}>
{t.badges.slice(0, 2).map(b => <BadgeDot key={b} kind={b} size={20} />)}
</div>
)}
{/* status pill */}
<StatusPill status={t.status} size="md" />
</div>
</CardShell>
);
}
// ===========================================================================
// 4×2 — full detail. Name + section + status pill + amount + badges + waiters with names
// ===========================================================================
function Card4x2({ table, w, h }) {
const t = table;
const s = TABLE_STATUS[t.status];
const showAmount = t.amount > 0 || t.status === 'paid' || t.status === 'partial';
// First waiter name (or "Multiple")
const waiterCaption = t.waiters.length === 0
? 'Unassigned'
: t.waiters.length >= 3
? `${t.waiters.length} waiters`
: t.waiters.map(w => w.split(' ')[0]).join(', ');
return (
<CardShell status={t.status} w={w} h={h} padding={16}>
{/* top row: name + section + status pill | amount */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{
fontFamily: "'Geist Mono', monospace",
fontWeight: 800, fontSize: 38,
letterSpacing: -1.5, lineHeight: 1,
}}>{t.name}</div>
<div style={{
fontSize: 11, fontWeight: 700,
opacity: 0.7,
textTransform: 'uppercase', letterSpacing: 0.8,
marginTop: 4,
}}>{t.section}</div>
<div style={{ marginTop: 8 }}>
<StatusPill status={t.status} size="lg" />
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
{showAmount && <Amount value={t.amount} size={38} />}
</div>
</div>
{/* badges block — right-aligned, up to 4 in 2×2 grid, sits above waiter line */}
<div style={{
marginTop: 'auto',
display: 'flex', justifyContent: 'flex-end',
paddingBottom: 10,
minHeight: 24,
}}>
{t.badges.length > 0 && (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, max-content)',
gridAutoRows: 'min-content',
gap: 6,
justifyItems: 'end',
direction: 'rtl', // fill right column first, then wrap left
}}>
{t.badges.slice(0, 4).map(b => (
<div key={b} style={{ direction: 'ltr' }}>
<BadgeChip kind={b} size="sm" />
</div>
))}
</div>
)}
</div>
{/* bottom: waiters with names */}
<div style={{
paddingTop: 10,
borderTop: '1px solid rgba(255,255,255,0.18)',
display: 'flex', alignItems: 'center', gap: 10,
}}>
{t.waiters.length === 0 ? (
<span style={{ fontSize: 13, opacity: 0.7, fontWeight: 500 }}>Unassigned</span>
) : (
<>
<StackedAvatars waiters={t.waiters} size={26} ring={s.bg} />
<span style={{ fontSize: 14, fontWeight: 600 }}>{waiterCaption}</span>
</>
)}
</div>
</CardShell>
);
}
window.TableCards = { Card1x1, Card2x1, Card2x2, Card4x1, Card4x2 };

View File

@@ -0,0 +1,167 @@
// Wrapping screens — phone frame with the grid at each density
const { IOSDevice } = window;
const { TABLES } = window;
const { Card1x1, Card2x1, Card2x2, Card4x1, Card4x2 } = window.TableCards;
const { DesignCanvas, DCSection, DCArtboard } = window;
// Density specs — each one has a column count, gap, and a card renderer.
// "1x1" means 4 columns of tiny squares; "4x2" means 1 large card per row.
//
// The naming reflects relative density: 1x1 = highest density (smallest cards),
// 4x2 = lowest density (biggest, most info).
const DENSITIES = {
'1x1': {
label: '1×1 — Highest density',
desc: 'Just the name. Status as color.',
cols: 4, gap: 8,
aspectW: 1, aspectH: 1,
Card: Card1x1,
},
'2x1': {
label: '2×1 — Compact',
desc: 'Name + status pill.',
cols: 2, gap: 10,
aspectW: 2, aspectH: 1,
Card: Card2x1,
},
'2x2': {
label: '2×2 — Balanced',
desc: 'Name, status, amount, waiters.',
cols: 2, gap: 12,
aspectW: 1, aspectH: 1,
Card: Card2x2,
},
'4x1': {
label: '4×1 — Wide row',
desc: 'Name, amount, status, waiters.',
cols: 1, gap: 10,
aspectW: 4, aspectH: 1,
Card: Card4x1,
},
'4x2': {
label: '4×2 — Full detail',
desc: 'Everything. Section, badges, waiter names.',
cols: 1, gap: 12,
aspectW: 2, aspectH: 1,
Card: Card4x2,
},
};
// Top filter bar
function FilterBar() {
const filters = [
{ label: 'All', active: true },
{ label: 'Mine' },
{ label: 'Free' },
{ label: 'Zone (2)' },
];
return (
<div style={{
padding: '10px 16px 14px',
background: 'white',
display: 'flex', gap: 8,
borderBottom: '1px solid #edeff1',
}}>
{filters.map(f => (
<button key={f.label} style={{
height: 38, padding: '0 16px',
borderRadius: 10,
background: f.active ? '#f5b740' : 'white',
border: '1.5px solid ' + (f.active ? '#f5b740' : '#dfe2e6'),
color: f.active ? '#3a2a05' : '#5a6169',
fontSize: 14, fontWeight: 700,
fontFamily: 'inherit',
cursor: 'pointer',
}}>{f.label}</button>
))}
</div>
);
}
function Header({ density }) {
return (
<div style={{
padding: '54px 16px 10px',
background: 'white',
display: 'flex', alignItems: 'center', gap: 10,
}}>
<div style={{ flex: 1, fontSize: 20, fontWeight: 700, color: '#111315' }}>Tables</div>
<button style={{
width: 38, height: 38,
borderRadius: 19, border: '1px solid #dfe2e6', background: 'white',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M12 22C13.1 22 14 21.1 14 20H10C10 21.1 10.9 22 12 22ZM18 16V11C18 7.9 16.4 5.4 13.5 4.7V4C13.5 3.2 12.8 2.5 12 2.5C11.2 2.5 10.5 3.2 10.5 4V4.7C7.6 5.4 6 7.9 6 11V16L4 18V19H20V18L18 16Z" stroke="#2b2f33" strokeWidth="1.6"/>
</svg>
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14, fontWeight: 600, color: '#2b2f33' }}>
dimitris
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M6 9L12 15L18 9" stroke="#5a6169" strokeWidth="2"/></svg>
</div>
</div>
);
}
function DensityScreen({ densityKey }) {
const d = DENSITIES[densityKey];
// Compute card width: phone interior is ~370px wide, padding 12px each side
const padding = 12;
const innerW = 370 - padding * 2;
const cardW = (innerW - d.gap * (d.cols - 1)) / d.cols;
const cardH = cardW * (d.aspectH / d.aspectW);
return (
<div style={{
width: '100%', height: '100%',
background: '#f4f4f2',
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
}}>
<Header density={d.label} />
<FilterBar />
<div style={{ flex: 1, overflowY: 'auto', padding: padding }}>
<div style={{
display: 'grid',
gridTemplateColumns: `repeat(${d.cols}, 1fr)`,
gap: d.gap,
}}>
{TABLES.map(t => (
<d.Card key={t.name} table={t} w={cardW} h={cardH} />
))}
</div>
</div>
</div>
);
}
function App() {
const order = ['1x1', '2x1', '2x2', '4x1', '4x2'];
return (
<DesignCanvas title="Table grid — 5 density options">
<DCSection id="densities" title="Density variants — selectable in user settings">
{order.map(k => {
const d = DENSITIES[k];
return (
<DCArtboard key={k} id={k} label={d.label + ' — ' + d.desc} width={460} height={920}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: '100%', height: '100%',
background: 'transparent',
}}>
<IOSDevice>
<DensityScreen densityKey={k} />
</IOSDevice>
</div>
</DCArtboard>
);
})}
</DCSection>
</DesignCanvas>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

View File

@@ -0,0 +1,47 @@
// Table grid data + status palette
// Statuses — bold colors, high contrast for fast reading
const TABLE_STATUS = {
free: { label: 'Free', bg: '#e9ebee', fg: '#3a3f45', pillBg: '#d3d6db', pillFg: '#3a3f45' },
open: { label: 'Open', bg: '#f5b740', fg: '#3a2a05', pillBg: '#3a2a05', pillFg: '#ffe7b2' },
partial: { label: 'Partial', bg: '#3b86e6', fg: '#ffffff', pillBg: 'rgba(0,0,0,0.25)', pillFg: '#ffffff' },
paid: { label: 'Paid', bg: '#3aa961', fg: '#ffffff', pillBg: 'rgba(0,0,0,0.25)', pillFg: '#ffffff' },
reserved: { label: 'Reserved', bg: '#8b5cd6', fg: '#ffffff', pillBg: 'rgba(0,0,0,0.25)', pillFg: '#ffffff' },
attention: { label: 'Needs you', bg: '#e64545', fg: '#ffffff', pillBg: 'rgba(0,0,0,0.3)', pillFg: '#ffffff' },
mine: { label: 'Mine', bg: '#1f1f24', fg: '#ffffff', pillBg: '#f5b740', pillFg: '#3a2a05' },
};
// Badge dictionary — icon + tone for each flag
const TABLE_BADGES = {
cleaning: { label: 'Cleaning', icon: '🧹', tone: '#8a6d2b' },
waiter: { label: 'Waiter', icon: '🔔', tone: '#d94b26' },
vip: { label: 'VIP', icon: '⭐', tone: '#a76b00' },
allergy: { label: 'Allergy', icon: '⚠', tone: '#a5361b' },
birthday: { label: 'Birthday', icon: '🎂', tone: '#a8276b' },
};
// 24 demo tables across statuses + sections
const TABLES = [
{ name: 'A-1', section: 'Terrace', status: 'open', amount: 84.50, waiters: ['Marco Riva'], badges: [] },
{ name: 'A-2', section: 'Terrace', status: 'mine', amount: 127.20, waiters: ['You'], badges: ['vip'] },
{ name: 'A-3', section: 'Terrace', status: 'free', amount: 0, waiters: [], badges: [] },
{ name: 'A-4', section: 'Terrace', status: 'attention', amount: 56.00, waiters: ['Luca'], badges: ['waiter'] },
{ name: 'A-5', section: 'Terrace', status: 'reserved', amount: 0, waiters: ['Elena'], badges: ['birthday'] },
{ name: 'A-6', section: 'Terrace', status: 'paid', amount: 0, waiters: ['Marco Riva'], badges: [] },
{ name: 'B-1', section: 'Hall', status: 'partial', amount: 38.00, waiters: ['Sofia'], badges: [] },
{ name: 'B-2', section: 'Hall', status: 'open', amount: 212.80, waiters: ['Marco', 'Sofia', 'Luca', 'Elena'], badges: ['vip', 'allergy', 'birthday', 'waiter'] },
{ name: 'B-3', section: 'Hall', status: 'free', amount: 0, waiters: [], badges: ['cleaning'] },
{ name: 'B-4', section: 'Hall', status: 'mine', amount: 16.30, waiters: ['You', 'Billy'], badges: [] },
{ name: 'B-5', section: 'Hall', status: 'open', amount: 72.80, waiters: ['Sofia'], badges: ['allergy'] },
{ name: 'B-6', section: 'Hall', status: 'free', amount: 0, waiters: [], badges: [] },
{ name: 'C-1', section: 'Bar', status: 'partial', amount: 24.50, waiters: ['Elena'], badges: [] },
{ name: 'C-2', section: 'Bar', status: 'free', amount: 0, waiters: [], badges: [] },
{ name: 'C-3', section: 'Bar', status: 'paid', amount: 0, waiters: ['Luca'], badges: [] },
{ name: 'C-4', section: 'Bar', status: 'reserved', amount: 0, waiters: ['Sofia'], badges: [] },
];
window.TABLE_STATUS = TABLE_STATUS;
window.TABLE_BADGES = TABLE_BADGES;
window.TABLES = TABLES;

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB