Initial backup of root workspace files
Glue files not covered by subproject repos: top-level docs, logo, keysat-design-system, and crosscheck tests. Subproject folders are gitignored (each has its own Gitea remote).
This commit is contained in:
@@ -0,0 +1,936 @@
|
||||
|
||||
// DesignCanvas.jsx — Figma-ish design canvas wrapper
|
||||
// Warm gray grid bg + Sections + Artboards + PostIt notes.
|
||||
// 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>
|
||||
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
|
||||
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
|
||||
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
|
||||
// </DCSection>
|
||||
// </DesignCanvas>
|
||||
|
||||
const DC = {
|
||||
bg: '#f0eee9',
|
||||
grid: 'rgba(0,0,0,0.06)',
|
||||
label: 'rgba(60,50,40,0.7)',
|
||||
title: 'rgba(40,30,20,0.85)',
|
||||
subtitle: 'rgba(60,50,40,0.6)',
|
||||
postitBg: '#fef4a8',
|
||||
postitText: '#5a4a2a',
|
||||
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
|
||||
};
|
||||
|
||||
// One-time CSS injection (classes are dc-prefixed so they don't collide with
|
||||
// the hosted design's own styles).
|
||||
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
|
||||
const s = document.createElement('style');
|
||||
s.id = 'dc-styles';
|
||||
s.textContent = [
|
||||
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
|
||||
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
|
||||
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
|
||||
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
|
||||
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
|
||||
// isolation:isolate contains artboard content's z-indexes so a
|
||||
// z-indexed child (sticky navbar etc.) can't paint over .dc-header or
|
||||
// the .dc-menu popover that drops into the top of the card.
|
||||
'.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}',
|
||||
'.dc-card *{scrollbar-width:none}',
|
||||
'.dc-card *::-webkit-scrollbar{display:none}',
|
||||
// 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{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-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-menu){opacity:1}',
|
||||
'.dc-expand,.dc-kebab{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,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}',
|
||||
// Slot hosting an open menu floats above later siblings (which otherwise
|
||||
// paint on top — same z-index:auto, later DOM order) so the popup isn't
|
||||
// clipped by the next card.
|
||||
'[data-dc-slot]:has(.dc-menu){z-index:10}',
|
||||
'.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;',
|
||||
' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}',
|
||||
'.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;',
|
||||
' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;',
|
||||
' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}',
|
||||
'.dc-menu button:hover{background:rgba(0,0,0,.05)}',
|
||||
'.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}',
|
||||
'.dc-menu .dc-danger{color:#c96442}',
|
||||
'.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}',
|
||||
// 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);
|
||||
}
|
||||
|
||||
const DCCtx = React.createContext(null);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
|
||||
// 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
|
||||
// host's window.omelette bridge — editing requires the omelette runtime.
|
||||
// Focus is ephemeral.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const DC_STATE_FILE = '.design-canvas.state.json';
|
||||
|
||||
function DesignCanvas({ children, minScale, maxScale, style }) {
|
||||
const [state, setState] = React.useState({ sections: {}, focus: null });
|
||||
// Hold rendering until the sidecar read settles so the saved order/titles
|
||||
// appear on first paint (no source-order flash). didRead gates writes until
|
||||
// the read settles so the empty initial state can't clobber a slow read;
|
||||
// skipNextWrite suppresses the one echo-write that would otherwise follow
|
||||
// hydration.
|
||||
const [ready, setReady] = React.useState(false);
|
||||
const didRead = React.useRef(false);
|
||||
const skipNextWrite = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let off = false;
|
||||
fetch('./' + DC_STATE_FILE)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((saved) => {
|
||||
if (off || !saved || !saved.sections) return;
|
||||
skipNextWrite.current = true;
|
||||
setState((s) => ({ ...s, sections: saved.sections }));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { didRead.current = true; if (!off) setReady(true); });
|
||||
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
|
||||
return () => { off = true; clearTimeout(t); };
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!didRead.current) return;
|
||||
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
|
||||
const t = setTimeout(() => {
|
||||
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
|
||||
}, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [state.sections]);
|
||||
|
||||
// Build registries synchronously from children so FocusOverlay can read
|
||||
// them in the same render. Only direct DCSection > DCArtboard children are
|
||||
// walked — wrapping them in other elements opts out of focus/reorder.
|
||||
const registry = {}; // slotId -> { sectionId, artboard }
|
||||
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
|
||||
const sectionOrder = [];
|
||||
React.Children.forEach(children, (sec) => {
|
||||
if (!sec || sec.type !== DCSection) return;
|
||||
const sid = sec.props.id ?? sec.props.title;
|
||||
if (!sid) return;
|
||||
sectionOrder.push(sid);
|
||||
const persisted = state.sections[sid] || {};
|
||||
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) 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);
|
||||
});
|
||||
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
|
||||
sectionMeta[sid] = {
|
||||
title: persisted.title ?? sec.props.title,
|
||||
subtitle: sec.props.subtitle,
|
||||
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
|
||||
};
|
||||
});
|
||||
|
||||
const api = React.useMemo(() => ({
|
||||
state,
|
||||
section: (id) => state.sections[id] || {},
|
||||
patchSection: (id, p) => setState((s) => ({
|
||||
...s,
|
||||
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
|
||||
})),
|
||||
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
|
||||
}), [state]);
|
||||
|
||||
// Esc exits focus; any outside pointerdown commits an in-progress rename.
|
||||
React.useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
|
||||
const onPd = (e) => {
|
||||
const ae = document.activeElement;
|
||||
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.addEventListener('pointerdown', onPd, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('pointerdown', onPd, true);
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<DCCtx.Provider value={api}>
|
||||
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
|
||||
{state.focus && registry[state.focus] && (
|
||||
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
|
||||
)}
|
||||
</DCCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCViewport — transform-based pan/zoom (internal)
|
||||
//
|
||||
// Input mapping (Figma-style):
|
||||
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
|
||||
// • trackpad scroll → pan (two-finger)
|
||||
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
|
||||
// • middle-drag / primary-drag-on-bg → pan
|
||||
//
|
||||
// Transform state lives in a ref and is written straight to the DOM
|
||||
// (translate3d + will-change) so wheel ticks don't go through React —
|
||||
// keeps pans at 60fps on dense canvases.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
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) 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(() => {
|
||||
const vp = vpRef.current;
|
||||
if (!vp) return;
|
||||
|
||||
const zoomAt = (cx, cy, factor) => {
|
||||
const r = vp.getBoundingClientRect();
|
||||
const px = cx - r.left, py = cy - r.top;
|
||||
const t = tf.current;
|
||||
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
|
||||
const k = next / t.scale;
|
||||
// keep the world point under the cursor fixed
|
||||
t.x = px - (px - t.x) * k;
|
||||
t.y = py - (py - t.y) * k;
|
||||
t.scale = next;
|
||||
apply();
|
||||
};
|
||||
|
||||
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
|
||||
// line-mode deltas (Firefox) or large integer pixel deltas with no X
|
||||
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
|
||||
// two-finger scroll sends small/fractional pixel deltas, often with
|
||||
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
|
||||
const isMouseWheel = (e) =>
|
||||
e.deltaMode !== 0 ||
|
||||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
|
||||
|
||||
const onWheel = (e) => {
|
||||
e.preventDefault();
|
||||
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
|
||||
if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) {
|
||||
// trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched
|
||||
// wheels fall through to the fixed-step branch below.
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
|
||||
} else if (isMouseWheel(e)) {
|
||||
// notched mouse wheel — fixed-ratio step per click
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
|
||||
} else {
|
||||
// trackpad two-finger scroll — pan
|
||||
tf.current.x -= e.deltaX;
|
||||
tf.current.y -= e.deltaY;
|
||||
apply();
|
||||
}
|
||||
};
|
||||
|
||||
// Safari sends native gesture* events for trackpad pinch with a smooth
|
||||
// e.scale; preferring these over the ctrl+wheel fallback gives a much
|
||||
// better feel there. No-ops on other browsers. Safari also fires
|
||||
// ctrlKey wheel events during the same pinch — isGesturing makes
|
||||
// onWheel drop those entirely so they neither zoom nor pan.
|
||||
let gsBase = 1;
|
||||
let isGesturing = false;
|
||||
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
|
||||
const onGestureChange = (e) => {
|
||||
e.preventDefault();
|
||||
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
|
||||
};
|
||||
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
|
||||
|
||||
// Drag-pan: middle button anywhere, or primary button on canvas
|
||||
// background (anything that isn't an artboard or an inline editor).
|
||||
let drag = null;
|
||||
const onPointerDown = (e) => {
|
||||
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
|
||||
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
|
||||
e.preventDefault();
|
||||
vp.setPointerCapture(e.pointerId);
|
||||
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
|
||||
vp.style.cursor = 'grabbing';
|
||||
};
|
||||
const onPointerMove = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
tf.current.x += e.clientX - drag.lx;
|
||||
tf.current.y += e.clientY - drag.ly;
|
||||
drag.lx = e.clientX; drag.ly = e.clientY;
|
||||
apply();
|
||||
};
|
||||
const onPointerUp = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
vp.releasePointerCapture(e.pointerId);
|
||||
drag = null;
|
||||
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 });
|
||||
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
|
||||
vp.addEventListener('pointerdown', onPointerDown);
|
||||
vp.addEventListener('pointermove', onPointerMove);
|
||||
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);
|
||||
vp.removeEventListener('gestureend', onGestureEnd);
|
||||
vp.removeEventListener('pointerdown', onPointerDown);
|
||||
vp.removeEventListener('pointermove', onPointerMove);
|
||||
vp.removeEventListener('pointerup', onPointerUp);
|
||||
vp.removeEventListener('pointercancel', onPointerUp);
|
||||
};
|
||||
}, [apply, minScale, maxScale]);
|
||||
|
||||
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
|
||||
return (
|
||||
<div
|
||||
ref={vpRef}
|
||||
className="design-canvas"
|
||||
style={{
|
||||
height: '100vh', width: '100vw',
|
||||
background: DC.bg,
|
||||
overflow: 'hidden',
|
||||
overscrollBehavior: 'none',
|
||||
touchAction: 'none',
|
||||
position: 'relative',
|
||||
fontFamily: DC.font,
|
||||
boxSizing: 'border-box',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={worldRef}
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0,
|
||||
transformOrigin: '0 0',
|
||||
willChange: 'transform',
|
||||
width: 'max-content', minWidth: '100%',
|
||||
minHeight: '100%',
|
||||
padding: '60px 0 80px',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCSection — editable title + h-row of artboards in persisted order
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCSection({ id, title, subtitle, children, gap = 48 }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const sid = id ?? title;
|
||||
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 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));
|
||||
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
|
||||
}, [sec.order, srcOrder.join('|')]);
|
||||
|
||||
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: '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) => (
|
||||
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
|
||||
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>
|
||||
{rest}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
|
||||
function DCArtboard() { return null; }
|
||||
|
||||
// Per-artboard export (kind: 'png' | 'html'). Both paths share the same
|
||||
// self-contained clone: computed styles baked in, @font-face / <img> /
|
||||
// inline-style background-image urls inlined as data URIs. PNG wraps the
|
||||
// clone in foreignObject→canvas at 3× the artboard's natural width×height
|
||||
// (same pipeline the host uses for page captures); HTML wraps it in a
|
||||
// minimal standalone document. Both are independent of viewport zoom.
|
||||
async function dcExport(node, w, h, name, kind) {
|
||||
try { await document.fonts.ready; } catch {}
|
||||
const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => {
|
||||
const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b);
|
||||
})).catch(() => url);
|
||||
|
||||
// Collect @font-face rules. ss.cssRules throws SecurityError on
|
||||
// cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch
|
||||
// the CSS text directly (those endpoints send ACAO:*) and regex-extract
|
||||
// the blocks. @import and @media/@supports are walked so nested
|
||||
// @font-face rules aren't missed.
|
||||
const fontRules = [], pending = [], seen = new Set();
|
||||
const scrapeCss = (href) => {
|
||||
if (seen.has(href)) return; seen.add(href);
|
||||
pending.push(fetch(href).then((r) => r.text()).then((css) => {
|
||||
for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href });
|
||||
for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
|
||||
scrapeCss(new URL(m[1], href).href);
|
||||
}).catch(() => {}));
|
||||
};
|
||||
const walk = (rules, base) => {
|
||||
for (const r of rules) {
|
||||
if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base });
|
||||
else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
|
||||
const ibase = r.styleSheet.href || base;
|
||||
try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); }
|
||||
} else if (r.cssRules) walk(r.cssRules, base);
|
||||
}
|
||||
};
|
||||
for (const ss of document.styleSheets) {
|
||||
const base = ss.href || location.href;
|
||||
try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); }
|
||||
}
|
||||
while (pending.length) await pending.shift();
|
||||
const fontCss = (await Promise.all(fontRules.map(async (rule) => {
|
||||
let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g;
|
||||
while ((m = re.exec(rule.css))) {
|
||||
if (m[2].indexOf('data:') === 0) continue;
|
||||
let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; }
|
||||
out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")');
|
||||
}
|
||||
return out;
|
||||
}))).join('\n');
|
||||
|
||||
const cloneStyled = (src) => {
|
||||
if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode('');
|
||||
const dst = src.cloneNode(false);
|
||||
if (src.nodeType === 1) {
|
||||
const cs = getComputedStyle(src); let txt = '';
|
||||
for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
|
||||
dst.setAttribute('style', txt + 'animation:none;transition:none;');
|
||||
if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {}
|
||||
}
|
||||
for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
|
||||
return dst;
|
||||
};
|
||||
const clone = cloneStyled(node);
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
|
||||
// Drop the card's own shadow/radius so the export is a flush w×h rect;
|
||||
// the artboard's own background (if any) is already in the computed style.
|
||||
clone.style.boxShadow = 'none'; clone.style.borderRadius = '0';
|
||||
|
||||
const jobs = [];
|
||||
clone.querySelectorAll('img').forEach((el) => {
|
||||
const s = el.getAttribute('src');
|
||||
if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d)));
|
||||
});
|
||||
[clone, ...clone.querySelectorAll('*')].forEach((el) => {
|
||||
const bg = el.style.backgroundImage; if (!bg) return;
|
||||
let m; const re = /url\(["']?([^"')]+)["']?\)/g;
|
||||
while ((m = re.exec(bg))) {
|
||||
const tok = m[0], url = m[1];
|
||||
if (url.indexOf('data:') === 0) continue;
|
||||
jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); }));
|
||||
}
|
||||
});
|
||||
await Promise.all(jobs);
|
||||
|
||||
const xml = new XMLSerializer().serializeToString(clone);
|
||||
const save = (blob, ext) => {
|
||||
if (!blob) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
};
|
||||
|
||||
if (kind === 'html') {
|
||||
const html = '<!doctype html><html><head><meta charset="utf-8"><title>' + name + '</title>' +
|
||||
(fontCss ? '<style>' + fontCss + '</style>' : '') +
|
||||
'</head><body style="margin:0">' + xml + '</body></html>';
|
||||
return save(new Blob([html], { type: 'text/html' }), 'html');
|
||||
}
|
||||
|
||||
// PNG: the SVG's own width/height must be the output resolution — an
|
||||
// <img>-loaded SVG rasterizes at its intrinsic size, so sizing it at 1×
|
||||
// and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the
|
||||
// w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders
|
||||
// the HTML at full resolution.
|
||||
const px = 3;
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w * px + '" height="' + h * px +
|
||||
'" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject width="' + w + '" height="' + h + '">' +
|
||||
(fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') + xml + '</foreignObject></svg>';
|
||||
const img = new Image();
|
||||
await new Promise((res, rej) => {
|
||||
img.onload = res; img.onerror = () => rej(new Error('svg load failed'));
|
||||
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||||
});
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = w * px; cv.height = h * px;
|
||||
cv.getContext('2d').drawImage(img, 0, 0);
|
||||
cv.toBlob((blob) => save(blob, 'png'), 'image/png');
|
||||
}
|
||||
|
||||
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 cardRef = React.useRef(null);
|
||||
const menuRef = React.useRef(null);
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const [confirming, setConfirming] = React.useState(false);
|
||||
|
||||
// ⋯ menu: close on any outside pointerdown. Two-click delete lives inside
|
||||
// the menu — first click arms the row, second commits; closing disarms.
|
||||
React.useEffect(() => {
|
||||
if (!menuOpen) { setConfirming(false); return; }
|
||||
const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); };
|
||||
document.addEventListener('pointerdown', off, true);
|
||||
return () => document.removeEventListener('pointerdown', off, true);
|
||||
}, [menuOpen]);
|
||||
|
||||
const doExport = (kind) => {
|
||||
setMenuOpen(false);
|
||||
if (!cardRef.current) return;
|
||||
const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_');
|
||||
dcExport(cardRef.current, width, height, name, kind)
|
||||
.catch((e) => console.error('[design-canvas] export failed:', e));
|
||||
};
|
||||
|
||||
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
|
||||
// their would-be slots in real time via transforms. DOM order only
|
||||
// changes on drop.
|
||||
const onGripDown = (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const me = ref.current;
|
||||
// translateX is applied in local (pre-scale) space but pointer deltas and
|
||||
// getBoundingClientRect().left are screen-space — divide by the viewport's
|
||||
// current scale so the dragged card tracks the cursor at any zoom level.
|
||||
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
|
||||
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
|
||||
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
|
||||
const slotXs = homes.map((h) => h.x);
|
||||
const startIdx = order.indexOf(id);
|
||||
const startX = e.clientX;
|
||||
let liveOrder = order.slice();
|
||||
me.classList.add('dc-dragging');
|
||||
|
||||
const layout = () => {
|
||||
for (const h of homes) {
|
||||
if (h.id === id) continue;
|
||||
const slot = liveOrder.indexOf(h.id);
|
||||
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
|
||||
}
|
||||
};
|
||||
|
||||
const move = (ev) => {
|
||||
const dx = ev.clientX - startX;
|
||||
me.style.transform = `translateX(${dx / scale}px)`;
|
||||
const cur = homes[startIdx].x + dx;
|
||||
let nearest = 0, best = Infinity;
|
||||
for (let i = 0; i < slotXs.length; i++) {
|
||||
const d = Math.abs(slotXs[i] - cur);
|
||||
if (d < best) { best = d; nearest = i; }
|
||||
}
|
||||
if (liveOrder.indexOf(id) !== nearest) {
|
||||
liveOrder = order.filter((k) => k !== id);
|
||||
liveOrder.splice(nearest, 0, id);
|
||||
layout();
|
||||
}
|
||||
};
|
||||
|
||||
const up = () => {
|
||||
document.removeEventListener('pointermove', move);
|
||||
document.removeEventListener('pointerup', up);
|
||||
const finalSlot = liveOrder.indexOf(id);
|
||||
me.classList.remove('dc-dragging');
|
||||
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
|
||||
// After the settle transition, kill transitions + clear transforms +
|
||||
// commit the reorder in the same frame so there's no visual snap-back.
|
||||
setTimeout(() => {
|
||||
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
|
||||
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
for (const h of homes) h.el.style.transition = '';
|
||||
}));
|
||||
}, 180);
|
||||
};
|
||||
document.addEventListener('pointermove', move);
|
||||
document.addEventListener('pointerup', up);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<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-btns">
|
||||
<div ref={menuRef} style={{ position: 'relative' }}>
|
||||
<button className="dc-kebab" title="More" onClick={() => setMenuOpen((o) => !o)}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><circle cx="2.5" cy="6" r="1.1"/><circle cx="6" cy="6" r="1.1"/><circle cx="9.5" cy="6" r="1.1"/></svg>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="dc-menu" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => doExport('png')}>Download PNG</button>
|
||||
<button onClick={() => doExport('html')}>Download HTML</button>
|
||||
<hr />
|
||||
<button className="dc-danger"
|
||||
onClick={() => { if (confirming) { setMenuOpen(false); onDelete(); } else setConfirming(true); }}>
|
||||
{confirming ? 'Click again to delete' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
<div ref={cardRef} 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>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline rename — commits on blur or Enter.
|
||||
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
|
||||
const T = tag;
|
||||
return (
|
||||
<T className="dc-editable" contentEditable suppressContentEditableWarning
|
||||
onClick={onClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
|
||||
style={style}>{value}</T>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
|
||||
// sections, Esc or backdrop click to exit.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const { sectionId, artboard } = entry;
|
||||
const sec = ctx.section(sectionId);
|
||||
const meta = sectionMeta[sectionId];
|
||||
const peers = meta.slotIds;
|
||||
const aid = artboard.props.id ?? artboard.props.label;
|
||||
const idx = peers.indexOf(aid);
|
||||
const secIdx = sectionOrder.indexOf(sectionId);
|
||||
|
||||
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
|
||||
const goSection = (d) => {
|
||||
// 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(() => {
|
||||
const k = (e) => {
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
|
||||
};
|
||||
document.addEventListener('keydown', k);
|
||||
return () => document.removeEventListener('keydown', k);
|
||||
});
|
||||
|
||||
const { width = 260, height = 480, children } = artboard.props;
|
||||
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
|
||||
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
|
||||
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
|
||||
|
||||
const [ddOpen, setDd] = React.useState(false);
|
||||
const Arrow = ({ dir, onClick }) => (
|
||||
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
|
||||
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
|
||||
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
|
||||
</button>
|
||||
);
|
||||
|
||||
// Portal to body so position:fixed is the real viewport regardless of any
|
||||
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
|
||||
return ReactDOM.createPortal(
|
||||
<div onClick={() => ctx.setFocus(null)}
|
||||
onWheel={(e) => e.preventDefault()}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
|
||||
fontFamily: DC.font, color: '#fff' }}>
|
||||
|
||||
{/* top bar: section dropdown (left) · close (right) */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button onClick={() => setDd((o) => !o)}
|
||||
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
|
||||
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
|
||||
</span>
|
||||
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
|
||||
</button>
|
||||
{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.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',
|
||||
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
|
||||
{sectionMeta[sid].title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={() => ctx.setFocus(null)}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
|
||||
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
|
||||
</div>
|
||||
|
||||
{/* card centered, label + index below — only the card itself stops
|
||||
propagation so any backdrop click (including the margins around
|
||||
the card) exits focus */}
|
||||
<div
|
||||
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
|
||||
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
|
||||
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
|
||||
{(sec.labels || {})[aid] ?? artboard.props.label}
|
||||
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Arrow dir="left" onClick={() => go(-1)} />
|
||||
<Arrow dir="right" onClick={() => go(1)} />
|
||||
|
||||
{/* dots */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
|
||||
{peers.map((p, i) => (
|
||||
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
|
||||
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
|
||||
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Post-it — absolute-positioned sticky note
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', top, left, right, bottom, width,
|
||||
background: DC.postitBg, padding: '14px 16px',
|
||||
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
|
||||
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
|
||||
transform: `rotate(${rotate}deg)`,
|
||||
zIndex: 5,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — Logo directions v2</title>
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { margin: 0; font-family: 'Manrope', sans-serif; background: var(--cream-100); }
|
||||
.ab { background: #FBF9F2; padding: 32px 36px; box-sizing: border-box; min-height: 100%; position: relative; background-image: radial-gradient(rgba(14,31,51,0.022) 1px, transparent 1px), radial-gradient(rgba(138,111,61,0.020) 1px, transparent 1px); background-size: 3px 3px, 7px 7px; }
|
||||
.label-tag { position: absolute; top: 14px; right: 18px; font-family: 'JetBrains Mono', monospace; font-size: 9.5px; color: #8A6F3D; letter-spacing: 0.18em; text-transform: uppercase; }
|
||||
.name { font-family: 'Manrope', sans-serif; font-weight: 600; font-size: 13px; letter-spacing: -0.01em; color: #0E1F33; margin-bottom: 20px; }
|
||||
.name span { font-weight: 400; color: #8A6F3D; margin-left: 8px; font-size: 11px; }
|
||||
.row { display: flex; align-items: center; gap: 28px; padding: 20px; background: rgba(245,241,232,0.55); border: 1px solid rgba(14,31,51,0.08); border-radius: 8px; }
|
||||
.row .marks { display: flex; align-items: center; gap: 16px; }
|
||||
.row .lock { display: flex; align-items: center; gap: 10px; padding-left: 22px; border-left: 1px solid rgba(14,31,51,0.12); }
|
||||
.row .lock .wm { font-family: 'Manrope', sans-serif; font-weight: 500; font-size: 22px; letter-spacing: 0.32em; color: #1E3A5F; text-transform: uppercase; }
|
||||
.scenarios { margin-top: 14px; display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
|
||||
.scenarios .browser-tab { display: flex; align-items: center; gap: 6px; padding: 6px 12px; background: rgba(14,31,51,0.06); border-radius: 6px 6px 0 0; font-size: 11px; color: #2C3E54; }
|
||||
.scenarios .dark { background: #0E1F33; padding: 7px 12px; border-radius: 6px; display: flex; align-items: center; gap: 8px; color: #FBF9F2; font-family: 'Manrope'; font-weight: 500; font-size: 11.5px; letter-spacing: 0.18em; text-transform: uppercase; }
|
||||
.scenarios .chip { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: #FBF9F2; border: 1px solid rgba(14,31,51,0.12); border-radius: 999px; font-size: 11.5px; color: #2C3E54; }
|
||||
.pros { margin-top: 14px; font-size: 12px; color: #5A6B7F; line-height: 1.6; padding-top: 12px; border-top: 1px dashed rgba(14,31,51,0.15); }
|
||||
.pros strong { color: #0E1F33; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- 1. SCROLL with key -->
|
||||
<template id="logo-1">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Scroll body -->
|
||||
<path d="M22 22 L78 22 L78 78 L22 78 Z" fill="#FBF9F2" stroke="#1E3A5F" stroke-width="3"/>
|
||||
<!-- Top scroll roll -->
|
||||
<ellipse cx="50" cy="22" rx="28" ry="5" fill="#1E3A5F"/>
|
||||
<!-- Bottom scroll roll -->
|
||||
<ellipse cx="50" cy="78" rx="28" ry="5" fill="#1E3A5F"/>
|
||||
<!-- Text lines -->
|
||||
<line x1="32" y1="36" x2="68" y2="36" stroke="#1E3A5F" stroke-width="1.5"/>
|
||||
<line x1="32" y1="44" x2="62" y2="44" stroke="#1E3A5F" stroke-width="1.5"/>
|
||||
<!-- Key (centered, lower portion) -->
|
||||
<circle cx="42" cy="60" r="6" fill="none" stroke="#BFA068" stroke-width="2.5"/>
|
||||
<rect x="48" y="58.5" width="14" height="3" fill="#BFA068"/>
|
||||
<rect x="58" y="61.5" width="2" height="4" fill="#BFA068"/>
|
||||
<rect x="62" y="61.5" width="2" height="3" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- 2. CERTIFICATE / DEED -->
|
||||
<template id="logo-2">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Certificate paper -->
|
||||
<rect x="18" y="20" width="58" height="64" fill="#FBF9F2" stroke="#1E3A5F" stroke-width="2.5"/>
|
||||
<!-- Inner gold border -->
|
||||
<rect x="22" y="24" width="50" height="56" fill="none" stroke="#BFA068" stroke-width="0.75"/>
|
||||
<!-- Header lines -->
|
||||
<line x1="30" y1="34" x2="64" y2="34" stroke="#1E3A5F" stroke-width="2"/>
|
||||
<line x1="34" y1="42" x2="60" y2="42" stroke="#1E3A5F" stroke-width="1"/>
|
||||
<!-- Wax seal in lower-right, overlapping certificate edge -->
|
||||
<circle cx="74" cy="68" r="14" fill="#1E3A5F"/>
|
||||
<circle cx="74" cy="68" r="11" fill="none" stroke="#BFA068" stroke-width="0.75"/>
|
||||
<!-- Tiny key in seal -->
|
||||
<circle cx="71" cy="68" r="3" fill="none" stroke="#BFA068" stroke-width="1.5"/>
|
||||
<rect x="74" y="67.25" width="6" height="1.5" fill="#BFA068"/>
|
||||
<rect x="78" y="68.75" width="1" height="2" fill="#BFA068"/>
|
||||
<!-- Ribbons under seal -->
|
||||
<path d="M68 80 L66 90 L72 86 Z" fill="#BFA068"/>
|
||||
<path d="M80 80 L82 90 L76 86 Z" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- 3. WINDOW with key -->
|
||||
<template id="logo-3">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Application window frame -->
|
||||
<rect x="14" y="20" width="72" height="60" rx="4" fill="#1E3A5F"/>
|
||||
<!-- Title bar dots -->
|
||||
<circle cx="20" cy="26" r="1.5" fill="#BFA068"/>
|
||||
<circle cx="25" cy="26" r="1.5" fill="rgba(245,241,232,0.5)"/>
|
||||
<circle cx="30" cy="26" r="1.5" fill="rgba(245,241,232,0.5)"/>
|
||||
<!-- Window content area -->
|
||||
<rect x="18" y="32" width="64" height="44" rx="2" fill="#FBF9F2"/>
|
||||
<!-- Centered key -->
|
||||
<circle cx="42" cy="54" r="8" fill="none" stroke="#1E3A5F" stroke-width="3"/>
|
||||
<circle cx="42" cy="54" r="2.5" fill="#FBF9F2"/>
|
||||
<rect x="50" y="52" width="20" height="4" fill="#1E3A5F"/>
|
||||
<rect x="62" y="56" width="3" height="6" fill="#1E3A5F"/>
|
||||
<rect x="67" y="56" width="3" height="4" fill="#1E3A5F"/>
|
||||
<!-- Gold accent line under window -->
|
||||
<rect x="18" y="78" width="64" height="2" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- 4. RECEIPT / TICKET STUB -->
|
||||
<template id="logo-4">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Receipt body with zigzag bottom -->
|
||||
<path d="M22 14 L78 14 L78 78 L74 82 L70 78 L66 82 L62 78 L58 82 L54 78 L50 82 L46 78 L42 82 L38 78 L34 82 L30 78 L26 82 L22 78 Z" fill="#FBF9F2" stroke="#1E3A5F" stroke-width="2.5"/>
|
||||
<!-- Header bar -->
|
||||
<rect x="22" y="14" width="56" height="10" fill="#1E3A5F"/>
|
||||
<!-- Lines -->
|
||||
<line x1="30" y1="34" x2="70" y2="34" stroke="#1E3A5F" stroke-width="1.25"/>
|
||||
<line x1="30" y1="40" x2="60" y2="40" stroke="#1E3A5F" stroke-width="1.25"/>
|
||||
<line x1="30" y1="46" x2="65" y2="46" stroke="#1E3A5F" stroke-width="1.25"/>
|
||||
<!-- Perforation -->
|
||||
<line x1="22" y1="56" x2="78" y2="56" stroke="#8A6F3D" stroke-width="0.75" stroke-dasharray="2 2"/>
|
||||
<!-- Key in stub -->
|
||||
<circle cx="38" cy="68" r="5" fill="none" stroke="#BFA068" stroke-width="2"/>
|
||||
<rect x="43" y="67" width="14" height="2.5" fill="#BFA068"/>
|
||||
<rect x="53" y="69.5" width="1.5" height="3.5" fill="#BFA068"/>
|
||||
<rect x="57" y="69.5" width="1.5" height="2.5" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- 5. STAMP / NOTARY MARK -->
|
||||
<template id="logo-5">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Outer stamp ring (slightly imperfect to feel inked) -->
|
||||
<circle cx="50" cy="50" r="36" fill="none" stroke="#1E3A5F" stroke-width="3"/>
|
||||
<circle cx="50" cy="50" r="28" fill="none" stroke="#1E3A5F" stroke-width="1"/>
|
||||
<!-- Curved text along top (Keysat dots) -->
|
||||
<g fill="#1E3A5F">
|
||||
<circle cx="28" cy="32" r="1"/>
|
||||
<circle cx="50" cy="22" r="1"/>
|
||||
<circle cx="72" cy="32" r="1"/>
|
||||
<circle cx="28" cy="68" r="1"/>
|
||||
<circle cx="50" cy="78" r="1"/>
|
||||
<circle cx="72" cy="68" r="1"/>
|
||||
</g>
|
||||
<!-- LICENSED text top -->
|
||||
<text x="50" y="42" text-anchor="middle" font-family="Manrope" font-weight="600" font-size="6.5" fill="#1E3A5F" letter-spacing="1.5">LICENSED</text>
|
||||
<!-- Center key (horizontal) -->
|
||||
<g>
|
||||
<circle cx="38" cy="55" r="4.5" fill="none" stroke="#BFA068" stroke-width="2"/>
|
||||
<rect x="42" y="54" width="20" height="2.5" fill="#BFA068"/>
|
||||
<rect x="56" y="56.5" width="1.5" height="3.5" fill="#BFA068"/>
|
||||
<rect x="60" y="56.5" width="1.5" height="2.5" fill="#BFA068"/>
|
||||
</g>
|
||||
<!-- Year / mark below -->
|
||||
<text x="50" y="72" text-anchor="middle" font-family="JetBrains Mono" font-weight="600" font-size="5" fill="#1E3A5F" letter-spacing="1">ED25519</text>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- 6. KEY THROUGH BRACKETS -->
|
||||
<template id="logo-6">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Left angle bracket -->
|
||||
<path d="M30 24 L18 50 L30 76" stroke="#1E3A5F" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<!-- Right angle bracket -->
|
||||
<path d="M70 24 L82 50 L70 76" stroke="#1E3A5F" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<!-- Key, centered -->
|
||||
<circle cx="42" cy="50" r="8" fill="none" stroke="#BFA068" stroke-width="3"/>
|
||||
<circle cx="42" cy="50" r="2.5" fill="#FBF9F2"/>
|
||||
<rect x="50" y="48" width="14" height="4" fill="#BFA068"/>
|
||||
<rect x="58" y="52" width="2.5" height="6" fill="#BFA068"/>
|
||||
<rect x="62" y="52" width="2.5" height="4" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- 7. ENVELOPE / SEALED LETTER -->
|
||||
<template id="logo-7">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Envelope body -->
|
||||
<rect x="14" y="28" width="72" height="50" fill="#FBF9F2" stroke="#1E3A5F" stroke-width="2.5"/>
|
||||
<!-- Envelope flap -->
|
||||
<path d="M14 28 L50 56 L86 28 Z" fill="#1E3A5F"/>
|
||||
<!-- Inner gold line -->
|
||||
<rect x="17" y="31" width="66" height="44" fill="none" stroke="#BFA068" stroke-width="0.5"/>
|
||||
<!-- Wax seal on flap -->
|
||||
<circle cx="50" cy="58" r="9" fill="#BFA068"/>
|
||||
<!-- Key inside seal -->
|
||||
<circle cx="46.5" cy="58" r="2.5" fill="none" stroke="#1E3A5F" stroke-width="1.5"/>
|
||||
<rect x="49" y="57.25" width="6" height="1.5" fill="#1E3A5F"/>
|
||||
<rect x="53" y="58.75" width="1" height="2" fill="#1E3A5F"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- 8. KEYHOLE in document -->
|
||||
<template id="logo-8">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Document with corner fold -->
|
||||
<path d="M22 14 L66 14 L80 28 L80 86 L22 86 Z" fill="#FBF9F2" stroke="#1E3A5F" stroke-width="2.5"/>
|
||||
<!-- Folded corner -->
|
||||
<path d="M66 14 L66 28 L80 28 Z" fill="#1E3A5F"/>
|
||||
<!-- Header line -->
|
||||
<line x1="30" y1="42" x2="72" y2="42" stroke="#1E3A5F" stroke-width="2"/>
|
||||
<line x1="30" y1="48" x2="64" y2="48" stroke="#1E3A5F" stroke-width="1"/>
|
||||
<!-- Large keyhole, centered lower -->
|
||||
<circle cx="51" cy="64" r="7" fill="#1E3A5F"/>
|
||||
<path d="M48 70 L51 78 L54 70 Z" fill="#1E3A5F"/>
|
||||
<!-- Gold inner of keyhole -->
|
||||
<circle cx="51" cy="64" r="3" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script type="text/babel" data-presets="env,react">
|
||||
const LogoRow = ({tplId, title, sub, scenarios}) => {
|
||||
const [html, setHtml] = React.useState('');
|
||||
React.useEffect(() => {
|
||||
const t = document.getElementById(tplId);
|
||||
if (t) setHtml(t.innerHTML);
|
||||
}, [tplId]);
|
||||
const Mark = ({size, dark}) => (
|
||||
<span style={{display:'inline-block',width:size,height:size,filter:dark?'invert(1) hue-rotate(180deg) brightness(1.4)':'none'}}
|
||||
dangerouslySetInnerHTML={{__html: html}}/>
|
||||
);
|
||||
return (
|
||||
<div className="ab">
|
||||
<div className="label-tag">{tplId.toUpperCase()}</div>
|
||||
<div className="name">{title}<span>{sub}</span></div>
|
||||
<div className="row">
|
||||
<div className="marks">
|
||||
<Mark size={84}/><Mark size={40}/><Mark size={20}/>
|
||||
</div>
|
||||
<div className="lock">
|
||||
<Mark size={36}/>
|
||||
<span className="wm">KEYSAT</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="scenarios">
|
||||
<div className="browser-tab"><Mark size={14}/>keysat.com — Bitcoin licensing</div>
|
||||
<div className="dark"><Mark size={18} dark={true}/>KEYSAT</div>
|
||||
<div className="chip"><Mark size={14}/>Settings</div>
|
||||
</div>
|
||||
<div className="pros">{scenarios}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => (
|
||||
<DesignCanvas title="Keysat — Logo directions, take 2" subtitle="Reframed around what Keysat does: issuing signed certificates for software paid in Bitcoin. Documents, seals, receipts, signed mail.">
|
||||
<DCSection id="docs" title="Document & certificate metaphors">
|
||||
<DCArtboard id="scroll" label="1 · Scroll with key" width={920} height={380}>
|
||||
<LogoRow tplId="logo-1" title="The Scroll" sub="Unfurled scroll with a small key beneath the lines"
|
||||
scenarios={<><strong>Vibe:</strong> ancient deed, signed grant. Maps directly to 'license issued by you.' The key is a quiet detail, not the main feature. Risk: scrolls can feel a bit fantasy-novel.</>}/>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="cert" label="2 · Certificate with wax seal" width={920} height={380}>
|
||||
<LogoRow tplId="logo-2" title="The Certificate" sub="Document with a wax seal in the corner containing a key"
|
||||
scenarios={<><strong>Vibe:</strong> diploma, deed, notarized agreement. Most literal match to 'certificate of license' (the existing visual motif on hero & detail pages). Pairs perfectly with cream-paper-gold.</>}/>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="receipt" label="4 · Receipt / ticket stub" width={920} height={380}>
|
||||
<LogoRow tplId="logo-4" title="The Receipt" sub="Ticket-stub receipt with perforation, header bar, and key"
|
||||
scenarios={<><strong>Vibe:</strong> proof of purchase, ticket, paid receipt. Connects to 'paid in Bitcoin → license issued.' The zigzag edge gives it character and prints well. Distinctive.</>}/>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="envelope" label="7 · Sealed envelope" width={920} height={380}>
|
||||
<LogoRow tplId="logo-7" title="The Sealed Letter" sub="Envelope with wax seal on the flap, key inside the seal"
|
||||
scenarios={<><strong>Vibe:</strong> formal correspondence, sealed delivery. The license arrives. Friendly, less austere than scroll/certificate. Risk: 'envelope = email' read.</>}/>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="folded" label="8 · Document with keyhole" width={920} height={380}>
|
||||
<LogoRow tplId="logo-8" title="The Keyhole Document" sub="Letter with folded corner, large keyhole shape on the page"
|
||||
scenarios={<><strong>Vibe:</strong> the document IS the lock. Quiet, modern, less ornate. Reads cleanly at small sizes (the keyhole holds up). Conceptually elegant: you license = you have the key to read.</>}/>
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
<DCSection id="marks" title="Mark / stamp metaphors">
|
||||
<DCArtboard id="window" label="3 · App window with key" width={920} height={380}>
|
||||
<LogoRow tplId="logo-3" title="The Window" sub="Application window framing a key"
|
||||
scenarios={<><strong>Vibe:</strong> 'software you license.' Most explicit product reference of all eight. Title-bar dots add a subtle Mac/desktop reading. Risk: looks more like a 'software' icon than a 'licensing' icon.</>}/>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="stamp" label="5 · Notary stamp" width={920} height={380}>
|
||||
<LogoRow tplId="logo-5" title="The Stamp" sub="Round notary mark, 'LICENSED', key, and 'ED25519'"
|
||||
scenarios={<><strong>Vibe:</strong> official stamp, certified, notary. Type baked into the mark. Distinctive and confident. Risk: relies on legible micro-text — only works above ~32px. Needs a simplified small-size variant.</>}/>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="brackets" label="6 · Code brackets + key" width={920} height={380}>
|
||||
<LogoRow tplId="logo-6" title="The Bracketed Key" sub="Angle brackets < > framing a key"
|
||||
scenarios={<><strong>Vibe:</strong> 'license, in code.' Direct nod to developer audience. Modern, technical, unfussy. Pairs nicely with the dev-focused dashboard and SDK docs. Risk: less warm than the document directions.</>}/>
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
);
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,256 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — Logo directions</title>
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { margin: 0; font-family: 'Manrope', sans-serif; background: var(--cream-100); }
|
||||
.ab { background: #FBF9F2; padding: 36px 40px; box-sizing: border-box; min-height: 100%; position: relative; background-image: radial-gradient(rgba(14,31,51,0.022) 1px, transparent 1px), radial-gradient(rgba(138,111,61,0.020) 1px, transparent 1px); background-size: 3px 3px, 7px 7px; }
|
||||
.label-tag { position: absolute; top: 14px; right: 18px; font-family: 'JetBrains Mono', monospace; font-size: 9.5px; color: #8A6F3D; letter-spacing: 0.18em; text-transform: uppercase; }
|
||||
.name { font-family: 'Manrope', sans-serif; font-weight: 600; font-size: 13px; letter-spacing: -0.01em; color: #0E1F33; margin-bottom: 24px; }
|
||||
.name span { font-weight: 400; color: #8A6F3D; margin-left: 8px; font-size: 11px; letter-spacing: 0.04em; }
|
||||
.grid { display: grid; grid-template-columns: 1fr; gap: 24px; }
|
||||
.row { display: flex; align-items: center; gap: 32px; padding: 22px; background: rgba(245,241,232,0.55); border: 1px solid rgba(14,31,51,0.08); border-radius: 8px; }
|
||||
.row .marks { display: flex; align-items: center; gap: 18px; }
|
||||
.row .marks svg.lg { width: 84px; height: 84px; }
|
||||
.row .marks svg.md { width: 40px; height: 40px; }
|
||||
.row .marks svg.sm { width: 20px; height: 20px; }
|
||||
.row .lock { display: flex; align-items: center; gap: 12px; padding-left: 24px; border-left: 1px solid rgba(14,31,51,0.12); }
|
||||
.row .lock svg { width: 36px; height: 36px; }
|
||||
.row .lock .wm { font-family: 'Manrope', sans-serif; font-weight: 500; font-size: 22px; letter-spacing: 0.32em; color: #1E3A5F; text-transform: uppercase; }
|
||||
.scenarios { margin-top: 16px; display: flex; gap: 18px; align-items: center; flex-wrap: wrap; }
|
||||
.scenarios .chip { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: #FBF9F2; border: 1px solid rgba(14,31,51,0.12); border-radius: 999px; font-size: 11.5px; color: #2C3E54; }
|
||||
.scenarios .chip svg { width: 14px; height: 14px; }
|
||||
.scenarios .browser-tab { display: flex; align-items: center; gap: 6px; padding: 6px 12px; background: rgba(14,31,51,0.06); border-radius: 6px 6px 0 0; font-size: 11px; color: #2C3E54; }
|
||||
.scenarios .browser-tab svg { width: 14px; height: 14px; }
|
||||
.scenarios .dark { background: #0E1F33; padding: 8px 14px; border-radius: 6px; display: flex; align-items: center; gap: 8px; color: #FBF9F2; font-family: 'Manrope'; font-weight: 500; font-size: 12px; letter-spacing: 0.18em; text-transform: uppercase; }
|
||||
.pros { margin-top: 16px; font-size: 12px; color: #5A6B7F; line-height: 1.6; padding-top: 12px; border-top: 1px dashed rgba(14,31,51,0.15); }
|
||||
.pros strong { color: #0E1F33; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- ============== 1. BASTION / CITADEL ============== -->
|
||||
<template id="logo-1">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Battlements / crenellations top -->
|
||||
<path d="M16 32 L16 24 L24 24 L24 30 L32 30 L32 22 L40 22 L40 30 L48 30 L48 24 L52 24 L52 30 L60 30 L60 22 L68 22 L68 30 L76 30 L76 24 L84 24 L84 32 Z" fill="#1E3A5F"/>
|
||||
<!-- Main keep body -->
|
||||
<rect x="16" y="32" width="68" height="50" fill="#1E3A5F"/>
|
||||
<!-- Gate arch -->
|
||||
<path d="M42 82 L42 64 Q42 56 50 56 Q58 56 58 64 L58 82 Z" fill="#FBF9F2"/>
|
||||
<!-- Two arrow slits -->
|
||||
<rect x="26" y="44" width="3" height="12" fill="#FBF9F2"/>
|
||||
<rect x="71" y="44" width="3" height="12" fill="#FBF9F2"/>
|
||||
<!-- Gold cross-key behind, peeking from gate top -->
|
||||
<circle cx="50" cy="48" r="5" fill="none" stroke="#BFA068" stroke-width="2"/>
|
||||
<rect x="48.5" y="50" width="3" height="9" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- ============== 2. WAX SEAL / SIGNET ============== -->
|
||||
<template id="logo-2">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Outer ribbon edge (wax seal scallop) -->
|
||||
<g fill="#1E3A5F">
|
||||
<circle cx="50" cy="50" r="38"/>
|
||||
</g>
|
||||
<!-- Scalloped notches around -->
|
||||
<g fill="#FBF9F2">
|
||||
<circle cx="50" cy="10" r="2"/>
|
||||
<circle cx="78" cy="22" r="2"/>
|
||||
<circle cx="90" cy="50" r="2"/>
|
||||
<circle cx="78" cy="78" r="2"/>
|
||||
<circle cx="50" cy="90" r="2"/>
|
||||
<circle cx="22" cy="78" r="2"/>
|
||||
<circle cx="10" cy="50" r="2"/>
|
||||
<circle cx="22" cy="22" r="2"/>
|
||||
</g>
|
||||
<!-- Inner gold ring -->
|
||||
<circle cx="50" cy="50" r="32" fill="none" stroke="#BFA068" stroke-width="1"/>
|
||||
<!-- K monogram, slab -->
|
||||
<g fill="#FBF9F2">
|
||||
<rect x="36" y="32" width="6" height="36"/>
|
||||
<path d="M42 50 L60 32 L66 32 L48 50 L66 68 L60 68 L42 50 Z"/>
|
||||
</g>
|
||||
<!-- Tiny bullet stars/dots above and below -->
|
||||
<circle cx="50" cy="22" r="1.5" fill="#BFA068"/>
|
||||
<circle cx="50" cy="78" r="1.5" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- ============== 3. VAULT DOOR ============== -->
|
||||
<template id="logo-3">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Outer vault frame square -->
|
||||
<rect x="10" y="10" width="80" height="80" rx="4" fill="#1E3A5F"/>
|
||||
<!-- Inner door circle -->
|
||||
<circle cx="50" cy="50" r="30" fill="#FBF9F2"/>
|
||||
<circle cx="50" cy="50" r="30" fill="none" stroke="#BFA068" stroke-width="1"/>
|
||||
<!-- Bolts at 4 corners -->
|
||||
<circle cx="20" cy="20" r="2.5" fill="#BFA068"/>
|
||||
<circle cx="80" cy="20" r="2.5" fill="#BFA068"/>
|
||||
<circle cx="20" cy="80" r="2.5" fill="#BFA068"/>
|
||||
<circle cx="80" cy="80" r="2.5" fill="#BFA068"/>
|
||||
<!-- Spokes (8 of them) -->
|
||||
<g stroke="#1E3A5F" stroke-width="3" stroke-linecap="round">
|
||||
<line x1="50" y1="26" x2="50" y2="36"/>
|
||||
<line x1="50" y1="64" x2="50" y2="74"/>
|
||||
<line x1="26" y1="50" x2="36" y2="50"/>
|
||||
<line x1="64" y1="50" x2="74" y2="50"/>
|
||||
<line x1="33" y1="33" x2="40" y2="40"/>
|
||||
<line x1="60" y1="60" x2="67" y2="67"/>
|
||||
<line x1="67" y1="33" x2="60" y2="40"/>
|
||||
<line x1="33" y1="67" x2="40" y2="60"/>
|
||||
</g>
|
||||
<!-- Center hub -->
|
||||
<circle cx="50" cy="50" r="5" fill="#1E3A5F"/>
|
||||
<circle cx="50" cy="50" r="2" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- ============== 4. SHIELD WITH KEY TEETH ============== -->
|
||||
<template id="logo-4">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Heater shield outline -->
|
||||
<path d="M20 18 L80 18 L80 50 Q80 76 50 86 Q20 76 20 50 Z" fill="#1E3A5F"/>
|
||||
<!-- Key teeth notched out of bottom edge -->
|
||||
<path d="M38 80 L38 84 L42 84 L42 80 L46 80 L46 84 L50 84 L50 78 Q50 84 46 86 Q42 87 38 86 Z" fill="#FBF9F2"/>
|
||||
<!-- Shield bezel inset -->
|
||||
<path d="M26 24 L74 24 L74 50 Q74 72 50 81 Q26 72 26 50 Z" fill="none" stroke="#BFA068" stroke-width="1"/>
|
||||
<!-- Centered K -->
|
||||
<g fill="#FBF9F2">
|
||||
<rect x="40" y="34" width="5" height="34"/>
|
||||
<path d="M45 51 L60 34 L66 34 L51 51 L66 68 L60 68 L45 51 Z"/>
|
||||
</g>
|
||||
<!-- Gold horizontal bar (chief) at top of shield -->
|
||||
<rect x="26" y="28" width="48" height="2" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- ============== 5. KEEP / TOWER SILHOUETTE ============== -->
|
||||
<template id="logo-5">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Base platform -->
|
||||
<rect x="14" y="84" width="72" height="6" fill="#1E3A5F"/>
|
||||
<!-- Two flanking turrets -->
|
||||
<rect x="18" y="38" width="14" height="46" fill="#1E3A5F"/>
|
||||
<rect x="68" y="38" width="14" height="46" fill="#1E3A5F"/>
|
||||
<!-- Crenellations on turrets -->
|
||||
<path d="M18 38 L18 32 L22 32 L22 36 L26 36 L26 30 L30 30 L30 36 L32 36 L32 38 Z" fill="#1E3A5F"/>
|
||||
<path d="M68 38 L68 36 L70 36 L70 30 L74 30 L74 36 L78 36 L78 32 L82 32 L82 38 Z" fill="#1E3A5F"/>
|
||||
<!-- Central main keep (taller) -->
|
||||
<rect x="34" y="22" width="32" height="62" fill="#1E3A5F"/>
|
||||
<!-- Crenellations on main keep -->
|
||||
<path d="M34 22 L34 16 L40 16 L40 20 L46 20 L46 14 L54 14 L54 20 L60 20 L60 16 L66 16 L66 22 Z" fill="#1E3A5F"/>
|
||||
<!-- Gate -->
|
||||
<path d="M44 84 L44 70 Q44 64 50 64 Q56 64 56 70 L56 84 Z" fill="#FBF9F2"/>
|
||||
<!-- Arrow slits center keep -->
|
||||
<rect x="40" y="32" width="2" height="8" fill="#FBF9F2"/>
|
||||
<rect x="58" y="32" width="2" height="8" fill="#FBF9F2"/>
|
||||
<rect x="49" y="46" width="2" height="8" fill="#FBF9F2"/>
|
||||
<!-- Gold flag on top of central keep -->
|
||||
<rect x="49.5" y="6" width="1" height="10" fill="#BFA068"/>
|
||||
<path d="M50.5 7 L58 9 L50.5 11 Z" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- ============== 6. K-MONOGRAM AS BASTION ============== -->
|
||||
<template id="logo-6">
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- K vertical stem with crenellated top -->
|
||||
<path d="M22 86 L22 28 L26 28 L26 22 L30 22 L30 28 L34 28 L34 22 L38 22 L38 86 Z" fill="#1E3A5F"/>
|
||||
<!-- K upper diagonal with crenellated top -->
|
||||
<path d="M38 56 L60 28 L64 22 L70 22 L70 28 L74 28 L74 22 L78 22 L78 30 L52 56 Z" fill="#1E3A5F"/>
|
||||
<!-- K lower diagonal -->
|
||||
<path d="M38 56 L52 56 L78 86 L66 86 Z" fill="#1E3A5F"/>
|
||||
<!-- Gold horizontal foundation line -->
|
||||
<rect x="20" y="84" width="60" height="2" fill="#BFA068"/>
|
||||
<!-- Small dot/crest -->
|
||||
<circle cx="30" cy="14" r="2" fill="#BFA068"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script type="text/babel" data-presets="env,react">
|
||||
const LogoRow = ({tplId, title, sub, scenarios}) => {
|
||||
const [html, setHtml] = React.useState('');
|
||||
React.useEffect(() => {
|
||||
const t = document.getElementById(tplId);
|
||||
if (t) setHtml(t.innerHTML);
|
||||
}, [tplId]);
|
||||
return (
|
||||
<div className="ab">
|
||||
<div className="label-tag">{tplId.toUpperCase()}</div>
|
||||
<div className="name">{title}<span>{sub}</span></div>
|
||||
<div className="row">
|
||||
<div className="marks">
|
||||
<span className="lg" dangerouslySetInnerHTML={{__html: html}} style={{display:'inline-block',width:84,height:84}}/>
|
||||
<span className="md" dangerouslySetInnerHTML={{__html: html}} style={{display:'inline-block',width:40,height:40}}/>
|
||||
<span className="sm" dangerouslySetInnerHTML={{__html: html}} style={{display:'inline-block',width:20,height:20}}/>
|
||||
</div>
|
||||
<div className="lock">
|
||||
<span dangerouslySetInnerHTML={{__html: html}} style={{display:'inline-block',width:36,height:36}}/>
|
||||
<span className="wm">KEYSAT</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="scenarios">
|
||||
<div className="browser-tab">
|
||||
<span dangerouslySetInnerHTML={{__html: html}} style={{display:'inline-block',width:14,height:14}}/>
|
||||
keysat.com — Bitcoin licensing
|
||||
</div>
|
||||
<div className="dark">
|
||||
<span dangerouslySetInnerHTML={{__html: html}} style={{display:'inline-block',width:18,height:18,filter:'invert(1) hue-rotate(180deg) brightness(1.5)'}}/>
|
||||
KEYSAT
|
||||
</div>
|
||||
<div className="chip">
|
||||
<span dangerouslySetInnerHTML={{__html: html}} style={{display:'inline-block',width:14,height:14}}/>
|
||||
Settings
|
||||
</div>
|
||||
</div>
|
||||
<div className="pros">{scenarios}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => (
|
||||
<DesignCanvas title="Keysat — Logo directions" subtitle="Six fortress / protection metaphors. Each shows the mark at three sizes, in a horizontal lockup, on a tab favicon, on a dark UI badge, and as a settings chip — so you can judge it small.">
|
||||
<DCSection id="logos" title="Logo metaphors — fortress, vault, signet, keep">
|
||||
<DCArtboard id="bastion" label="1 · Bastion / citadel" width={920} height={400}>
|
||||
<LogoRow tplId="logo-1" title="Bastion" sub="Crenellated keep with gate + small gold key behind"
|
||||
scenarios={<><strong>Vibe:</strong> sovereign land, fortified perimeter, castle doctrine. Reads instantly as 'protection.' Key is integrated. Risk: a touch literal / medieval.</>}/>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="seal" label="2 · Wax seal / signet" width={920} height={400}>
|
||||
<LogoRow tplId="logo-2" title="Signet seal" sub="Round wax seal with K monogram"
|
||||
scenarios={<><strong>Vibe:</strong> notary, royal decree, signed document. Strong narrative tie to the Ed25519 / 'signed certificate' product. Reads as authenticity rather than fortress — softer 'protection.'</>}/>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="vault" label="3 · Vault door" width={920} height={400}>
|
||||
<LogoRow tplId="logo-3" title="Vault door" sub="Bank-vault door with bolts and spokes"
|
||||
scenarios={<><strong>Vibe:</strong> hard security, hardened storage, Swiss bank. Most explicitly says 'fortress' of the six. Risk: very fintech / neobank cliché — many crypto products use this.</>}/>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="shield" label="4 · Shield + key teeth" width={920} height={400}>
|
||||
<LogoRow tplId="logo-4" title="Shield" sub="Heraldic shield with key-teeth notched bottom + K monogram"
|
||||
scenarios={<><strong>Vibe:</strong> coat-of-arms, chivalric protection, K monogram inside. The teeth-as-notches detail is subtle and specific to Keysat. Strong fortress read.</>}/>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="keep" label="5 · Keep / tower silhouette" width={920} height={400}>
|
||||
<LogoRow tplId="logo-5" title="The Keep" sub="Three-tower medieval keep with central flag"
|
||||
scenarios={<><strong>Vibe:</strong> Tower of London, stronghold, quiet authority. Most literal 'fortress.' Cleaner than direction 1 because of vertical emphasis. Pairs beautifully with 'archival deed' visual story.</>}/>
|
||||
</DCArtboard>
|
||||
<DCArtboard id="kmono" label="6 · K-monogram as bastion" width={920} height={400}>
|
||||
<LogoRow tplId="logo-6" title="K-bastion" sub="Letterform K with crenellated tops"
|
||||
scenarios={<><strong>Vibe:</strong> wordmark and mark merged. Most distinctive — only Keysat could use this. Reads as 'K' and 'fortress wall' simultaneously. Risk: looks more like clever lettering than a literal protective symbol.</>}/>
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
);
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,173 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Keysat — Type Exploration</title>
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,600;9..144,800&family=Cormorant+Garamond:wght@500;700&family=Spectral:wght@500;700&family=Manrope:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600;700&family=Roboto+Mono:wght@400;500;600&family=Newsreader:opsz,wght@6..72,400;6..72,600;6..72,700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { margin: 0; font-family: var(--font-body); background: var(--cream-100); }
|
||||
|
||||
.ab { background: #FBF9F2; padding: 48px 56px; min-height: 100%; box-sizing: border-box; position: relative; background-image: radial-gradient(rgba(14,31,51,0.022) 1px, transparent 1px), radial-gradient(rgba(138,111,61,0.020) 1px, transparent 1px); background-size: 3px 3px, 7px 7px; }
|
||||
.ab .row-mark { display: flex; align-items: center; gap: 16px; margin-bottom: 36px; padding-bottom: 24px; border-bottom: 1px solid rgba(14,31,51,0.12); }
|
||||
.ab .row-mark img { width: 56px; height: 56px; }
|
||||
.ab .eyebrow { font-size: 11px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; color: #8A6F3D; margin-bottom: 18px; display: inline-flex; align-items: center; gap: 10px; }
|
||||
.ab .eyebrow::before { content: ''; width: 24px; height: 1px; background: #BFA068; }
|
||||
.ab .lede { font-size: 17px; line-height: 1.55; color: #2C3E54; max-width: 520px; margin: 18px 0 28px; }
|
||||
.ab .cta { display: inline-flex; align-items: center; gap: 8px; padding: 11px 20px; background: #1E3A5F; color: #FBF9F2; border-radius: 8px; font-weight: 600; font-size: 14px; }
|
||||
.ab .cert { margin-top: 32px; background: #FBF9F2; border: 1px solid rgba(14,31,51,0.12); border-radius: 12px; box-shadow: 0 0 0 1px #BFA068 inset, 0 2px 4px rgba(14,31,51,0.06); padding: 22px 24px; max-width: 460px; }
|
||||
.ab .cert .stamp { font-size: 9.5px; font-weight: 700; letter-spacing: 0.22em; text-transform: uppercase; color: #8A6F3D; margin-bottom: 10px; }
|
||||
.ab .cert .field { font-size: 10.5px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: #5A6B7F; margin-bottom: 3px; }
|
||||
.ab .cert .value { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 14px; color: #0E1F33; margin-bottom: 12px; }
|
||||
|
||||
/* ============== Direction A — Editorial serif ============== */
|
||||
.ab.a .wordmark { font-family: 'Fraunces', serif; font-weight: 700; font-size: 36px; letter-spacing: 0.12em; color: #1E3A5F; line-height: 1; font-variation-settings: 'opsz' 144; }
|
||||
.ab.a .wordmark .small { font-size: 11px; font-weight: 500; letter-spacing: 0.22em; color: #8A6F3D; display: block; margin-top: 6px; }
|
||||
.ab.a h1 { font-family: 'Fraunces', serif; font-weight: 600; font-size: 56px; line-height: 1.05; color: #0E1F33; margin: 0 0 4px; letter-spacing: -0.015em; font-variation-settings: 'opsz' 144; }
|
||||
.ab.a h1 em { font-style: italic; font-weight: 600; color: #1E3A5F; }
|
||||
.ab.a .cert h4 { font-family: 'Fraunces', serif; font-weight: 600; font-size: 22px; color: #0E1F33; margin: 0 0 4px; letter-spacing: -0.005em; }
|
||||
|
||||
/* ============== Direction B — Restrained classical sans ============== */
|
||||
.ab.b .wordmark { font-family: 'Manrope', sans-serif; font-weight: 500; font-size: 28px; letter-spacing: 0.32em; color: #1E3A5F; line-height: 1; text-transform: uppercase; }
|
||||
.ab.b .wordmark .small { font-size: 10.5px; font-weight: 500; letter-spacing: 0.22em; color: #8A6F3D; display: block; margin-top: 8px; text-transform: uppercase; }
|
||||
.ab.b h1 { font-family: 'Manrope', sans-serif; font-weight: 500; font-size: 44px; line-height: 1.1; color: #0E1F33; margin: 0; letter-spacing: -0.022em; }
|
||||
.ab.b h1 strong { font-weight: 700; color: #1E3A5F; }
|
||||
.ab.b .cert h4 { font-family: 'Manrope', sans-serif; font-weight: 600; font-size: 19px; color: #0E1F33; margin: 0 0 4px; letter-spacing: -0.01em; }
|
||||
|
||||
/* ============== Direction C — Slab / typewriter ============== */
|
||||
.ab.c .wordmark { font-family: 'Roboto Mono', monospace; font-weight: 600; font-size: 28px; letter-spacing: 0.04em; color: #1E3A5F; line-height: 1; }
|
||||
.ab.c .wordmark .small { font-family: 'IBM Plex Sans', sans-serif; font-size: 11px; font-weight: 500; letter-spacing: 0.18em; color: #8A6F3D; display: block; margin-top: 8px; text-transform: uppercase; }
|
||||
.ab.c h1 { font-family: 'Newsreader', serif; font-weight: 600; font-size: 48px; line-height: 1.1; color: #0E1F33; margin: 0; letter-spacing: -0.015em; }
|
||||
.ab.c h1 .mono { font-family: 'Roboto Mono', monospace; font-size: 0.78em; font-weight: 500; color: #1E3A5F; padding: 0 6px; background: rgba(191,160,104,0.18); border-radius: 4px; }
|
||||
.ab.c .cert h4 { font-family: 'Newsreader', serif; font-weight: 600; font-size: 22px; color: #0E1F33; margin: 0 0 4px; }
|
||||
|
||||
/* ============== Direction D — Mono-forward / cypherpunk-quiet ============== */
|
||||
.ab.d .wordmark { font-family: 'Roboto Mono', monospace; font-weight: 500; font-size: 24px; letter-spacing: 0.02em; color: #1E3A5F; line-height: 1; }
|
||||
.ab.d .wordmark .symbol { color: #8A6F3D; font-weight: 500; }
|
||||
.ab.d .wordmark .small { font-family: 'IBM Plex Sans', sans-serif; font-size: 10.5px; font-weight: 500; letter-spacing: 0.22em; color: #8A6F3D; display: block; margin-top: 8px; text-transform: uppercase; }
|
||||
.ab.d h1 { font-family: 'IBM Plex Sans', sans-serif; font-weight: 600; font-size: 40px; line-height: 1.15; color: #0E1F33; margin: 0; letter-spacing: -0.02em; }
|
||||
.ab.d h1 .mono { font-family: 'Roboto Mono', monospace; font-weight: 500; font-size: 0.92em; color: #1E3A5F; }
|
||||
.ab.d .cert h4 { font-family: 'IBM Plex Sans', sans-serif; font-weight: 600; font-size: 18px; color: #0E1F33; margin: 0 0 4px; letter-spacing: -0.01em; }
|
||||
.ab.d .lede { font-family: 'IBM Plex Sans', sans-serif; }
|
||||
|
||||
.label-tag { position: absolute; top: 16px; right: 20px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: #8A6F3D; letter-spacing: 0.18em; text-transform: uppercase; }
|
||||
.pros { margin-top: 22px; font-size: 12px; color: #5A6B7F; line-height: 1.6; max-width: 480px; padding-top: 14px; border-top: 1px dashed rgba(14,31,51,0.15); }
|
||||
.pros strong { color: #0E1F33; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
|
||||
<div id="root"></div>
|
||||
|
||||
<template id="art-a">
|
||||
<div class="ab a">
|
||||
<div class="label-tag">A · Editorial serif</div>
|
||||
<div class="row-mark">
|
||||
<img src="../assets/keysat-mark-v2.svg" alt="">
|
||||
<div class="wordmark">Keysat<span class="small">— Software licensing for Bitcoin creators —</span></div>
|
||||
</div>
|
||||
<div class="eyebrow">For independent creators</div>
|
||||
<h1>Sell software. <em>Keep the keys.</em></h1>
|
||||
<p class="lede">A self-hosted licensing server for indie software, paid in Bitcoin. The signing key, the customer list, the payment rails — all yours.</p>
|
||||
<a class="cta">Install Keysat →</a>
|
||||
<div class="cert">
|
||||
<div class="stamp">— Certificate of License —</div>
|
||||
<h4>Sundial 2.0</h4>
|
||||
<div class="field" style="margin-top: 10px">License key</div>
|
||||
<div class="value">KS-9F2A-7C41-XK22-6D8E</div>
|
||||
</div>
|
||||
<div class="pros"><strong>Vibe:</strong> rare-book, archival deed, classical authority. Most aligned with cream/paper/gold story. Quiet and serious. Best fit if Keysat is meant to feel <em>old-school trustworthy</em>.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="art-b">
|
||||
<div class="ab b">
|
||||
<div class="label-tag">B · Restrained classical sans</div>
|
||||
<div class="row-mark">
|
||||
<img src="../assets/keysat-mark-v2.svg" alt="">
|
||||
<div class="wordmark">KEYSAT<span class="small">Bitcoin licensing</span></div>
|
||||
</div>
|
||||
<div class="eyebrow">For independent creators</div>
|
||||
<h1>Bitcoin-paid software licensing, <strong>self-hosted on Start9.</strong></h1>
|
||||
<p class="lede">Buyers pay in Bitcoin via your own BTCPay. Your software verifies signed keys offline. No SaaS, no middleman, no platform risk.</p>
|
||||
<a class="cta">Install Keysat →</a>
|
||||
<div class="cert">
|
||||
<div class="stamp">— Certificate of License —</div>
|
||||
<h4>Sundial 2.0</h4>
|
||||
<div class="field" style="margin-top: 10px">License key</div>
|
||||
<div class="value">KS-9F2A-7C41-XK22-6D8E</div>
|
||||
</div>
|
||||
<div class="pros"><strong>Vibe:</strong> Lloyd's, Apollo, "engineered." Geometric but humanist — not chunky. Reads as a serious indie tool company. Like the current direction but <em>much</em> lighter weight. Probably the safest choice.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="art-c">
|
||||
<div class="ab c">
|
||||
<div class="label-tag">C · Serif + mono hybrid</div>
|
||||
<div class="row-mark">
|
||||
<img src="../assets/keysat-mark-v2.svg" alt="">
|
||||
<div class="wordmark">keysat<span class="small">Bitcoin software licensing</span></div>
|
||||
</div>
|
||||
<div class="eyebrow">For independent creators</div>
|
||||
<h1>Sell software. <span class="mono">get paid in BTC.</span> Keep the signing key.</h1>
|
||||
<p class="lede">A self-hosted licensing server for indie creators. Runs on your own Start9. BTCPay handles payment, your hardware holds the keys.</p>
|
||||
<a class="cta">Install Keysat →</a>
|
||||
<div class="cert">
|
||||
<div class="stamp">— Certificate of License —</div>
|
||||
<h4>Sundial 2.0</h4>
|
||||
<div class="field" style="margin-top: 10px">License key</div>
|
||||
<div class="value">KS-9F2A-7C41-XK22-6D8E</div>
|
||||
</div>
|
||||
<div class="pros"><strong>Vibe:</strong> indie-hacker print shop, Carpenter / Ledger / Hacker News classy. Editorial serif with monospace technical injections. Distinctive, but more eccentric.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="art-d">
|
||||
<div class="ab d">
|
||||
<div class="label-tag">D · Mono-forward / cypherpunk-quiet</div>
|
||||
<div class="row-mark">
|
||||
<img src="../assets/keysat-mark-v2.svg" alt="">
|
||||
<div class="wordmark"><span class="symbol">$</span> keysat<span class="small">Bitcoin software licensing</span></div>
|
||||
</div>
|
||||
<div class="eyebrow">For independent creators</div>
|
||||
<h1>Software licensing for Bitcoin creators — <span class="mono">self-hosted</span>.</h1>
|
||||
<p class="lede">Buyers pay in Bitcoin via your own BTCPay. Your software verifies signed keys offline. You own the signing key, the customer list, the payment rails.</p>
|
||||
<a class="cta">Install Keysat →</a>
|
||||
<div class="cert">
|
||||
<div class="stamp">— Certificate of License —</div>
|
||||
<h4>Sundial 2.0</h4>
|
||||
<div class="field" style="margin-top: 10px">License key</div>
|
||||
<div class="value">KS-9F2A-7C41-XK22-6D8E</div>
|
||||
</div>
|
||||
<div class="pros"><strong>Vibe:</strong> cypherpunk-quiet, BTCPay-native, terminal-flavored. Body in IBM Plex Sans. Closer to the audience's actual taste, but loses some of the cream-paper classical-ness.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script type="text/babel" data-presets="env,react">
|
||||
const Art = ({tplId}) => {
|
||||
const ref = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
const tpl = document.getElementById(tplId);
|
||||
if (tpl && ref.current) ref.current.innerHTML = tpl.innerHTML;
|
||||
}, [tplId]);
|
||||
return <div ref={ref} style={{width:'100%',height:'100%'}}/>;
|
||||
};
|
||||
|
||||
const App = () => (
|
||||
<DesignCanvas title="Keysat — Type & Wordmark Directions" subtitle="Same hero content, four type systems. Pick a direction (or mix) and I'll lock it in across the system.">
|
||||
<DCSection id="hero" title="Hero typography on cream paper">
|
||||
<DCArtboard id="a" label="A · Editorial serif (Fraunces)" width={760} height={780}><Art tplId="art-a"/></DCArtboard>
|
||||
<DCArtboard id="b" label="B · Classical sans (Manrope, lighter)" width={760} height={780}><Art tplId="art-b"/></DCArtboard>
|
||||
<DCArtboard id="c" label="C · Serif + mono hybrid (Newsreader + Roboto Mono)" width={760} height={780}><Art tplId="art-c"/></DCArtboard>
|
||||
<DCArtboard id="d" label="D · Mono-forward (IBM Plex + Roboto Mono)" width={760} height={780}><Art tplId="art-d"/></DCArtboard>
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
);
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user