This commit is contained in:
2026-05-09 16:27:40 -04:00
parent 86f558c65f
commit 3caf207129
31 changed files with 2727 additions and 1597 deletions
+68
View File
@@ -0,0 +1,68 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>ELY Browser — Design System</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="stylesheet" href="styles.css"/>
<style>
html, body { margin:0; padding:0; background:#f0eee9; font-family: 'Geist', system-ui, sans-serif; }
</style>
<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>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="shared.jsx"></script>
<script type="text/babel" src="screens/home.jsx"></script>
<script type="text/babel" src="screens/browsing.jsx"></script>
<script type="text/babel" src="screens/split.jsx"></script>
<script type="text/babel" src="screens/command.jsx"></script>
<script type="text/babel" src="screens/settings.jsx"></script>
<script type="text/babel" src="screens/sync.jsx"></script>
<script type="text/babel" src="screens/plugins.jsx"></script>
<script type="text/babel">
const W = 1500, H = 940;
function App() {
return (
<DesignCanvas title="ELY Browser" subtitle="A calm, glassy desktop browser — full design across 7 surfaces.">
<DCSection id="primary" title="Primary surfaces" subtitle="Home, browsing, split view">
<DCArtboard id="home" label="01 · Home / New Tab" width={W} height={H}>
<HomeScreen/>
</DCArtboard>
<DCArtboard id="browse" label="02 · Main Browsing Page" width={W} height={H}>
<BrowsingScreen/>
</DCArtboard>
<DCArtboard id="split" label="03 · Split View" width={W} height={H}>
<SplitScreen/>
</DCArtboard>
</DCSection>
<DCSection id="overlays" title="Overlays & system" subtitle="Quick switcher, settings">
<DCArtboard id="cmd" label="04 · Command Bar / Quick Switcher" width={W} height={H}>
<CommandScreen/>
</DCArtboard>
<DCArtboard id="set" label="05 · Settings" width={W} height={H}>
<SettingsScreen/>
</DCArtboard>
</DCSection>
<DCSection id="account" title="Account & extensibility" subtitle="Sync sign-in, plugin marketplace">
<DCArtboard id="sync" label="06 · Sync / Sign In" width={W} height={H}>
<SyncScreen/>
</DCArtboard>
<DCArtboard id="plugins" label="07 · Plugin Marketplace + Detail" width={W} height={H}>
<PluginsScreen/>
</DCArtboard>
</DCSection>
</DesignCanvas>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
</script>
</body>
</html>
+936
View File
@@ -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 });
+80
View File
@@ -0,0 +1,80 @@
// browsing.jsx — Main browsing page (real webpage open)
const BrowsingScreen = () => (
<ElyShell wallpaper="dawn" sidebarProps={{ activeId:'figma', mode:'browse', tabs:[
{ id:'figma', label:'Style Guide · Figma', icon:<Brand.Figma s={16}/>, active:true, audio:true, closable:true },
{ id:'roadmap', label:'Roadmap 2024 — Linear', icon:<Brand.Linear s={16}/> },
{ id:'mkt', label:'Marketing Site', icon:<Icon size={16} stroke="#6b6660"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18"/></Icon> },
{ id:'twt', label:'Home / X', icon:<Brand.X s={16}/> },
{ id:'gh', label:'elybrowser/core · main', icon:<Brand.GitHub s={16}/> },
] }}>
<TopBar host="figma.com" path="/file/12345/Style-Guide" canBack canForward/>
<div className="ely-scroll" style={{flex:1, overflowY:'auto', background:'#fff'}}>
{/* Mock figma-ish UI inside */}
<div style={{height:46, background:'#1d1c1a', color:'#cfcbc4', display:'flex', alignItems:'center', padding:'0 14px', fontSize:12, gap:10}}>
<div style={{display:'flex',alignItems:'center',gap:6}}><Brand.Figma s={14}/><span style={{color:'#fff', fontWeight:500}}>Style Guide</span></div>
<span style={{opacity:.55}}>/</span>
<span>ELY Design System</span>
<span style={{marginLeft:'auto', display:'flex', gap:8, alignItems:'center'}}>
<span style={{padding:'2px 8px', background:'#fff', color:'#1d1c1a', borderRadius:4, fontSize:11, fontWeight:500}}>Share</span>
<div style={{width:22,height:22,borderRadius:'50%',background:'linear-gradient(135deg,#7c6cf7,#ec6a8e)'}}/>
<div style={{width:22,height:22,borderRadius:'50%',background:'linear-gradient(135deg,#4fb59a,#7c6cf7)'}}/>
</span>
</div>
<div style={{display:'grid', gridTemplateColumns:'220px 1fr 260px', height:'calc(100% - 46px)', minHeight:560, background:'#f5f4f1'}}>
<div style={{background:'#fff', borderRight:'1px solid #ebe7e0', padding:'10px 8px', fontSize:11.5, color:'#3a3733'}}>
<div style={{padding:'4px 6px', fontWeight:600, color:'#1d1c1a'}}>Pages</div>
{['Cover','Tokens','Typography','Color','Spacing','Components','Patterns','Sandbox'].map((p,i)=>(
<div key={p} style={{padding:'5px 8px', borderRadius:5, background: i===2?'#f0e9e2':'transparent', color: i===2?'#1d1c1a':'#3a3733', display:'flex',alignItems:'center',gap:6}}>
<I.ChevronRight size={10} style={{opacity:.6}}/> {p}
</div>
))}
<div style={{padding:'10px 6px 4px', fontWeight:600, color:'#1d1c1a', marginTop:8}}>Layers</div>
{[['Frame · Display','◇'],[' H1 / Display','T'],[' H2 / Title','T'],[' H3 / Subtitle','T'],['Frame · Body','◇'],[' Paragraph','T'],[' Caption','T']].map(([n,g],i)=>(
<div key={i} style={{padding:'4px 6px', fontFamily:'monospace', fontSize:10.5, color:'#6b6660', whiteSpace:'pre'}}>{g} {n}</div>
))}
</div>
<div style={{padding:32, position:'relative', overflow:'hidden'}}>
<div style={{position:'absolute', inset:0, backgroundImage:'radial-gradient(circle at 1px 1px, rgba(0,0,0,0.06) 1px, transparent 0)', backgroundSize:'18px 18px'}}/>
<div style={{position:'relative', background:'#fff', borderRadius:6, padding:'32px 40px', boxShadow:'0 1px 0 rgba(0,0,0,0.06), 0 12px 40px -10px rgba(0,0,0,0.12)', maxWidth:520}}>
<div style={{fontSize:10.5, color:'#9a948c', letterSpacing:'0.12em', textTransform:'uppercase'}}>Display / Serif</div>
<div className="ely-serif" style={{fontSize:48, lineHeight:1.05, marginTop:8, color:'#1d1c1a'}}>The aesthetics<br/>of calm tech.</div>
<div style={{marginTop:24, display:'grid', gridTemplateColumns:'1fr 1fr', gap:14}}>
{['H1 / 64','H2 / 40','H3 / 28','Body / 16','Caption / 12','Mono / 13'].map((s,i)=>(
<div key={i} style={{borderTop:'1px solid #ebe7e0', paddingTop:8}}>
<div className={i<3?'ely-serif':''} style={{fontSize: i<3? 18: 13, color:'#1d1c1a', fontFamily: i===5?'monospace':undefined}}>The quick brown fox</div>
<div style={{fontSize:10.5, color:'#9a948c', marginTop:2}}>{s}</div>
</div>
))}
</div>
</div>
<div style={{position:'absolute', bottom:14, left:14, display:'flex', gap:6, padding:'6px 10px', background:'#fff', borderRadius:6, boxShadow:'0 1px 4px rgba(0,0,0,0.08)', fontSize:11, color:'#3a3733'}}>
100% <I.ChevronDown size={10}/>
</div>
</div>
<div style={{background:'#fff', borderLeft:'1px solid #ebe7e0', padding:'12px 12px', fontSize:11.5, color:'#3a3733'}}>
<div style={{display:'flex', gap:14, fontWeight:500, color:'#1d1c1a', borderBottom:'1px solid #ebe7e0', paddingBottom:8}}>
<span style={{borderBottom:'1.5px solid #1d1c1a', paddingBottom:6}}>Design</span>
<span style={{color:'#9a948c'}}>Prototype</span>
<span style={{color:'#9a948c'}}>Inspect</span>
</div>
<div style={{paddingTop:10, color:'#9a948c'}}>Color · Local styles</div>
<div style={{display:'grid', gridTemplateColumns:'repeat(6, 1fr)', gap:6, marginTop:8}}>
{['#1d1c1a','#3a3733','#6b6660','#c96442','#ec6a8e','#7c6cf7','#4fb59a','#f6f4ef','#efe8e1','#fff3ee','#ffd1d8','#b9c7ff'].map(c=>(
<div key={c} style={{aspectRatio:'1', borderRadius:4, background:c, boxShadow:'inset 0 0 0 1px rgba(0,0,0,0.05)'}}/>
))}
</div>
<div style={{paddingTop:14, color:'#9a948c'}}>Type · Newsreader / Geist</div>
<div style={{padding:8, background:'#f6f4ef', borderRadius:6, marginTop:8, fontSize:11}}>
<div className="ely-serif" style={{fontSize:18}}>Aa Bb Cc</div>
<div style={{color:'#6b6660', marginTop:2}}>Newsreader · 6..72</div>
</div>
<div style={{paddingTop:14, color:'#9a948c'}}>Spacing</div>
<div style={{display:'flex', gap:4, marginTop:6}}>
{[4,8,12,16,24,32].map(s=>(<div key={s} style={{width:s, height:18, background:'#1d1c1a', opacity:.85}}/>))}
</div>
</div>
</div>
</div>
</ElyShell>
);
window.BrowsingScreen = BrowsingScreen;
+91
View File
@@ -0,0 +1,91 @@
// command.jsx — Command Bar / Quick Switcher (overlaid on home)
const CommandScreen = () => (
<ElyShell wallpaper="dawn" sidebarProps={{ activeId:'home', mode:'home' }}>
<TopBar url="Search ELY or type a command…"/>
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
{/* dimmed home behind */}
<div style={{position:'absolute', inset:0, padding:'48px 64px', opacity:0.35, filter:'blur(4px) saturate(0.9)', pointerEvents:'none'}}>
<div style={{textAlign:'center'}}>
<div className="ely-serif" style={{fontSize:64, lineHeight:1.05, fontWeight:400, color:'var(--ely-ink)'}}>Where focus finds flow.</div>
</div>
</div>
<div style={{position:'absolute', inset:0, background:'rgba(20,15,10,0.18)', backdropFilter:'blur(6px)'}}/>
{/* command panel */}
<div style={{position:'absolute', top:80, left:'50%', transform:'translateX(-50%)', width:640, background:'rgba(255,255,255,0.86)', backdropFilter:'blur(40px) saturate(160%)', borderRadius:16, boxShadow:'0 30px 80px -20px rgba(40,30,20,0.45), 0 0 0 1px rgba(255,255,255,0.6) inset, 0 1px 0 rgba(255,255,255,0.8) inset', overflow:'hidden'}}>
<div style={{display:'flex', alignItems:'center', gap:12, padding:'16px 18px', borderBottom:'1px solid var(--ely-divider)'}}>
<I.Search size={18}/>
<div style={{fontSize:16, color:'var(--ely-ink)', flex:1}}>
roa<span style={{display:'inline-block', width:1, height:18, background:'var(--ely-accent)', verticalAlign:'middle', marginLeft:1, animation:'blink 1s infinite'}}/>
</div>
<span style={{padding:'2px 8px', borderRadius:6, background:'rgba(40,30,20,0.06)', fontSize:10.5, color:'var(--ely-ink-3)'}}>Switcher</span>
<span className="ely-mono" style={{fontSize:10.5, color:'var(--ely-ink-4)'}}>esc</span>
</div>
<div style={{maxHeight:440, overflowY:'auto'}}>
{/* Section: Open tabs */}
<CmdSection label="Open tabs">
<CmdRow icon={<Brand.Figma s={16}/>} title="Style Guide — Figma" hint="figma.com" right="⌘1" />
<CmdRow active icon={<Brand.Linear s={16}/>} title="Roadmap 2024 — Linear" hint="linear.app · 6 issues open" right="⌘2 · ↵" />
<CmdRow icon={<div style={{width:16,height:16, borderRadius:4, background:'linear-gradient(135deg,#7c6cf7,#ec6a8e)'}}/>} title="ELY Roadmap doc — Notion" hint="notion.so" right="⌘3" />
</CmdSection>
<CmdSection label="History">
<CmdRow icon={<Icon size={14}><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18"/></Icon>} title="Roadmap.app · 2024 update" hint="roadmap.app · 1d ago" />
<CmdRow icon={<Brand.GitHub s={14}/>} title="elybrowser/core · roadmap.md" hint="github.com · 3d ago" />
</CmdSection>
<CmdSection label="Bookmarks">
<CmdRow icon={<I.Bookmark size={14}/>} title="Roadmap planning template" hint="Pinned · Work" />
</CmdSection>
<CmdSection label="Actions">
<CmdRow icon={<I.Layers size={14}/>} title="Switch workspace" hint="Work · Personal · Studio" right="⌘⇧W" />
<CmdRow icon={<I.Split size={14}/>} title="Open in split view" hint="Beside current tab" right="⌘D" />
<CmdRow icon={<I.Settings size={14}/>} title="Open Settings" hint="Appearance, Sync, Plugins…" right="⌘," />
</CmdSection>
<CmdSection label="Ask ELY">
<CmdRow ai icon={<I.Sparkle size={14}/>} title='Ask: "What did we ship in the last roadmap cycle?"' hint="Searches recent tabs, history & docs" right="⌘↵"/>
</CmdSection>
</div>
<div style={{display:'flex', alignItems:'center', gap:14, padding:'10px 16px', borderTop:'1px solid var(--ely-divider)', fontSize:10.5, color:'var(--ely-ink-3)', background:'rgba(255,255,255,0.55)'}}>
<span style={{display:'inline-flex', alignItems:'center', gap:4}}><Kbd></Kbd><Kbd></Kbd> navigate</span>
<span style={{display:'inline-flex', alignItems:'center', gap:4}}><Kbd></Kbd> open</span>
<span style={{display:'inline-flex', alignItems:'center', gap:4}}><Kbd></Kbd> open in split</span>
<span style={{display:'inline-flex', alignItems:'center', gap:4}}><Kbd></Kbd> filter</span>
<span style={{marginLeft:'auto', display:'inline-flex', alignItems:'center', gap:6}}>
<I.Sparkle size={11}/> Powered by ELY
</span>
</div>
</div>
</div>
<style>{`@keyframes blink {0%,49%{opacity:1}50%,100%{opacity:0}}`}</style>
</ElyShell>
);
const Kbd = ({ children }) => (
<span className="ely-mono" style={{padding:'1px 5px', minWidth:14, textAlign:'center', borderRadius:4, background:'rgba(255,255,255,0.85)', boxShadow:'0 0 0 1px var(--ely-stroke), 0 1px 0 rgba(40,30,20,0.05)', fontSize:10}}>{children}</span>
);
const CmdSection = ({ label, children }) => (
<div style={{padding:'8px 0 4px'}}>
<div style={{padding:'6px 16px 4px', fontSize:10, color:'var(--ely-ink-4)', textTransform:'uppercase', letterSpacing:'0.1em', fontWeight:500}}>{label}</div>
{children}
</div>
);
const CmdRow = ({ icon, title, hint, right, active, ai }) => (
<div style={{display:'flex', alignItems:'center', gap:12, padding:'8px 16px', background: active?'rgba(201,100,66,0.08)': 'transparent', position:'relative'}}>
{active && <div style={{position:'absolute', left:0, top:6, bottom:6, width:2, borderRadius:2, background:'var(--ely-accent)'}}/>}
<div style={{width:24, height:24, borderRadius:6, background: ai?'linear-gradient(135deg,#ffd1d8,#b9c7ff)':'rgba(255,255,255,0.85)', boxShadow:'var(--ely-shadow-soft)', display:'flex',alignItems:'center',justifyContent:'center', color: ai?'#c96442':'var(--ely-ink-2)'}}>{icon}</div>
<div style={{flex:1, minWidth:0}}>
<div style={{fontSize:13, fontWeight: active?500:450, color:'var(--ely-ink)'}}>{title}</div>
{hint && <div style={{fontSize:11, color:'var(--ely-ink-4)', marginTop:1}}>{hint}</div>}
</div>
{right && <span className="ely-mono" style={{fontSize:10.5, color:'var(--ely-ink-3)'}}>{right}</span>}
</div>
);
window.CommandScreen = CommandScreen;
+118
View File
@@ -0,0 +1,118 @@
// home.jsx — Home / New Tab
const HomeScreen = () => (
<ElyShell wallpaper="dawn" sidebarProps={{ activeId:'home', mode:'home' }}>
<TopBar url="Search ELY or type a command…"/>
<div className="ely-scroll" style={{flex:1, overflowY:'auto', padding:'48px 64px 28px'}}>
<div style={{textAlign:'center', maxWidth: 880, margin:'0 auto'}}>
<div style={{display:'inline-flex', alignItems:'center', gap:8, color:'var(--ely-ink-3)', fontSize:13, marginBottom:14}}>
<span style={{color:'#e89a6e'}}><I.Sunrise size={16}/></span>
Good morning, Alex
</div>
<h1 className="ely-serif" style={{fontSize: 64, lineHeight: 1.05, margin:'0 0 28px', fontWeight:400, letterSpacing:'-0.02em', color:'var(--ely-ink)'}}>
Where focus finds flow.
</h1>
<div style={{position:'relative', maxWidth: 640, margin:'0 auto'}}>
<div style={{height:54, background:'rgba(255,255,255,0.85)', borderRadius:14, boxShadow:'var(--ely-shadow-card)', display:'flex', alignItems:'center', padding:'0 16px', gap:12}}>
<I.Search size={16}/>
<span style={{color:'var(--ely-ink-4)', fontSize:14, flex:1, textAlign:'left'}}>Search the web or ELY</span>
<button className="ely-iconbtn" style={{background:'rgba(40,30,20,0.04)'}}><I.ArrowRight size={14}/></button>
</div>
</div>
<div style={{display:'flex', justifyContent:'center', gap:8, marginTop:14}}>
{[['Search Tabs', <I.Search size={12}/>], ['Switch Workspace', <I.Layers size={12}/>], ['Open Notion', <Brand.Notion s={12}/>]].map(([t,i],idx)=>(
<span key={idx} style={{display:'inline-flex',alignItems:'center',gap:6, padding:'6px 12px', borderRadius:999, background:'rgba(255,255,255,0.55)', boxShadow:'var(--ely-shadow-soft)', fontSize:12, color:'var(--ely-ink-2)'}}>
{i}{t}
</span>
))}
</div>
</div>
{/* favorites */}
<div style={{marginTop:48}}>
<div style={{display:'flex', alignItems:'center', gap:6, color:'var(--ely-ink-3)', fontSize:12.5, marginBottom:12, fontWeight:500}}>
Favorites <I.ChevronDown size={12}/>
</div>
<div style={{display:'grid', gridTemplateColumns:'repeat(7, 1fr)', gap:10}}>
{[
[<Brand.Notion s={28}/>, 'Notion'],
[<Brand.YT s={28}/>, 'YouTube'],
[<Brand.Dribbble s={28}/>, 'Dribbble'],
[<Brand.X s={28}/>, 'X'],
[<Brand.Vercel s={28}/>, 'Vercel'],
[<Brand.Behance s={28}/>, 'Behance'],
].map(([g,n], i)=>(
<div key={i} className="ely-card" style={{height:96, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', gap:8}}>
{g}
<div style={{fontSize:11.5, color:'var(--ely-ink-3)'}}>{n}</div>
</div>
))}
<div className="ely-card" style={{height:96, display:'flex', alignItems:'center', justifyContent:'center', color:'var(--ely-ink-4)', background:'rgba(255,255,255,0.5)'}}>
<I.Plus size={18}/>
</div>
</div>
</div>
{/* lower row */}
<div style={{display:'grid', gridTemplateColumns:'1.6fr 1fr', gap:12, marginTop:16}}>
<div className="ely-card" style={{padding:16, display:'grid', gridTemplateColumns:'1fr 240px', gap:16}}>
<div>
<div style={{fontSize:13, fontWeight:500, marginBottom:14}}>Continue where you left off</div>
<div style={{display:'flex', flexDirection:'column', gap:10}}>
{[
[<Brand.Figma s={26}/>, 'Design System Figma', 'figma.com', '2 min ago'],
[<div style={{width:26,height:26,borderRadius:7,background:'linear-gradient(135deg,#7c6cf7,#1d1c1a)'}}/>, 'Roadmap 2024', 'linear.app', '1 hour ago'],
[<Icon size={26}><circle cx="12" cy="12" r="9" stroke="#6b6660" strokeWidth="1.4" fill="none"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" stroke="#6b6660" strokeWidth="1.4" fill="none"/></Icon>, 'Marketing Site', 'elybrowser.com', '3 hours ago'],
].map(([icon, name, host, when], i)=>(
<div key={i} style={{display:'flex', alignItems:'center', gap:12, padding:'4px 0'}}>
{icon}
<div style={{flex:1, minWidth:0}}>
<div style={{fontSize:13, fontWeight:500, color:'var(--ely-ink)'}}>{name}</div>
<div style={{fontSize:11.5, color:'var(--ely-ink-4)'}}>{host} · {when}</div>
</div>
</div>
))}
</div>
</div>
<div style={{position:'relative', borderRadius:12, overflow:'hidden', background:'linear-gradient(135deg,#dcc4b6,#9aa5c4)', minHeight:200}}>
<div style={{position:'absolute', inset:0, background:`
radial-gradient(60% 40% at 50% 30%, rgba(255,200,210,0.7), transparent),
radial-gradient(80% 60% at 30% 80%, rgba(120,140,180,0.6), transparent)
`}}/>
<div style={{position:'absolute', top:8, right:8, background:'rgba(0,0,0,0.35)', backdropFilter:'blur(8px)', color:'#fff', padding:'4px 10px', borderRadius:999, fontSize:10.5, display:'flex', alignItems:'center', gap:6}}>
Reading List <span style={{opacity:.6}}>·</span> 5
</div>
<div style={{position:'absolute', bottom:0, left:0, right:0, padding:14, color:'#fff', textShadow:'0 1px 8px rgba(0,0,0,0.4)'}}>
<div className="ely-serif" style={{fontSize:18, lineHeight:1.15, fontWeight:500}}>The aesthetics of<br/>calm technology</div>
<div style={{fontSize:11, opacity:.85, marginTop:6}}>Inside ELY Journal · 6 min read</div>
</div>
</div>
</div>
<div className="ely-card" style={{padding:16}}>
<div style={{fontSize:13, fontWeight:500, marginBottom:12}}>Recent activity</div>
<div style={{display:'flex', flexDirection:'column', gap:12}}>
{[
['viewed', 'Pricing Page', 'elybrowser.com/pricing', '2m', <Icon size={14}><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/></Icon>],
['opened', 'Style Guide', 'figma.com/file/12345', '1h', <Brand.Figma s={14}/>],
['commented on', 'Roadmap 2024', 'linear.app/roadmap', '3h', <Brand.Linear s={14}/>],
].map(([verb, name, url, when, ic], i)=>(
<div key={i} style={{display:'flex', alignItems:'flex-start', gap:10}}>
<div style={{width:24, height:24, borderRadius:6, background:'rgba(255,255,255,0.8)', boxShadow:'var(--ely-shadow-soft)', display:'flex',alignItems:'center',justifyContent:'center'}}>{ic}</div>
<div style={{flex:1, minWidth:0}}>
<div style={{fontSize:11.5, color:'var(--ely-ink-3)'}}>You {verb}</div>
<div style={{fontSize:13, fontWeight:500}}>{name}</div>
<div style={{fontSize:10.5, color:'var(--ely-ink-4)', marginTop:1}}>{url}</div>
</div>
<div style={{fontSize:10.5, color:'var(--ely-ink-4)'}}>{when}</div>
</div>
))}
</div>
<div style={{marginTop:14, paddingTop:10, borderTop:'1px solid var(--ely-divider)', fontSize:11.5, color:'var(--ely-ink-3)', display:'inline-flex', alignItems:'center', gap:4}}>
View all history <I.ArrowRight size={11}/>
</div>
</div>
</div>
</div>
</ElyShell>
);
window.HomeScreen = HomeScreen;
+159
View File
@@ -0,0 +1,159 @@
// plugins.jsx — Plugin Marketplace
const PluginsScreen = () => {
const cats = ['All', 'Productivity', 'Reading', 'Privacy', 'Developer', 'AI', 'Writing', 'Design'];
const plugins = [
{ name:'Reader+', author:'ELY Studio', desc:'Distraction-free reading mode with serif typography & ambient backgrounds.', accent:'linear-gradient(135deg,#ffd1d8,#b9c7ff)', glyph:'¶', installs:'24.5k', rating:4.9, tags:['Featured','Reading'] },
{ name:'Tab Capsules', author:'orbit.dev', desc:'Hibernate tabs into searchable capsules. Restore in one click — even months later.', accent:'linear-gradient(135deg,#c5f0db,#7c6cf7)', glyph:'◐', installs:'18.2k', rating:4.8, tags:['Productivity'] },
{ name:'Coast Privacy Shield', author:'Coast Labs', desc:'Per-site tracker counts and one-tap container tabs.', accent:'linear-gradient(135deg,#1d1c1a,#7c6cf7)', glyph:'◊', dark:true, installs:'31.0k', rating:4.7, tags:['Privacy'] },
{ name:'Quill', author:'paperhouse', desc:'Annotate any page. Notes sync to Notion, Obsidian, Bear.', accent:'linear-gradient(135deg,#fff3ee,#f4c8b8)', glyph:'✎', installs:'12.4k', rating:4.6, tags:['Writing'] },
{ name:'ELY AI · Summon', author:'ELY Studio', desc:'Ask anything about the page. Beautiful inline citations.', accent:'linear-gradient(135deg,#d6c4ff,#ffd1d8)', glyph:'✦', installs:'47.3k', rating:4.9, tags:['AI','Featured'] },
{ name:'Pomodoro Glass', author:'flux.app', desc:'Ambient timer that lives in your sidebar. Subtle, focused.', accent:'linear-gradient(135deg,#ffe5b8,#ec6a8e)', glyph:'◷', installs:'9.1k', rating:4.5, tags:['Productivity'] },
{ name:'Localhost Console', author:'devhabit', desc:'Inline preview & logs for any dev port. Toggle from omnibar.', accent:'linear-gradient(135deg,#0e0d0c,#4fb59a)', glyph:'_', dark:true, installs:'7.6k', rating:4.7, tags:['Developer'] },
{ name:'Lookbook', author:'studio.bento', desc:'Collect screenshots & swatches as you browse. Boards live in ELY.', accent:'linear-gradient(135deg,#ec6a8e,#7c6cf7)', glyph:'▥', installs:'14.0k', rating:4.8, tags:['Design'] },
];
return (
<ElyShell wallpaper="mint" sidebarProps={{ activeId:'plugins' }}>
<div className="ely-topbar">
<button className="ely-iconbtn"><I.ArrowLeft size={16}/></button>
<button className="ely-iconbtn"><I.ArrowRight size={16}/></button>
<div className="ely-omnibar"><I.Plug size={14}/><span className="url-host">ely://plugins</span><span className="url-path">/marketplace</span><span className="right"><I.Filters size={14}/></span></div>
<button className="ely-iconbtn is-on"><I.Plug size={15}/></button>
<button className="ely-iconbtn"><I.Moon size={16}/></button>
<button className="ely-iconbtn"><I.Menu size={16}/></button>
</div>
<div className="ely-scroll" style={{flex:1, overflowY:'auto'}}>
{/* Hero */}
<div style={{padding:'40px 56px 24px', display:'grid', gridTemplateColumns:'1.4fr 1fr', gap:28, alignItems:'end'}}>
<div>
<div style={{fontSize:11, color:'var(--ely-ink-4)', textTransform:'uppercase', letterSpacing:'0.1em'}}>Plugins · Native to ELY</div>
<h1 className="ely-serif" style={{fontSize:48, lineHeight:1.05, margin:'6px 0 10px', fontWeight:400, letterSpacing:'-0.02em'}}>Quiet tools.<br/>Everyday magic.</h1>
<div style={{fontSize:13.5, color:'var(--ely-ink-2)', maxWidth:520, lineHeight:1.55}}>A small, curated marketplace built for ELY sandboxed, theme-aware, no Chrome extension framework required. Each plugin ships with its own tweakable controls in your sidebar.</div>
<div style={{marginTop:18, height:42, padding:'0 16px', borderRadius:12, background:'rgba(255,255,255,0.85)', boxShadow:'var(--ely-shadow-card)', display:'flex', alignItems:'center', gap:10, maxWidth:480}}>
<I.Search size={14}/>
<span style={{fontSize:13, color:'var(--ely-ink-4)', flex:1}}>Search 184 plugins</span>
<span className="ely-mono" style={{fontSize:10.5, color:'var(--ely-ink-4)'}}>K</span>
</div>
</div>
<div className="ely-card" style={{padding:18, background:'linear-gradient(135deg, rgba(255,255,255,0.9), rgba(214,196,255,0.55))'}}>
<div style={{fontSize:11, color:'var(--ely-ink-3)', textTransform:'uppercase', letterSpacing:'0.1em', display:'flex',alignItems:'center', gap:6}}><I.Sparkle size={11}/> Editor's pick</div>
<div style={{display:'flex', alignItems:'flex-start', gap:14, marginTop:12}}>
<div style={{width:56, height:56, borderRadius:14, background:'linear-gradient(135deg,#d6c4ff,#ffd1d8)', display:'flex',alignItems:'center',justifyContent:'center', fontSize:28, color:'#fff'}}>✦</div>
<div style={{flex:1}}>
<div style={{fontSize:15, fontWeight:600}}>ELY AI · Summon</div>
<div style={{fontSize:11.5, color:'var(--ely-ink-4)'}}>by ELY Studio · 47.3k installs</div>
<div style={{fontSize:12.5, color:'var(--ely-ink-2)', marginTop:6, lineHeight:1.4}}>Ask anything about the current page with citations.</div>
</div>
</div>
<div style={{display:'flex', alignItems:'center', gap:8, marginTop:12}}>
<span style={{padding:'7px 14px', borderRadius:8, background:'var(--ely-ink)', color:'#fff', fontSize:12, fontWeight:500}}>Install</span>
<span style={{padding:'7px 14px', borderRadius:8, background:'rgba(255,255,255,0.7)', boxShadow:'var(--ely-shadow-soft)', fontSize:12, fontWeight:500}}>Open detail</span>
<span style={{marginLeft:'auto', fontSize:11.5, color:'var(--ely-ink-3)'}}>⭐︎ 4.9 · 1,241 reviews</span>
</div>
</div>
</div>
{/* Cats */}
<div style={{padding:'4px 56px 18px', display:'flex', gap:6, flexWrap:'wrap'}}>
{cats.map((c,i)=>(
<span key={c} style={{padding:'6px 12px', borderRadius:999, fontSize:12, background: i===0?'var(--ely-ink)':'rgba(255,255,255,0.7)', color: i===0?'#fff':'var(--ely-ink-2)', boxShadow:i===0?'none':'var(--ely-shadow-soft)', fontWeight:500}}>{c}</span>
))}
<span style={{marginLeft:'auto', fontSize:11.5, color:'var(--ely-ink-3)', display:'inline-flex', alignItems:'center', gap:6}}>Sort: Most installed <I.ChevronDown size={11}/></span>
</div>
{/* Plugin grid */}
<div style={{padding:'0 56px 28px', display:'grid', gridTemplateColumns:'repeat(4, 1fr)', gap:14}}>
{plugins.map((p,i)=>(
<div key={i} className="ely-card" style={{padding:14, display:'flex', flexDirection:'column', gap:10}}>
<div style={{aspectRatio:'5/3', borderRadius:10, background:p.accent, display:'flex',alignItems:'center',justifyContent:'center', fontSize:36, color: p.dark?'rgba(255,255,255,0.9)':'rgba(255,255,255,0.95)', position:'relative', overflow:'hidden'}}>
<div style={{position:'absolute', inset:0, background:'radial-gradient(60% 50% at 30% 20%, rgba(255,255,255,0.4), transparent 70%)'}}/>
<span style={{position:'relative', textShadow:'0 2px 14px rgba(0,0,0,0.2)'}}>{p.glyph}</span>
{p.tags.includes('Featured') && <span style={{position:'absolute', top:8, left:8, padding:'2px 8px', borderRadius:999, background:'rgba(255,255,255,0.95)', color:'var(--ely-ink)', fontSize:9.5, fontWeight:600, letterSpacing:'0.04em'}}>FEATURED</span>}
</div>
<div style={{display:'flex',alignItems:'flex-start',gap:8}}>
<div style={{flex:1, minWidth:0}}>
<div style={{fontSize:13.5, fontWeight:600}}>{p.name}</div>
<div style={{fontSize:11, color:'var(--ely-ink-4)'}}>{p.author}</div>
</div>
<span style={{padding:'3px 9px', borderRadius:6, background:'rgba(40,30,20,0.05)', fontSize:11, fontWeight:500, color:'var(--ely-ink-2)'}}>Get</span>
</div>
<div style={{fontSize:12, color:'var(--ely-ink-3)', lineHeight:1.45, height: 50, overflow:'hidden'}}>{p.desc}</div>
<div style={{display:'flex', alignItems:'center', gap:8, fontSize:10.5, color:'var(--ely-ink-4)', borderTop:'1px solid var(--ely-divider)', paddingTop:8, marginTop:'auto'}}>
<span>★ {p.rating}</span>
<span>·</span>
<span>{p.installs} installs</span>
<span style={{marginLeft:'auto', display:'inline-flex',alignItems:'center', gap:3, color:'#2f7d68'}}><I.Shield size={10}/> Sandboxed</span>
</div>
</div>
))}
</div>
{/* Plugin detail (preview / featured below) */}
<div style={{padding:'10px 56px 40px'}}>
<div className="ely-card" style={{padding:24, display:'grid', gridTemplateColumns:'320px 1fr', gap:24}}>
<div>
<div style={{aspectRatio:'4/3', borderRadius:14, background:'linear-gradient(135deg,#ffd1d8,#b9c7ff)', display:'flex',alignItems:'center',justifyContent:'center', fontSize:80, color:'#fff', textShadow:'0 4px 20px rgba(0,0,0,0.15)'}}>¶</div>
<div style={{display:'flex', alignItems:'center', gap:10, marginTop:14}}>
<span style={{flex:1, height:40, background:'var(--ely-ink)', color:'#fff', borderRadius:10, display:'inline-flex',alignItems:'center',justifyContent:'center', fontSize:13, fontWeight:500, gap:6}}><I.Download size={13}/> Install · Free</span>
<span style={{width:40, height:40, background:'rgba(255,255,255,0.85)', boxShadow:'var(--ely-shadow-soft)', borderRadius:10, display:'inline-flex',alignItems:'center',justifyContent:'center'}}><I.Heart size={14}/></span>
</div>
<div style={{marginTop:16, fontSize:11, color:'var(--ely-ink-3)', textTransform:'uppercase', letterSpacing:'0.1em'}}>Permissions</div>
<div style={{marginTop:8, display:'flex', flexDirection:'column', gap:6, fontSize:12}}>
{[['Read content of pages you choose',''],['Store reading preferences locally',''],['Access network',' '],['Read browsing history',' ']].map(([t,c],i)=>(
<div key={i} style={{display:'flex',alignItems:'center', gap:8, color: c===''?'var(--ely-ink-2)':'var(--ely-ink-4)'}}>
<span style={{width:14, height:14, borderRadius:4, background: c===''?'#e8f3ee':'rgba(40,30,20,0.05)', color:'#2f7d68', display:'inline-flex',alignItems:'center',justifyContent:'center', fontSize:10}}>{c}</span>
{t}
</div>
))}
</div>
</div>
<div>
<div style={{display:'flex', alignItems:'flex-start', gap:14}}>
<div style={{flex:1}}>
<div style={{fontSize:11, color:'var(--ely-ink-4)', textTransform:'uppercase', letterSpacing:'0.1em'}}>Reading · Featured</div>
<h2 className="ely-serif" style={{fontSize:32, fontWeight:400, margin:'4px 0 4px', letterSpacing:'-0.02em'}}>Reader+</h2>
<div style={{fontSize:12.5, color:'var(--ely-ink-3)'}}>by ELY Studio · v2.4 · updated 3 days ago</div>
</div>
<div style={{textAlign:'right'}}>
<div style={{fontSize:18, fontWeight:600}}>★ 4.9</div>
<div style={{fontSize:10.5, color:'var(--ely-ink-4)'}}>1,894 reviews</div>
</div>
</div>
<p style={{fontSize:13.5, color:'var(--ely-ink-2)', lineHeight:1.6, marginTop:14}}>Reader+ pulls the article out of the noise. A serif type system, gentle ambient backgrounds, and a focus mode that quietly hides everything you don't need. Built native to ELY's plugin runtime — themes follow your wallpaper, settings live in the sidebar.</p>
<div style={{display:'grid', gridTemplateColumns:'repeat(4, 1fr)', gap:8, marginTop:18}}>
{[
['Installs','24,521'],['Rating','4.9 / 5'],['Size','340 kB'],['Sandboxed','Yes']
].map(([k,v])=>(
<div key={k} style={{padding:'10px 12px', borderRadius:10, background:'rgba(255,255,255,0.7)', boxShadow:'var(--ely-shadow-soft)'}}>
<div style={{fontSize:10.5, color:'var(--ely-ink-4)', textTransform:'uppercase', letterSpacing:'0.1em'}}>{k}</div>
<div style={{fontSize:13, fontWeight:500, marginTop:2}}>{v}</div>
</div>
))}
</div>
<div style={{marginTop:22}}>
<div style={{fontSize:13, fontWeight:600, marginBottom:10}}>What it adds to ELY</div>
<div style={{display:'flex', flexDirection:'column', gap:8}}>
{[
['Reader sidebar', 'Outline, time-to-read, table of contents themed to your wallpaper.'],
['Ambient mode', 'Subtle background motion sourced from the article\'s lead image.'],
['Pocket-style queue', 'Save for later that lives in ELY\'s reading list, not a third-party app.'],
].map(([t,d],i)=>(
<div key={i} style={{display:'flex', alignItems:'flex-start', gap:10, padding:'10px 12px', borderRadius:10, background:'rgba(255,255,255,0.55)'}}>
<span style={{width:18, height:18, borderRadius:5, background:'rgba(201,100,66,0.12)', color:'var(--ely-accent)', display:'inline-flex',alignItems:'center',justifyContent:'center', fontSize:11, marginTop:1}}><I.Check size={11}/></span>
<div style={{flex:1}}>
<div style={{fontSize:12.5, fontWeight:500}}>{t}</div>
<div style={{fontSize:11.5, color:'var(--ely-ink-3)', marginTop:1, lineHeight:1.4}}>{d}</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
</ElyShell>
);};
window.PluginsScreen = PluginsScreen;
+138
View File
@@ -0,0 +1,138 @@
// settings.jsx — Settings page
const SettingsScreen = () => {
const SectionLabel = ({ children, k='label' }) => k==='label' ?
<div style={{padding:'8px 12px 4px', fontSize:10, color:'var(--ely-ink-4)', textTransform:'uppercase', letterSpacing:'0.1em', fontWeight:500}}>{children}</div>
: <div>{children}</div>;
const NavItem = ({ icon, label, active }) => (
<div style={{display:'flex',alignItems:'center',gap:10, padding:'7px 12px', borderRadius:8, background: active?'rgba(255,255,255,0.85)':'transparent', boxShadow: active?'var(--ely-shadow-soft)':'none', fontSize:13, color: active?'var(--ely-ink)':'var(--ely-ink-2)'}}>
<span style={{color:'var(--ely-ink-3)'}}>{icon}</span>{label}
</div>
);
const Toggle = ({ on }) => (
<span style={{width:32, height:18, borderRadius:999, background: on?'var(--ely-accent)':'rgba(40,30,20,0.15)', position:'relative', display:'inline-block', boxShadow:'inset 0 0 0 1px rgba(0,0,0,0.05)'}}>
<span style={{position:'absolute', top:2, left: on?16:2, width:14, height:14, borderRadius:'50%', background:'#fff', boxShadow:'0 1px 2px rgba(0,0,0,0.2)', transition:'left .2s'}}/>
</span>
);
const Row = ({ title, desc, children, last }) => (
<div style={{display:'flex', alignItems:'center', gap:16, padding:'14px 0', borderBottom: last?'none':'1px solid var(--ely-divider)'}}>
<div style={{flex:1}}>
<div style={{fontSize:13, fontWeight:500}}>{title}</div>
{desc && <div style={{fontSize:11.5, color:'var(--ely-ink-3)', marginTop:2}}>{desc}</div>}
</div>
{children}
</div>
);
const Select = ({ value }) => (
<span style={{display:'inline-flex',alignItems:'center', gap:6, padding:'5px 10px', background:'rgba(255,255,255,0.85)', borderRadius:6, boxShadow:'var(--ely-shadow-soft)', fontSize:12}}>{value} <I.ChevronDown size={11}/></span>
);
return (
<ElyShell wallpaper="dawn" sidebarProps={{ activeId:'settings' }}>
<div className="ely-topbar">
<button className="ely-iconbtn"><I.ArrowLeft size={16}/></button>
<button className="ely-iconbtn disabled"><I.ArrowRight size={16}/></button>
<div className="ely-omnibar"><I.Settings size={14}/><span className="url-host">ely://settings</span><span className="url-path">/appearance</span><span className="right"><I.Filters size={14}/></span></div>
<button className="ely-iconbtn"><I.Copy size={16}/></button>
<button className="ely-iconbtn"><I.Download size={16}/></button>
<button className="ely-iconbtn"><I.Moon size={16}/></button>
<button className="ely-iconbtn"><I.Menu size={16}/></button>
</div>
<div style={{flex:1, display:'grid', gridTemplateColumns:'232px 1fr', minHeight:0}}>
<div style={{padding:'16px 10px', borderRight:'1px solid var(--ely-divider)', overflowY:'auto'}}>
<div style={{padding:'4px 12px 12px'}}>
<div style={{fontSize:18, fontWeight:500}} className="ely-serif">Settings</div>
<div style={{fontSize:11.5, color:'var(--ely-ink-4)', marginTop:2}}>ELY 0.42 · macOS 14.4</div>
</div>
<SectionLabel>General</SectionLabel>
<NavItem icon={<I.Palette size={14}/>} label="Appearance" active/>
<NavItem icon={<I.Sidebar size={14}/>} label="Sidebar"/>
<NavItem icon={<I.Tab size={14}/>} label="Tabs &amp; Workspaces"/>
<NavItem icon={<I.Cmd size={14}/>} label="Shortcuts"/>
<SectionLabel>Account</SectionLabel>
<NavItem icon={<I.Cloud size={14}/>} label="Sync"/>
<NavItem icon={<I.Shield size={14}/>} label="Privacy &amp; Security"/>
<NavItem icon={<I.User size={14}/>} label="Profiles"/>
<SectionLabel>Power</SectionLabel>
<NavItem icon={<I.Plug size={14}/>} label="Plugins"/>
<NavItem icon={<I.Sparkle size={14}/>} label="ELY AI"/>
<NavItem icon={<I.Code size={14}/>} label="Developer"/>
<SectionLabel>About</SectionLabel>
<NavItem icon={<I.Heart size={14}/>} label="What's new"/>
<NavItem icon={<I.Bell size={14}/>} label="Help &amp; feedback"/>
</div>
<div className="ely-scroll" style={{padding:'28px 40px 32px', overflowY:'auto'}}>
<div style={{maxWidth:780, margin:'0 auto'}}>
<div style={{fontSize:11, color:'var(--ely-ink-4)', textTransform:'uppercase', letterSpacing:'0.1em'}}>General</div>
<h1 className="ely-serif" style={{fontSize:34, fontWeight:400, margin:'4px 0 6px', letterSpacing:'-0.02em'}}>Appearance</h1>
<div style={{fontSize:13, color:'var(--ely-ink-3)', maxWidth:520}}>Tune the atmosphere of your browser. ELY's theme follows the time of day by default — pick a wallpaper, then let it breathe.</div>
<div style={{marginTop:24, display:'grid', gridTemplateColumns:'repeat(4, 1fr)', gap:10}}>
{[
['Dawn','#ffd1d8','#b9c7ff', true],
['Violet','#d6c4ff','#ffe0c0'],
['Mint','#c5f0db','#ffe5b8'],
['Slate','#cfd6e2','#1d1c1a']
].map(([n, a, b, sel])=>(
<div key={n} style={{position:'relative'}}>
<div style={{aspectRatio:'4/3', borderRadius:10, background:`radial-gradient(70% 60% at 80% 20%, ${a}, transparent 70%), radial-gradient(60% 60% at 15% 90%, ${b}, transparent 70%), #f6f4ef`, boxShadow:'var(--ely-shadow-card)', outline: sel?'2px solid var(--ely-accent)':'none', outlineOffset:2}}/>
<div style={{display:'flex', alignItems:'center', gap:6, padding:'8px 2px 0', fontSize:12}}>
<span style={{flex:1, fontWeight:500}}>{n}</span>
{sel && <span style={{color:'var(--ely-accent)'}}><I.Check size={13}/></span>}
</div>
</div>
))}
</div>
<div style={{marginTop:32}}>
<Row title="Theme mode" desc="Match system appearance, or pick one to lock.">
<div style={{display:'flex', gap:4, padding:3, background:'rgba(40,30,20,0.05)', borderRadius:8}}>
{['System','Light','Dark'].map((t,i)=>(
<span key={t} style={{padding:'4px 12px', borderRadius:6, fontSize:12, background: i===0?'#fff':'transparent', boxShadow: i===0?'var(--ely-shadow-soft)':'none', fontWeight:500}}>{t}</span>
))}
</div>
</Row>
<Row title="Accent color" desc="Used across selection, focus rings & links.">
<div style={{display:'flex', gap:8}}>
{['#c96442','#ec6a8e','#7c6cf7','#4fb59a','#1d1c1a'].map((c,i)=>(
<div key={c} style={{width:22,height:22, borderRadius:'50%', background:c, boxShadow: i===0?'0 0 0 2px #fff, 0 0 0 4px var(--ely-accent)':'inset 0 0 0 1px rgba(0,0,0,0.05)'}}/>
))}
</div>
</Row>
<Row title="Font" desc="Display serif used for headlines & New Tab.">
<Select value="Newsreader"/>
</Row>
<Row title="Translucency" desc="Glass density of sidebar & panels.">
<div style={{width:200, height:4, borderRadius:2, background:'rgba(40,30,20,0.1)', position:'relative'}}>
<div style={{position:'absolute', left:0, top:0, bottom:0, width:'62%', background:'var(--ely-accent)', borderRadius:2}}/>
<div style={{position:'absolute', left:'62%', top:'50%', width:14, height:14, borderRadius:'50%', background:'#fff', boxShadow:'0 1px 4px rgba(0,0,0,0.2)', transform:'translate(-50%,-50%)'}}/>
</div>
</Row>
<Row title="Reduce motion" desc="Mute transitions & ambient effects.">
<Toggle/>
</Row>
<Row title="Reading-mode background" last>
<Select value="Sepia"/>
</Row>
</div>
<div style={{marginTop:36, fontSize:11, color:'var(--ely-ink-4)', textTransform:'uppercase', letterSpacing:'0.1em'}}>Sidebar</div>
<h2 className="ely-serif" style={{fontSize:22, fontWeight:400, margin:'2px 0 18px'}}>Layout</h2>
<div style={{display:'grid', gridTemplateColumns:'repeat(3, 1fr)', gap:10}}>
{['Single column','Compact','Hidden on hover'].map((n,i)=>(
<div key={n} className="ely-card" style={{padding:14, outline: i===0?'2px solid var(--ely-accent)':'none'}}>
<div style={{aspectRatio:'5/3', background:'#fff', borderRadius:8, boxShadow:'var(--ely-shadow-soft)', position:'relative', overflow:'hidden'}}>
<div style={{position:'absolute', left:6, top:6, bottom:6, width: i===2?6:i===1?32:54, background:'rgba(40,30,20,0.08)', borderRadius:5}}/>
<div style={{position:'absolute', left: i===2?20:i===1?46:68, top:6, right:6, bottom:6, background:'rgba(40,30,20,0.04)', borderRadius:5}}/>
</div>
<div style={{fontSize:12, fontWeight:500, marginTop:10}}>{n}</div>
<div style={{fontSize:11, color:'var(--ely-ink-4)', marginTop:2}}>{['Default · workspace + tabs','Icons-only with tooltips','Slide in on cursor reach'][i]}</div>
</div>
))}
</div>
</div>
</div>
</div>
</ElyShell>
);};
window.SettingsScreen = SettingsScreen;
+105
View File
@@ -0,0 +1,105 @@
// split.jsx — Split-view browsing
const SplitScreen = () => {
const Pane = ({ title, host, children, accent }) => (
<div style={{display:'flex', flexDirection:'column', flex:1, minWidth:0, background:'#fff', borderRadius:10, overflow:'hidden', boxShadow:'0 1px 0 rgba(0,0,0,0.05), 0 12px 30px -16px rgba(0,0,0,0.18)'}}>
<div style={{height:30, background:'#f6f4ef', borderBottom:'1px solid #ebe7e0', display:'flex', alignItems:'center', padding:'0 10px', gap:8, fontSize:11, color:'#3a3733'}}>
<span style={{width:8, height:8, borderRadius:'50%', background:accent}}/>
<I.Lock size={11}/>
<span style={{color:'#1d1c1a', fontWeight:500}}>{host}</span>
<span style={{color:'#9a948c'}}>{title}</span>
<span style={{marginLeft:'auto', display:'flex', gap:6, color:'#9a948c'}}>
<I.Reload size={11}/><I.X size={11}/>
</span>
</div>
<div style={{flex:1, minHeight:0, overflow:'hidden'}}>{children}</div>
</div>
);
return (
<ElyShell wallpaper="violet" sidebarProps={{ activeId:'figma', mode:'browse', tabs:[
{ id:'figma', label:'Style Guide · Figma', icon:<Brand.Figma s={16}/>, active:true, closable:true },
{ id:'lin', label:'Roadmap 2024 — Linear', icon:<Brand.Linear s={16}/>, active:true },
{ id:'gh', label:'elybrowser/core', icon:<Brand.GitHub s={16}/> },
] }}>
<div className="ely-topbar" style={{gap:6}}>
<button className="ely-iconbtn"><I.ArrowLeft size={16}/></button>
<button className="ely-iconbtn disabled"><I.ArrowRight size={16}/></button>
<div className="ely-omnibar" style={{flex:1}}>
<I.Lock size={14}/>
<span className="url-host">figma.com</span>
<span className="url-path">/file/12345/Style-Guide</span>
<span className="right"><I.Filters size={14}/><I.Star size={14}/></span>
</div>
<div style={{width:1, height:22, background:'var(--ely-divider)', margin:'0 4px'}}/>
<div className="ely-omnibar" style={{flex:1}}>
<I.Lock size={14}/>
<span className="url-host">linear.app</span>
<span className="url-path">/elybrowser/roadmap</span>
<span className="right"><I.Filters size={14}/><I.Star size={14}/></span>
</div>
<button className="ely-iconbtn is-on" title="Split"><I.Split size={16}/></button>
<button className="ely-iconbtn"><I.Menu size={16}/></button>
</div>
<div style={{flex:1, padding:14, display:'flex', gap:10, minHeight:0}}>
{/* Left: Figma */}
<Pane title="· Style Guide" host="figma.com" accent="#7c6cf7">
<div style={{height:'100%', display:'grid', gridTemplateRows:'auto 1fr', background:'#1d1c1a', color:'#cfcbc4'}}>
<div style={{padding:'8px 12px', fontSize:11, display:'flex', gap:8, alignItems:'center'}}>
<Brand.Figma s={14}/>
<span style={{color:'#fff', fontWeight:500}}>Style Guide</span>
<span style={{opacity:.6}}>/ Typography</span>
<span style={{marginLeft:'auto', display:'flex', gap:6}}>
<span style={{padding:'2px 8px', background:'#fff', color:'#1d1c1a', borderRadius:4, fontSize:10.5}}>Share</span>
</span>
</div>
<div style={{position:'relative', overflow:'hidden', background:'#f5f4f1'}}>
<div style={{position:'absolute', inset:0, backgroundImage:'radial-gradient(circle at 1px 1px, rgba(0,0,0,0.05) 1px, transparent 0)', backgroundSize:'16px 16px'}}/>
<div style={{position:'relative', margin:'24px auto', maxWidth:380, background:'#fff', padding:'24px 28px', borderRadius:6, boxShadow:'0 1px 0 rgba(0,0,0,0.06)'}}>
<div className="ely-serif" style={{fontSize:36, lineHeight:1.05}}>The aesthetics of calm tech.</div>
<div style={{marginTop:14, fontSize:11.5, color:'#3a3733', lineHeight:1.5}}>A type system tuned for long-form reading and slow, deliberate interfaces. Designed for ambient use.</div>
<div style={{marginTop:14, display:'flex', gap:6}}>
{['#1d1c1a','#c96442','#ec6a8e','#7c6cf7','#4fb59a'].map(c=>(<div key={c} style={{width:18,height:18,borderRadius:4, background:c}}/>))}
</div>
</div>
</div>
</div>
</Pane>
{/* Right: Linear */}
<Pane title="· Roadmap 2024" host="linear.app" accent="#5e6ad2">
<div style={{height:'100%', background:'#0e0d0c', color:'#dcd9d2', display:'flex'}}>
<div style={{width:140, borderRight:'1px solid #1d1c1a', padding:'10px 8px', fontSize:11}}>
<div style={{display:'flex',alignItems:'center',gap:6, padding:'4px 6px'}}><Brand.Linear s={14}/><span style={{fontWeight:600}}>ELY</span></div>
{['Inbox','My Issues','Triage'].map(l=>(<div key={l} style={{padding:'4px 6px', color:'#9a948c'}}>{l}</div>))}
<div style={{padding:'8px 6px 2px', color:'#6b6660', fontSize:10, textTransform:'uppercase', letterSpacing:'.08em'}}>Cycles</div>
{['24 · Now','24 · Next','25 · Q1','25 · Q2'].map((l,i)=>(<div key={l} style={{padding:'4px 6px', borderRadius:4, background:i===0?'#1d1c1a':'transparent', color:i===0?'#fff':'#9a948c'}}>{l}</div>))}
</div>
<div style={{flex:1, padding:'10px 14px', fontSize:11.5}}>
<div style={{display:'flex', alignItems:'center', gap:8}}>
<span style={{fontSize:13, color:'#fff', fontWeight:500}}>Roadmap 2024</span>
<span style={{color:'#6b6660'}}>· 18 issues</span>
<span style={{marginLeft:'auto', padding:'2px 8px', background:'#5e6ad2', color:'#fff', borderRadius:4, fontSize:10.5}}>+ New</span>
</div>
<div style={{display:'grid', gridTemplateColumns:'1fr', gap:1, marginTop:10, background:'#1d1c1a', borderRadius:4, overflow:'hidden'}}>
{[
['ELY-101','In Progress', 'Universal command bar', '#f5a623'],
['ELY-102','In Progress', 'Vertical tabs · sticky pinning', '#f5a623'],
['ELY-103','Todo', 'Cloudflare sync · device pairing', '#5e6ad2'],
['ELY-104','Todo', 'Plugin SDK · v0', '#5e6ad2'],
['ELY-105','Backlog', 'Reader mode · ambient theme', '#6b6660'],
['ELY-106','Done', 'Workspace switching', '#4fb59a'],
].map(([id,st,t,c],i)=>(
<div key={i} style={{display:'flex',alignItems:'center', gap:10, padding:'8px 10px', background:'#0e0d0c'}}>
<span style={{width:8,height:8,borderRadius:'50%', background:c}}/>
<span style={{color:'#6b6660', width:54}}>{id}</span>
<span style={{flex:1, color:'#fff'}}>{t}</span>
<span style={{color:'#9a948c'}}>{st}</span>
<span style={{width:18,height:18,borderRadius:'50%', background:'linear-gradient(135deg,#7c6cf7,#ec6a8e)'}}/>
</div>
))}
</div>
</div>
</div>
</Pane>
</div>
</ElyShell>
);};
window.SplitScreen = SplitScreen;
+111
View File
@@ -0,0 +1,111 @@
// sync.jsx — Sync / Sign In page
const SyncScreen = () => {
const Device = ({ name, model, when, current, icon }) => (
<div style={{display:'flex',alignItems:'center', gap:12, padding:'14px 4px', borderBottom:'1px solid var(--ely-divider)'}}>
<div style={{width:36, height:36, borderRadius:9, background:'rgba(255,255,255,0.85)', boxShadow:'var(--ely-shadow-soft)', display:'flex',alignItems:'center',justifyContent:'center', color:'var(--ely-ink-2)'}}>{icon}</div>
<div style={{flex:1}}>
<div style={{fontSize:13, fontWeight:500, display:'flex', alignItems:'center', gap:8}}>{name}{current && <span style={{padding:'1px 7px', background:'rgba(79,181,154,0.18)', color:'#2f7d68', borderRadius:999, fontSize:10}}>This device</span>}</div>
<div style={{fontSize:11, color:'var(--ely-ink-4)', marginTop:1}}>{model} · {when}</div>
</div>
<span style={{fontSize:11, color:'var(--ely-ink-3)', display:'inline-flex',alignItems:'center', gap:4}}><span style={{width:6, height:6, borderRadius:'50%', background:'#4fb59a'}}/> Synced</span>
</div>
);
return (
<ElyShell wallpaper="violet" sidebarProps={{ activeId:'settings' }}>
<div className="ely-topbar">
<button className="ely-iconbtn"><I.ArrowLeft size={16}/></button>
<button className="ely-iconbtn disabled"><I.ArrowRight size={16}/></button>
<div className="ely-omnibar"><I.Cloud size={14}/><span className="url-host">ely://settings</span><span className="url-path">/sync</span></div>
<button className="ely-iconbtn"><I.Moon size={16}/></button>
<button className="ely-iconbtn"><I.Menu size={16}/></button>
</div>
<div className="ely-scroll" style={{flex:1, overflowY:'auto', padding:'40px 56px 32px'}}>
<div style={{maxWidth:880, margin:'0 auto', display:'grid', gridTemplateColumns:'1.1fr 1fr', gap:32, alignItems:'start'}}>
<div>
<div style={{display:'inline-flex', alignItems:'center', gap:8, padding:'4px 10px', borderRadius:999, background:'rgba(255,255,255,0.7)', boxShadow:'var(--ely-shadow-soft)', fontSize:11, color:'var(--ely-ink-3)'}}>
<I.Cloud size={12}/> Cloudflare Sync · end-to-end encrypted
</div>
<h1 className="ely-serif" style={{fontSize:46, lineHeight:1.05, margin:'16px 0 10px', fontWeight:400, letterSpacing:'-0.02em'}}>Your tabs, on every device.</h1>
<p style={{fontSize:14, color:'var(--ely-ink-2)', lineHeight:1.55, maxWidth:440, margin:0}}>Sign in once and ELY keeps your tabs, workspaces, pinned items and history in sync encrypted in your hands, mirrored at the edge.</p>
<div style={{marginTop:28, padding:24, borderRadius:16, background:'rgba(255,255,255,0.85)', boxShadow:'var(--ely-shadow-card)', maxWidth:380}}>
<div style={{fontSize:12, color:'var(--ely-ink-3)', marginBottom:14, display:'flex', alignItems:'center', gap:6}}>
<I.Lock size={12}/> Better Auth · ELY Account
</div>
<label style={{display:'block', fontSize:11, color:'var(--ely-ink-3)', marginBottom:6}}>Email</label>
<div style={{height:40, padding:'0 12px', borderRadius:9, background:'#fff', boxShadow:'var(--ely-shadow-soft)', display:'flex', alignItems:'center', fontSize:13, color:'var(--ely-ink)'}}>alex@elybrowser.com</div>
<label style={{display:'block', fontSize:11, color:'var(--ely-ink-3)', marginBottom:6, marginTop:14}}>Password</label>
<div style={{height:40, padding:'0 12px', borderRadius:9, background:'#fff', boxShadow:'var(--ely-shadow-soft)', display:'flex', alignItems:'center', fontSize:13, color:'var(--ely-ink)', letterSpacing:'0.2em'}}></div>
<button style={{width:'100%', marginTop:18, height:42, borderRadius:10, background:'var(--ely-ink)', color:'#fff', fontWeight:500, fontSize:13, border:0, display:'flex', alignItems:'center', justifyContent:'center', gap:8}}>
Sign in <I.ArrowRight size={14}/>
</button>
<div style={{display:'flex', alignItems:'center', gap:10, margin:'14px 0 12px'}}>
<div style={{flex:1, height:1, background:'var(--ely-divider)'}}/><span style={{fontSize:10.5, color:'var(--ely-ink-4)'}}>or continue with</span><div style={{flex:1, height:1, background:'var(--ely-divider)'}}/>
</div>
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:8}}>
{['Google','GitHub','Apple'].map(p=>(
<div key={p} style={{height:36, borderRadius:9, background:'#fff', boxShadow:'var(--ely-shadow-soft)', display:'flex', alignItems:'center', justifyContent:'center', gap:6, fontSize:12, fontWeight:500, color:'var(--ely-ink)'}}>
{p==='GitHub' && <Brand.GitHub s={13}/>}
{p==='Google' && <span style={{width:13, height:13, borderRadius:'50%', background:'conic-gradient(from 90deg, #ea4335, #fbbc05, #34a853, #4285f4)'}}/>}
{p==='Apple' && <span style={{fontSize:13}}></span>}
{p}
</div>
))}
</div>
<div style={{marginTop:14, fontSize:11, color:'var(--ely-ink-4)', textAlign:'center'}}>By signing in you accept the ELY <span style={{color:'var(--ely-ink-2)', textDecoration:'underline', textDecorationColor:'var(--ely-ink-5)'}}>terms</span> &amp; <span style={{color:'var(--ely-ink-2)', textDecoration:'underline', textDecorationColor:'var(--ely-ink-5)'}}>privacy</span>.</div>
</div>
</div>
<div style={{display:'flex', flexDirection:'column', gap:14}}>
<div className="ely-card" style={{padding:18}}>
<div style={{display:'flex', alignItems:'center', gap:10, marginBottom:6}}>
<span style={{width:28, height:28, borderRadius:8, background:'linear-gradient(135deg,#fff,#f0e9e2)', display:'flex',alignItems:'center',justifyContent:'center', boxShadow:'var(--ely-shadow-soft)', color:'var(--ely-accent)'}}><I.Devices size={14}/></span>
<div style={{fontSize:13, fontWeight:500, flex:1}}>Devices</div>
<span style={{fontSize:11, color:'var(--ely-ink-3)'}}>3 connected</span>
</div>
<Device current name="Alex's MacBook Pro" model="macOS 14.4 · Apple Silicon" when="Active now" icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="3" y="4" width="18" height="12" rx="1.5"/><path d="M2 20h20"/></svg>}/>
<Device name="iPhone 15 Pro" model="iOS 17.4 · Cellular" when="2 minutes ago" icon={<svg width="14" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M11 18h2"/></svg>}/>
<Device name="Studio iMac" model="macOS 14.2 · Office" when="6 hours ago" icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="2" y="3" width="20" height="14" rx="1.5"/><path d="M8 21h8M12 17v4"/></svg>}/>
<div style={{padding:'12px 4px 0', display:'flex', alignItems:'center', gap:10}}>
<span style={{padding:'7px 14px', borderRadius:8, background:'#fff', boxShadow:'var(--ely-shadow-soft)', fontSize:12, fontWeight:500, display:'inline-flex', alignItems:'center', gap:6}}><I.Plus size={12}/> Pair a device</span>
<span style={{fontSize:11, color:'var(--ely-ink-4)'}}>or scan QR with the ELY app</span>
</div>
</div>
<div className="ely-card" style={{padding:18, background:'linear-gradient(135deg, rgba(255,235,225,0.95), rgba(232,224,255,0.85))'}}>
<div style={{display:'flex', alignItems:'flex-start', gap:12}}>
<span style={{width:30, height:30, borderRadius:8, background:'#fff', display:'flex',alignItems:'center',justifyContent:'center', boxShadow:'var(--ely-shadow-soft)', color:'var(--ely-accent)'}}><I.Key size={15}/></span>
<div style={{flex:1}}>
<div style={{fontSize:13, fontWeight:500}}>Recovery key</div>
<div style={{fontSize:11.5, color:'var(--ely-ink-3)', marginTop:2}}>The only way to restore an end-to-end encrypted account. We don't keep a copy.</div>
</div>
</div>
<div className="ely-mono" style={{marginTop:14, padding:14, background:'rgba(255,255,255,0.85)', borderRadius:10, fontSize:13, letterSpacing:'0.05em', lineHeight:1.7, color:'var(--ely-ink)', textAlign:'center', fontWeight:500, boxShadow:'var(--ely-shadow-soft)'}}>
river · slate · candle · lantern<br/>orchid · meadow · whistle · ember
</div>
<div style={{display:'flex', gap:8, marginTop:12}}>
<span style={{flex:1, padding:'8px 10px', borderRadius:8, background:'#fff', boxShadow:'var(--ely-shadow-soft)', fontSize:12, fontWeight:500, textAlign:'center'}}>Copy</span>
<span style={{flex:1, padding:'8px 10px', borderRadius:8, background:'#fff', boxShadow:'var(--ely-shadow-soft)', fontSize:12, fontWeight:500, textAlign:'center'}}>Download .txt</span>
<span style={{flex:1, padding:'8px 10px', borderRadius:8, background:'var(--ely-ink)', color:'#fff', fontSize:12, fontWeight:500, textAlign:'center'}}>I've saved it</span>
</div>
</div>
<div className="ely-card" style={{padding:16}}>
<div style={{fontSize:13, fontWeight:500, marginBottom:8}}>What syncs</div>
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap:6, fontSize:12, color:'var(--ely-ink-2)'}}>
{[['Tabs &amp; pinned',true],['Workspaces',true],['History',true],['Bookmarks',true],['Reading list',true],['Plugins',true],['Passwords',false],['Open windows',false]].map(([n,on],i)=>(
<div key={i} style={{display:'flex',alignItems:'center',gap:8, padding:'4px 0'}}>
<span style={{color: on?'#2f7d68':'var(--ely-ink-4)'}}>{on? <I.Check size={13}/> : <I.X size={13}/>}</span>
<span dangerouslySetInnerHTML={{__html:n}}/>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</ElyShell>
);};
window.SyncScreen = SyncScreen;
+272
View File
@@ -0,0 +1,272 @@
// shared.jsx — icons + sidebar + browser shell shared across screens
// All Lucide-style 1.5px stroke SVG.
const Icon = ({ d, size = 16, fill = 'none', stroke = 'currentColor', sw = 1.5, children, style }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill={fill} stroke={stroke}
strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round" style={style}>
{d ? <path d={d} /> : children}
</svg>
);
const I = {
ArrowLeft: (p)=> <Icon size={p?.size||16} d="M19 12H5M12 19l-7-7 7-7"/>,
ArrowRight: (p)=> <Icon size={p?.size||16} d="M5 12h14M12 5l7 7-7 7"/>,
ArrowUpRight:(p)=> <Icon size={p?.size||14} d="M7 17 17 7M7 7h10v10"/>,
Plus: (p)=> <Icon size={p?.size||14} d="M12 5v14M5 12h14"/>,
X: (p)=> <Icon size={p?.size||14} d="M18 6 6 18M6 6l12 12"/>,
Search: (p)=> <Icon size={p?.size||14}><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></Icon>,
Star: (p)=> <Icon size={p?.size||14} d="M12 17.3l-5.4 3 1-6-4.3-4.2 6-.9L12 3.5l2.7 5.7 6 .9-4.3 4.2 1 6z"/>,
Sparkle: (p)=> <Icon size={p?.size||14} d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1"/>,
Sun: (p)=> <Icon size={p?.size||16}><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></Icon>,
Moon: (p)=> <Icon size={p?.size||16} d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>,
Sunrise: (p)=> <Icon size={p?.size||14} d="M17 18a5 5 0 0 0-10 0M12 9V2M4.2 10.2l1.4 1.4M19.8 10.2l-1.4 1.4M2 18h2M20 18h2M22 22H2M8 6l4-4 4 4"/>,
Settings: (p)=> <Icon size={p?.size||16}><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></Icon>,
Menu: (p)=> <Icon size={p?.size||16} d="M4 6h16M4 12h16M4 18h16"/>,
Filters: (p)=> <Icon size={p?.size||14} d="M3 6h13M3 12h9M3 18h6M19 4v6M22 7h-6M16 14v6M19 17h-6"/>,
Bookmark: (p)=> <Icon size={p?.size||14} d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>,
Copy: (p)=> <Icon size={p?.size||16}><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></Icon>,
Download: (p)=> <Icon size={p?.size||16} d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>,
ChevronDown:(p)=> <Icon size={p?.size||14} d="m6 9 6 6 6-6"/>,
ChevronRight:(p)=> <Icon size={p?.size||14} d="m9 6 6 6-6 6"/>,
ChevronUp: (p)=> <Icon size={p?.size||14} d="m6 15 6-6 6 6"/>,
Layers: (p)=> <Icon size={p?.size||16} d="M12 2 2 7l10 5 10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>,
Home: (p)=> <Icon size={p?.size||16} d="M3 9.5 12 3l9 6.5V21a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1z"/>,
Calendar: (p)=> <Icon size={p?.size||16}><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></Icon>,
Globe: (p)=> <Icon size={p?.size||14}><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></Icon>,
Lock: (p)=> <Icon size={p?.size||14}><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 1 1 8 0v4"/></Icon>,
Sidebar: (p)=> <Icon size={p?.size||16}><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></Icon>,
Split: (p)=> <Icon size={p?.size||16}><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M12 4v16"/></Icon>,
Cmd: (p)=> <Icon size={p?.size||14} d="M9 9V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3z"/>,
Reload: (p)=> <Icon size={p?.size||14} d="M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5"/>,
Pin: (p)=> <Icon size={p?.size||14} d="M12 17v5M9 3h6l-1 4 3 4H7l3-4z"/>,
Tab: (p)=> <Icon size={p?.size||14} d="M3 12h13l-3-3M3 12l3 3M21 5v14"/>,
Inbox: (p)=> <Icon size={p?.size||14} d="M22 12h-6l-2 3h-4l-2-3H2M5.5 5h13l3 7v6a2 2 0 0 1-2 2H4.5a2 2 0 0 1-2-2v-6z"/>,
Trash: (p)=> <Icon size={p?.size||14} d="M3 6h18M19 6l-1.5 14a2 2 0 0 1-2 2H8.5a2 2 0 0 1-2-2L5 6M10 11v6M14 11v6M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/>,
Eye: (p)=> <Icon size={p?.size||14}><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></Icon>,
Mic: (p)=> <Icon size={p?.size||14} d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3zM5 11a7 7 0 0 0 14 0M12 18v4"/>,
Lightning: (p)=> <Icon size={p?.size||14} d="M13 2 4 14h7l-1 8 9-12h-7z"/>,
Shield: (p)=> <Icon size={p?.size||14} d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>,
Cloud: (p)=> <Icon size={p?.size||14} d="M17 18a5 5 0 0 0-1-9.9A7 7 0 1 0 5 16h12z"/>,
Plug: (p)=> <Icon size={p?.size||14} d="M9 2v4M15 2v4M7 6h10v4a5 5 0 0 1-10 0zM12 15v7"/>,
Heart: (p)=> <Icon size={p?.size||14} d="M12 21s-7-4.5-9-9a5 5 0 0 1 9-3 5 5 0 0 1 9 3c-2 4.5-9 9-9 9z"/>,
Check: (p)=> <Icon size={p?.size||14} d="m5 12 5 5 9-11"/>,
Dot: (p)=> <Icon size={p?.size||4} fill="currentColor"><circle cx="12" cy="12" r="6"/></Icon>,
Key: (p)=> <Icon size={p?.size||14}><circle cx="8" cy="15" r="4"/><path d="m11 12 9-9 1 4-3 1 1 3-4 1z"/></Icon>,
Devices: (p)=> <Icon size={p?.size||14}><rect x="3" y="5" width="13" height="11" rx="1.5"/><rect x="14" y="11" width="7" height="9" rx="1.5"/></Icon>,
User: (p)=> <Icon size={p?.size||14}><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></Icon>,
Bell: (p)=> <Icon size={p?.size||14} d="M6 8a6 6 0 1 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10 21a2 2 0 0 0 4 0"/>,
Palette: (p)=> <Icon size={p?.size||14} d="M12 22a10 10 0 1 1 10-10c0 2-2 3-4 3h-2a2 2 0 0 0-2 2v2c0 2-1 3-2 3z"/>,
Code: (p)=> <Icon size={p?.size||14} d="m8 6-6 6 6 6M16 6l6 6-6 6M14 4l-4 16"/>,
Filter: (p)=> <Icon size={p?.size||14} d="M22 3H2l8 9.5V21l4-2v-6.5z"/>,
};
/* ===== little brand glyphs (faux app icons) ===== */
const Brand = {
Notion: ({s=22}) => (
<div style={{width:s, height:s, borderRadius:5, background:'#fff', display:'inline-flex',alignItems:'center',justifyContent:'center',
boxShadow:'0 0 0 1px rgba(0,0,0,0.08)', fontFamily:'serif', fontWeight:700, fontSize:s*0.65, color:'#111'}}>N</div>
),
YT: ({s=22}) => (
<div style={{width:s, height:s, borderRadius:5, background:'#FF3B2D', display:'inline-flex',alignItems:'center',justifyContent:'center', color:'#fff'}}>
<svg width={s*0.55} height={s*0.55} viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</div>
),
Dribbble: ({s=22}) => (
<div style={{width:s, height:s, borderRadius:'50%', background:'#ea4c89', display:'inline-flex',alignItems:'center',justifyContent:'center', color:'#fff', fontWeight:800, fontSize: s*0.55, fontStyle:'italic'}}>·</div>
),
X: ({s=22}) => (
<div style={{width:s, height:s, borderRadius:5, background:'#000', display:'inline-flex',alignItems:'center',justifyContent:'center', color:'#fff', fontWeight:700, fontSize: s*0.55}}>𝕏</div>
),
Vercel: ({s=22}) => (
<div style={{width:s, height:s, borderRadius:5, background:'#fff', display:'inline-flex',alignItems:'center',justifyContent:'center', boxShadow:'0 0 0 1px rgba(0,0,0,0.08)'}}>
<svg width={s*0.65} height={s*0.65} viewBox="0 0 24 24" fill="#000"><path d="M12 4 22 20H2z"/></svg>
</div>
),
Behance: ({s=22}) => (
<div style={{width:s, height:s, borderRadius:5, background:'#1769ff', display:'inline-flex',alignItems:'center',justifyContent:'center', color:'#fff', fontWeight:800, fontSize: s*0.55, fontFamily:'serif'}}></div>
),
Figma: ({s=18}) => (
<div style={{width:s, height:s, position:'relative'}}>
<svg width={s} height={s} viewBox="0 0 24 24">
<path fill="#F24E1E" d="M8 2h4v6H8a3 3 0 0 1 0-6z"/>
<path fill="#A259FF" d="M12 2h4a3 3 0 0 1 0 6h-4z"/>
<path fill="#F24E1E" d="M8 8h4v6H8a3 3 0 0 1 0-6z" opacity="0"/>
<path fill="#1ABCFE" d="M12 8h.5a3 3 0 1 1-.5 6V8z"/>
<path fill="#0ACF83" d="M8 14h4v3a3 3 0 0 1-6 0 3 3 0 0 1 2-3z"/>
<path fill="#FF7262" d="M8 8h4v6H8a3 3 0 0 1 0-6z"/>
</svg>
</div>
),
Slack: ({s=18}) => (
<svg width={s} height={s} viewBox="0 0 24 24"><path fill="#36c5f0" d="M5 14a2 2 0 1 0-2-2h2zm1 0a2 2 0 1 1 4 0v5a2 2 0 1 1-4 0z"/><path fill="#2eb67d" d="M10 5a2 2 0 1 0 2-2v2zm0 1a2 2 0 1 1 0 4H5a2 2 0 1 1 0-4z"/><path fill="#ecb22e" d="M19 10a2 2 0 1 0 2 2h-2zm-1 0a2 2 0 1 1-4 0V5a2 2 0 1 1 4 0z"/><path fill="#e01e5a" d="M14 19a2 2 0 1 0-2 2v-2zm0-1a2 2 0 1 1 0-4h5a2 2 0 1 1 0 4z"/></svg>
),
GitHub: ({s=18}) => (
<svg width={s} height={s} viewBox="0 0 24 24" fill="#1d1c1a"><path d="M12 2a10 10 0 0 0-3.16 19.49c.5.09.66-.22.66-.48v-1.7c-2.78.6-3.37-1.34-3.37-1.34-.46-1.16-1.11-1.46-1.11-1.46-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.89 1.52 2.34 1.08 2.91.83.09-.65.35-1.08.63-1.33-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02a9.6 9.6 0 0 1 5 0c1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.69-4.57 4.94.36.31.68.92.68 1.85v2.74c0 .27.16.58.67.48A10 10 0 0 0 12 2z"/></svg>
),
Linear: ({s=18}) => (
<div style={{width:s, height:s, borderRadius:'50%', background:'linear-gradient(135deg,#5e6ad2,#1d1c1a)', display:'inline-flex',alignItems:'center',justifyContent:'center', color:'#fff', fontSize:s*0.5, fontWeight:600, letterSpacing:'-0.05em'}}>L</div>
),
};
/* ============== SIDEBAR ============== */
const Sidebar = ({ activeId = 'home', mode = 'home', tabs, withTrafficTitle = true, workspace='Work', accentEmoji='🌸' }) => {
const defaultTabs = [
{ id:'figma', label:'Design System Figma', icon:<Brand.Figma s={16}/>, closable:true },
{ id:'roadmap', label:'Roadmap 2024', icon:<div style={{width:16,height:16,borderRadius:4, background:'linear-gradient(135deg,#7c6cf7,#ec6a8e)'}}/> },
{ id:'mkt', label:'Marketing Site', icon:<Icon size={16} stroke="#6b6660"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18"/></Icon> },
];
const t = tabs || defaultTabs;
return (
<aside className="ely-sidebar">
{withTrafficTitle && (
<div className="ely-traffic">
<span className="dot red"/><span className="dot yellow"/><span className="dot green"/>
<span className="title">ELY Browser <I.ChevronDown size={12}/></span>
</div>
)}
<div className="ely-ws-row">
<div className="ely-ws-tile">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
<rect x="3" y="4" width="8" height="16" rx="1.5"/>
<rect x="13" y="4" width="8" height="7" rx="1.5"/>
<rect x="13" y="13" width="8" height="7" rx="1.5"/>
</svg>
</div>
<div className="ely-ws-pick">
<span className="ws-glyph">{accentEmoji}</span>
<span className="grow">{workspace}</span>
<I.ChevronDown size={12}/>
</div>
<div className="ely-ws-add"><I.Plus size={14}/></div>
</div>
<div className="ely-nav">
<div className={'ely-nav-item' + (activeId==='home'?' active':'')}>
<span className="ico"><I.Home size={16}/></span>
<span className="label">Home</span>
</div>
<div className="ely-nav-item">
<span className="ico-tile" style={{color:'#f24e1e'}}>31</span>
<span className="label">Calendar</span>
</div>
<div className="ely-nav-item">
<span className="ico-tile"><Brand.Slack s={12}/></span>
<span className="label">Slack</span>
<span className="badge">12</span>
</div>
<div className="ely-nav-item">
<span className="ico-tile"><Brand.GitHub s={12}/></span>
<span className="label">GitHub</span>
</div>
<div className="ely-nav-item">
<span className="ico-tile"><Brand.Linear s={12}/></span>
<span className="label">Linear</span>
<span className="badge">3</span>
</div>
</div>
<div className="ely-sec-label"><I.Pin size={11}/> Pinned</div>
<div className="ely-nav">
<div className="ely-nav-item">
<span className="ico"><Brand.Figma s={16}/></span>
<span className="label">Design System Figma</span>
</div>
<div className={'ely-nav-item' + (activeId==='ely'?' active':'')}>
<span className="ico"><div style={{width:16,height:16,borderRadius:'50%', background:'linear-gradient(135deg,#ec6a8e,#c96442)'}}/></span>
<span className="label">ELY Browser Home</span>
<span className="dot-unread"/>
</div>
<div className="ely-nav-item">
<span className="ico"><Icon size={16} stroke="#6b6660" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M9 13h6M9 17h6M9 9h2"/></span>
<span className="label">Release Notes</span>
</div>
<div className="ely-nav-item">
<span className="ico"><div style={{width:16,height:16,borderRadius:4, background:'linear-gradient(135deg,#7c6cf7,#ec6a8e)'}}/></span>
<span className="label">Roadmap 2024</span>
<span className="dot-unread"/>
</div>
<div className="ely-nav-item">
<span className="ico"><Icon size={16} stroke="#6b6660"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18"/></Icon></span>
<span className="label">Marketing Site</span>
</div>
</div>
<div className="ely-sec-label"><I.Tab size={11}/> Tabs · {t.length}</div>
<div className="ely-nav">
{t.map(tab => (
<div key={tab.id} className={'ely-nav-item' + (mode==='browse' && tab.id==='figma'?' active':'') + (tab.active?' active':'')}>
<span className="ico">{tab.icon}</span>
<span className="label">{tab.label}</span>
{tab.audio && <span className="ico" style={{color:'var(--ely-accent)'}}><I.Lightning size={12}/></span>}
<span className="close"><I.X size={12}/></span>
</div>
))}
<div className="ely-nav-item" style={{color:'var(--ely-ink-3)'}}>
<span className="ico"><I.Plus size={14}/></span>
<span className="label">New Tab</span>
</div>
</div>
<div className="ely-sidebar-foot">
<div className="ely-nav-item">
<span className="ico"><I.Settings size={15}/></span>
<span className="label">Settings</span>
</div>
<div className="ely-profile-row">
<div className="ely-avatar">A</div>
<span className="name">Alex</span>
<I.ChevronDown size={12}/>
</div>
</div>
</aside>
);
};
/* Browser shell that hosts a sidebar + main area */
const ElyShell = ({ wallpaper='dawn', children, sidebarProps={}, fakeTraffic=true }) => (
<div className="ely-window">
<div className={'ely-wallpaper ely-wp-' + wallpaper}/>
<div className="ely-shell">
<Sidebar {...sidebarProps} withTrafficTitle={fakeTraffic}/>
<main className="ely-main">{children}</main>
</div>
</div>
);
/* topbar */
const TopBar = ({ url, host, path, secure=true, right, canBack=true, canForward=false }) => (
<div className="ely-topbar">
<button className={'ely-iconbtn'+(canBack?'':' disabled')}><I.ArrowLeft size={16}/></button>
<button className={'ely-iconbtn'+(canForward?'':' disabled')}><I.ArrowRight size={16}/></button>
<div className="ely-omnibar">
{secure ? <I.Lock size={14}/> : <I.Globe size={14}/>}
{host ? (
<>
<span className="url-host">{host}</span>
{path && <span className="url-path">{path}</span>}
</>
) : (
<span>{url || 'Search ELY or type a command…'}</span>
)}
<span className="right">
<I.Filters size={14}/>
<I.Star size={14}/>
</span>
</div>
{right || (
<>
<button className="ely-iconbtn"><I.Copy size={16}/></button>
<button className="ely-iconbtn"><I.Download size={16}/></button>
<button className="ely-iconbtn"><I.Moon size={16}/></button>
<button className="ely-iconbtn"><I.Menu size={16}/></button>
</>
)}
</div>
);
window.Sidebar = Sidebar;
window.ElyShell = ElyShell;
window.TopBar = TopBar;
window.Icon = Icon;
window.I = I;
window.Brand = Brand;
+302
View File
@@ -0,0 +1,302 @@
/* ELY Browser global tokens & shared chrome */
@import url('https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@300;400;450;500;600;700&family=Geist+Mono:wght@400;500&display=swap');
:root {
--ely-ink: #1d1c1a;
--ely-ink-2: #3a3733;
--ely-ink-3: #6b6660;
--ely-ink-4: #9a948c;
--ely-ink-5: #c8c2b8;
--ely-glass: rgba(255,255,255,0.62);
--ely-glass-2: rgba(255,255,255,0.78);
--ely-glass-3: rgba(255,255,255,0.92);
--ely-tint: rgba(255,255,255,0.42);
--ely-stroke: rgba(40,30,20,0.08);
--ely-stroke-2: rgba(40,30,20,0.04);
--ely-divider: rgba(40,30,20,0.06);
--ely-accent: #c96442; /* warm clay */
--ely-accent-2: #d97757;
--ely-violet: #7c6cf7;
--ely-rose: #ec6a8e;
--ely-mint: #4fb59a;
--ely-radius-card: 18px;
--ely-radius-pane: 14px;
--ely-radius-pill: 999px;
--ely-shadow-window: 0 30px 70px -20px rgba(40,30,20,0.35), 0 0 0 1px rgba(255,255,255,0.5) inset, 0 1px 0 rgba(255,255,255,0.7) inset;
--ely-shadow-card: 0 8px 24px -10px rgba(40,30,20,0.18), 0 0 0 1px var(--ely-stroke) inset;
--ely-shadow-soft: 0 1px 0 rgba(255,255,255,0.7) inset, 0 0 0 1px var(--ely-stroke);
--ely-font-sans: 'Geist', ui-sans-serif, sans-serif;
--ely-font-serif: 'Newsreader', 'Source Serif 4', Georgia, serif;
--ely-font-mono: 'Geist Mono', ui-monospace, monospace;
}
/* ============== BROWSER FRAME ============== */
.ely-window {
width: 100%;
height: 100%;
position: relative;
font-family: var(--ely-font-sans);
color: var(--ely-ink);
font-feature-settings: 'cv11', 'ss01';
letter-spacing: -0.01em;
overflow: hidden;
border-radius: 14px;
isolation: isolate;
}
/* the dreamy gradient wallpaper */
.ely-wallpaper {
position: absolute; inset: 0;
background:
radial-gradient(60% 50% at 85% 18%, #ffd1d8 0%, rgba(255,209,216,0) 70%),
radial-gradient(55% 60% at 15% 95%, #b9c7ff 0%, rgba(185,199,255,0) 75%),
radial-gradient(80% 80% at 50% 50%, #fff3ee 0%, #efe8e1 100%);
z-index: 0;
}
.ely-wallpaper.ely-wp-dawn {
background:
radial-gradient(60% 50% at 85% 18%, #ffd1d8 0%, rgba(255,209,216,0) 70%),
radial-gradient(55% 60% at 15% 95%, #b9c7ff 0%, rgba(185,199,255,0) 75%),
radial-gradient(80% 80% at 50% 50%, #fff3ee 0%, #efe8e1 100%);
}
.ely-wallpaper.ely-wp-violet {
background:
radial-gradient(50% 50% at 80% 20%, #d6c4ff 0%, rgba(214,196,255,0) 70%),
radial-gradient(50% 50% at 12% 90%, #ffe0c0 0%, rgba(255,224,192,0) 70%),
radial-gradient(80% 80% at 50% 50%, #f4eef9 0%, #e9e2ee 100%);
}
.ely-wallpaper.ely-wp-mint {
background:
radial-gradient(50% 50% at 85% 25%, #c5f0db 0%, rgba(197,240,219,0) 70%),
radial-gradient(55% 55% at 10% 95%, #ffe5b8 0%, rgba(255,229,184,0) 70%),
radial-gradient(80% 80% at 50% 50%, #f0f5ee 0%, #e6ece2 100%);
}
.ely-shell {
position: absolute;
inset: 16px;
z-index: 1;
display: grid;
grid-template-columns: 256px 1fr;
gap: 12px;
}
/* ============== SIDEBAR ============== */
.ely-sidebar {
background: var(--ely-glass);
backdrop-filter: blur(28px) saturate(140%);
-webkit-backdrop-filter: blur(28px) saturate(140%);
border-radius: var(--ely-radius-card);
box-shadow: var(--ely-shadow-window);
display: flex;
flex-direction: column;
padding: 10px 10px 12px;
gap: 10px;
position: relative;
}
.ely-traffic {
display: flex; gap: 8px; padding: 6px 6px 4px;
align-items: center;
}
.ely-traffic .dot { width: 12px; height: 12px; border-radius: 50%; box-shadow: inset 0 0 0 0.5px rgba(0,0,0,0.18); }
.ely-traffic .dot.red { background: #ff5f57; }
.ely-traffic .dot.yellow { background: #febc2e; }
.ely-traffic .dot.green { background: #28c840; }
.ely-traffic .title {
margin-left: 8px;
font-size: 12.5px; font-weight: 500;
color: var(--ely-ink-2);
display: inline-flex; align-items: center; gap: 4px;
}
.ely-traffic .title svg { opacity: .6; }
.ely-ws-row {
display: flex; align-items: center; gap: 6px;
padding: 4px 2px;
}
.ely-ws-tile {
width: 32px; height: 32px;
border-radius: 9px;
background: linear-gradient(135deg, #fff 0%, #ffe9e0 100%);
display: flex; align-items: center; justify-content: center;
box-shadow: var(--ely-shadow-soft);
color: var(--ely-accent);
flex: 0 0 auto;
}
.ely-ws-pick {
flex: 1; min-width: 0;
display: flex; align-items: center; gap: 8px;
padding: 6px 8px;
border-radius: 9px;
background: rgba(255,255,255,0.6);
box-shadow: var(--ely-shadow-soft);
font-size: 13px; font-weight: 500; color: var(--ely-ink);
}
.ely-ws-pick .ws-glyph {
width: 18px; height: 18px; border-radius: 5px;
background: linear-gradient(135deg, #ec6a8e, #c96442);
display: inline-flex; align-items: center; justify-content: center;
color: #fff; font-size: 10px;
}
.ely-ws-pick .grow { flex: 1; }
.ely-ws-pick svg { color: var(--ely-ink-3); }
.ely-ws-add {
width: 32px; height: 32px;
border-radius: 9px;
background: rgba(255,255,255,0.4);
box-shadow: var(--ely-shadow-soft);
display: flex; align-items: center; justify-content: center;
color: var(--ely-ink-3);
flex: 0 0 auto;
}
.ely-nav { display: flex; flex-direction: column; gap: 1px; padding: 4px 0 0; }
.ely-nav-item {
display: flex; align-items: center; gap: 10px;
padding: 7px 10px;
border-radius: 8px;
font-size: 13px;
color: var(--ely-ink-2);
position: relative;
}
.ely-nav-item .ico {
width: 18px; height: 18px; flex: 0 0 auto;
display: inline-flex; align-items: center; justify-content: center;
color: var(--ely-ink-3);
}
.ely-nav-item .ico-tile {
width: 18px; height: 18px; flex: 0 0 auto;
border-radius: 4px;
background: #fff; box-shadow: 0 0 0 1px var(--ely-stroke);
display: inline-flex; align-items: center; justify-content: center;
font-size: 10px; font-weight: 600;
}
.ely-nav-item .label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ely-nav-item .badge {
font-size: 10px;
background: rgba(40,30,20,0.06);
color: var(--ely-ink-3);
padding: 1px 6px;
border-radius: 999px;
}
.ely-nav-item .dot-unread {
width: 6px; height: 6px; border-radius: 50%;
background: var(--ely-accent);
}
.ely-nav-item.active {
background: rgba(255,255,255,0.85);
color: var(--ely-ink);
box-shadow: var(--ely-shadow-soft);
}
.ely-nav-item .close {
width: 16px; height: 16px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 4px;
color: var(--ely-ink-4);
opacity: 0;
}
.ely-nav-item:hover .close { opacity: 1; }
.ely-sec-label {
font-size: 10.5px;
font-weight: 500;
color: var(--ely-ink-4);
letter-spacing: 0.08em;
text-transform: uppercase;
display: flex; align-items: center; gap: 6px;
padding: 8px 10px 4px;
}
.ely-sidebar-foot {
margin-top: auto;
display: flex; flex-direction: column; gap: 2px;
padding-top: 8px;
border-top: 1px solid var(--ely-divider);
}
.ely-profile-row {
display: flex; align-items: center; gap: 10px;
padding: 6px 10px;
border-radius: 9px;
}
.ely-avatar {
width: 26px; height: 26px; border-radius: 50%;
background: linear-gradient(135deg, #f4c8b8, #c96442);
display: inline-flex; align-items: center; justify-content: center;
font-size: 10px; font-weight: 600; color: #fff;
flex: 0 0 auto;
}
.ely-profile-row .name { font-size: 13px; font-weight: 500; flex: 1; }
/* ============== MAIN PANE ============== */
.ely-main {
background: var(--ely-glass);
backdrop-filter: blur(28px) saturate(140%);
-webkit-backdrop-filter: blur(28px) saturate(140%);
border-radius: var(--ely-radius-card);
box-shadow: var(--ely-shadow-window);
display: flex; flex-direction: column;
overflow: hidden;
position: relative;
}
.ely-topbar {
height: 54px;
display: flex; align-items: center; gap: 8px;
padding: 0 14px;
border-bottom: 1px solid var(--ely-divider);
flex: 0 0 auto;
}
.ely-iconbtn {
width: 30px; height: 30px;
border-radius: 8px;
display: inline-flex; align-items: center; justify-content: center;
color: var(--ely-ink-3);
background: transparent;
border: 0;
}
.ely-iconbtn:hover { background: rgba(255,255,255,0.55); color: var(--ely-ink); }
.ely-iconbtn.is-on { background: rgba(255,255,255,0.85); color: var(--ely-ink); box-shadow: var(--ely-shadow-soft); }
.ely-iconbtn.disabled { color: var(--ely-ink-5); }
.ely-omnibar {
flex: 1;
height: 36px;
display: flex; align-items: center; gap: 10px;
background: rgba(255,255,255,0.55);
border-radius: var(--ely-radius-pill);
padding: 0 14px;
font-size: 13px;
color: var(--ely-ink-3);
box-shadow: var(--ely-shadow-soft);
}
.ely-omnibar .url-host { color: var(--ely-ink); font-weight: 500; }
.ely-omnibar .url-path { color: var(--ely-ink-3); }
.ely-omnibar .right { margin-left: auto; display: flex; align-items: center; gap: 4px; color: var(--ely-ink-4); }
/* generic glass card */
.ely-card {
background: var(--ely-glass-2);
border-radius: 16px;
box-shadow: var(--ely-shadow-card);
}
/* utility */
.ely-row { display: flex; align-items: center; }
.ely-col { display: flex; flex-direction: column; }
.ely-grow { flex: 1; min-width: 0; }
.ely-mono { font-family: var(--ely-font-mono); }
.ely-serif { font-family: var(--ely-font-serif); }
/* scrollbar invisibilize within artboards (already done by canvas) */
.ely-scroll::-webkit-scrollbar { display: none; }
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB