Waiter PWA fixes, and extra feautures. Also added Emergency Mode, search etc
This commit is contained in:
@@ -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"}}}}
|
||||
39
CLAUDE_DESIGN/Table Grid Densities.html
Normal file
39
CLAUDE_DESIGN/Table Grid Densities.html
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
375
CLAUDE_DESIGN/table-cards-densities.jsx
Normal file
375
CLAUDE_DESIGN/table-cards-densities.jsx
Normal 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 };
|
||||
167
CLAUDE_DESIGN/tables-app.jsx
Normal file
167
CLAUDE_DESIGN/tables-app.jsx
Normal 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 />);
|
||||
47
CLAUDE_DESIGN/tables-data.jsx
Normal file
47
CLAUDE_DESIGN/tables-data.jsx
Normal 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;
|
||||
BIN
CLAUDE_DESIGN/uploads/pasted-1777645261768-0.png
Normal file
BIN
CLAUDE_DESIGN/uploads/pasted-1777645261768-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
BIN
CLAUDE_DESIGN/uploads/pasted-1777645330082-0.png
Normal file
BIN
CLAUDE_DESIGN/uploads/pasted-1777645330082-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Reference in New Issue
Block a user