update
This commit is contained in:
@@ -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>
|
||||
@@ -0,0 +1,936 @@
|
||||
|
||||
// DesignCanvas.jsx — Figma-ish design canvas wrapper
|
||||
// Warm gray grid bg + Sections + Artboards + PostIt notes.
|
||||
// Artboards are reorderable (grip-drag), deletable, labels/titles are
|
||||
// inline-editable, and any artboard can be opened in a fullscreen focus
|
||||
// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
|
||||
// via the host bridge. No assets, no deps.
|
||||
//
|
||||
// Usage:
|
||||
// <DesignCanvas>
|
||||
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
|
||||
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
|
||||
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
|
||||
// </DCSection>
|
||||
// </DesignCanvas>
|
||||
|
||||
const DC = {
|
||||
bg: '#f0eee9',
|
||||
grid: 'rgba(0,0,0,0.06)',
|
||||
label: 'rgba(60,50,40,0.7)',
|
||||
title: 'rgba(40,30,20,0.85)',
|
||||
subtitle: 'rgba(60,50,40,0.6)',
|
||||
postitBg: '#fef4a8',
|
||||
postitText: '#5a4a2a',
|
||||
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
|
||||
};
|
||||
|
||||
// One-time CSS injection (classes are dc-prefixed so they don't collide with
|
||||
// the hosted design's own styles).
|
||||
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
|
||||
const s = document.createElement('style');
|
||||
s.id = 'dc-styles';
|
||||
s.textContent = [
|
||||
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
|
||||
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
|
||||
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
|
||||
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
|
||||
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
|
||||
// isolation:isolate contains artboard content's z-indexes so a
|
||||
// z-indexed child (sticky navbar etc.) can't paint over .dc-header or
|
||||
// the .dc-menu popover that drops into the top of the card.
|
||||
'.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}',
|
||||
'.dc-card *{scrollbar-width:none}',
|
||||
'.dc-card *::-webkit-scrollbar{display:none}',
|
||||
// Per-artboard header: grip + label on the left, delete/expand on the
|
||||
// right. Single flex row; when the artboard's on-screen width is too
|
||||
// narrow for both the label yields (ellipsis, then hidden entirely below
|
||||
// ~4ch via the container query) and the buttons stay on the row.
|
||||
'.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
|
||||
' display:flex;align-items:center;container-type:inline-size}',
|
||||
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
|
||||
'.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
|
||||
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
|
||||
'.dc-grip:active{cursor:grabbing}',
|
||||
'.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
|
||||
' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
|
||||
// Below ~4ch of label room: hide the label entirely, and drop the grip to
|
||||
// hover-only (same reveal rule as .dc-btns) so a narrow header is clean
|
||||
// until the card is moused.
|
||||
'@container (max-width: 110px){',
|
||||
' .dc-labeltext{display:none}',
|
||||
' .dc-grip{opacity:0}',
|
||||
' [data-dc-slot]:hover .dc-grip{opacity:1}',
|
||||
'}',
|
||||
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
|
||||
'.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
|
||||
'.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
|
||||
'.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
|
||||
'[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}',
|
||||
'.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
|
||||
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
|
||||
' font:inherit;transition:background .12s,color .12s}',
|
||||
'.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}',
|
||||
// Slot hosting an open menu floats above later siblings (which otherwise
|
||||
// paint on top — same z-index:auto, later DOM order) so the popup isn't
|
||||
// clipped by the next card.
|
||||
'[data-dc-slot]:has(.dc-menu){z-index:10}',
|
||||
'.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;',
|
||||
' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}',
|
||||
'.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;',
|
||||
' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;',
|
||||
' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}',
|
||||
'.dc-menu button:hover{background:rgba(0,0,0,.05)}',
|
||||
'.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}',
|
||||
'.dc-menu .dc-danger{color:#c96442}',
|
||||
'.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}',
|
||||
// Chrome (titles / labels / buttons) counter-scales against the viewport
|
||||
// zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
|
||||
// DCViewport on every transform update and inherits to all descendants —
|
||||
// any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
|
||||
// it the same way.
|
||||
//
|
||||
// The header uses transform:scale (out-of-flow, so layout impact doesn't
|
||||
// matter) with its world-space width set to card-width / inv-zoom so that
|
||||
// after counter-scaling its on-screen width exactly matches the card's —
|
||||
// that's what lets the container query + text-overflow behave against the
|
||||
// card's visible edge at every zoom level.
|
||||
//
|
||||
// The section head uses CSS zoom instead of transform so its layout box
|
||||
// grows with the counter-scale, pushing the card row down — otherwise the
|
||||
// constant-screen-size title would overflow into the (shrinking) world-
|
||||
// space gap and overlap the artboard headers at low zoom.
|
||||
'.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
|
||||
' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
|
||||
'.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
|
||||
].join('\n');
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
const DCCtx = React.createContext(null);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
|
||||
// Owns runtime state (per-section order, renamed titles/labels, hidden
|
||||
// artboards, focused artboard). Order/titles/labels/hidden persist to a
|
||||
// .design-canvas.state.json
|
||||
// sidecar next to the HTML. Reads go via plain fetch() so the saved
|
||||
// arrangement is visible anywhere the HTML + sidecar are served together
|
||||
// (omelette preview, direct link, downloaded zip). Writes go through the
|
||||
// host's window.omelette bridge — editing requires the omelette runtime.
|
||||
// Focus is ephemeral.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const DC_STATE_FILE = '.design-canvas.state.json';
|
||||
|
||||
function DesignCanvas({ children, minScale, maxScale, style }) {
|
||||
const [state, setState] = React.useState({ sections: {}, focus: null });
|
||||
// Hold rendering until the sidecar read settles so the saved order/titles
|
||||
// appear on first paint (no source-order flash). didRead gates writes until
|
||||
// the read settles so the empty initial state can't clobber a slow read;
|
||||
// skipNextWrite suppresses the one echo-write that would otherwise follow
|
||||
// hydration.
|
||||
const [ready, setReady] = React.useState(false);
|
||||
const didRead = React.useRef(false);
|
||||
const skipNextWrite = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let off = false;
|
||||
fetch('./' + DC_STATE_FILE)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((saved) => {
|
||||
if (off || !saved || !saved.sections) return;
|
||||
skipNextWrite.current = true;
|
||||
setState((s) => ({ ...s, sections: saved.sections }));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { didRead.current = true; if (!off) setReady(true); });
|
||||
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
|
||||
return () => { off = true; clearTimeout(t); };
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!didRead.current) return;
|
||||
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
|
||||
const t = setTimeout(() => {
|
||||
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
|
||||
}, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [state.sections]);
|
||||
|
||||
// Build registries synchronously from children so FocusOverlay can read
|
||||
// them in the same render. Only direct DCSection > DCArtboard children are
|
||||
// walked — wrapping them in other elements opts out of focus/reorder.
|
||||
const registry = {}; // slotId -> { sectionId, artboard }
|
||||
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
|
||||
const sectionOrder = [];
|
||||
React.Children.forEach(children, (sec) => {
|
||||
if (!sec || sec.type !== DCSection) return;
|
||||
const sid = sec.props.id ?? sec.props.title;
|
||||
if (!sid) return;
|
||||
sectionOrder.push(sid);
|
||||
const persisted = state.sections[sid] || {};
|
||||
const abs = [];
|
||||
React.Children.forEach(sec.props.children, (ab) => {
|
||||
if (!ab || ab.type !== DCArtboard) return;
|
||||
const aid = ab.props.id ?? ab.props.label;
|
||||
if (aid) abs.push([aid, ab]);
|
||||
});
|
||||
// hidden is scoped to one source revision — when the agent regenerates
|
||||
// (artboard-ID set changes), prior deletes don't apply to new content.
|
||||
const srcKey = abs.map(([k]) => k).join('\x1f');
|
||||
const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
|
||||
const srcIds = [];
|
||||
abs.forEach(([aid, ab]) => {
|
||||
if (hidden.includes(aid)) return;
|
||||
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
|
||||
srcIds.push(aid);
|
||||
});
|
||||
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
|
||||
sectionMeta[sid] = {
|
||||
title: persisted.title ?? sec.props.title,
|
||||
subtitle: sec.props.subtitle,
|
||||
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
|
||||
};
|
||||
});
|
||||
|
||||
const api = React.useMemo(() => ({
|
||||
state,
|
||||
section: (id) => state.sections[id] || {},
|
||||
patchSection: (id, p) => setState((s) => ({
|
||||
...s,
|
||||
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
|
||||
})),
|
||||
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
|
||||
}), [state]);
|
||||
|
||||
// Esc exits focus; any outside pointerdown commits an in-progress rename.
|
||||
React.useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
|
||||
const onPd = (e) => {
|
||||
const ae = document.activeElement;
|
||||
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.addEventListener('pointerdown', onPd, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('pointerdown', onPd, true);
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<DCCtx.Provider value={api}>
|
||||
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
|
||||
{state.focus && registry[state.focus] && (
|
||||
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
|
||||
)}
|
||||
</DCCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCViewport — transform-based pan/zoom (internal)
|
||||
//
|
||||
// Input mapping (Figma-style):
|
||||
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
|
||||
// • trackpad scroll → pan (two-finger)
|
||||
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
|
||||
// • middle-drag / primary-drag-on-bg → pan
|
||||
//
|
||||
// Transform state lives in a ref and is written straight to the DOM
|
||||
// (translate3d + will-change) so wheel ticks don't go through React —
|
||||
// keeps pans at 60fps on dense canvases.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
|
||||
const vpRef = React.useRef(null);
|
||||
const worldRef = React.useRef(null);
|
||||
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
|
||||
// Persist viewport across reloads so the user lands back where they were
|
||||
// after an agent edit or browser refresh. The sandbox origin is already
|
||||
// per-project; pathname keeps multiple canvas files in one project apart.
|
||||
const tfKey = 'dc-viewport:' + location.pathname;
|
||||
const saveT = React.useRef(0);
|
||||
|
||||
const lastPostedScale = React.useRef();
|
||||
const apply = React.useCallback(() => {
|
||||
const { x, y, scale } = tf.current;
|
||||
const el = worldRef.current;
|
||||
if (!el) return;
|
||||
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
|
||||
// Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
|
||||
el.style.setProperty('--dc-inv-zoom', String(1 / scale));
|
||||
// Keep the host toolbar's % readout in sync with the canvas scale. Pan
|
||||
// ticks leave scale unchanged — skip the cross-frame post for those.
|
||||
if (lastPostedScale.current !== scale) {
|
||||
lastPostedScale.current = scale;
|
||||
window.parent.postMessage({ type: '__dc_zoom', scale }, '*');
|
||||
}
|
||||
clearTimeout(saveT.current);
|
||||
saveT.current = setTimeout(() => {
|
||||
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
|
||||
}, 200);
|
||||
}, [tfKey]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const flush = () => {
|
||||
clearTimeout(saveT.current);
|
||||
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
|
||||
};
|
||||
try {
|
||||
const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
|
||||
if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
|
||||
tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
|
||||
apply();
|
||||
}
|
||||
} catch {}
|
||||
// Flush on pagehide and unmount so a reload within the 200ms debounce
|
||||
// window doesn't drop the last pan/zoom.
|
||||
window.addEventListener('pagehide', flush);
|
||||
return () => { window.removeEventListener('pagehide', flush); flush(); };
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const vp = vpRef.current;
|
||||
if (!vp) return;
|
||||
|
||||
const zoomAt = (cx, cy, factor) => {
|
||||
const r = vp.getBoundingClientRect();
|
||||
const px = cx - r.left, py = cy - r.top;
|
||||
const t = tf.current;
|
||||
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
|
||||
const k = next / t.scale;
|
||||
// keep the world point under the cursor fixed
|
||||
t.x = px - (px - t.x) * k;
|
||||
t.y = py - (py - t.y) * k;
|
||||
t.scale = next;
|
||||
apply();
|
||||
};
|
||||
|
||||
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
|
||||
// line-mode deltas (Firefox) or large integer pixel deltas with no X
|
||||
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
|
||||
// two-finger scroll sends small/fractional pixel deltas, often with
|
||||
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
|
||||
const isMouseWheel = (e) =>
|
||||
e.deltaMode !== 0 ||
|
||||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
|
||||
|
||||
const onWheel = (e) => {
|
||||
e.preventDefault();
|
||||
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
|
||||
if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) {
|
||||
// trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched
|
||||
// wheels fall through to the fixed-step branch below.
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
|
||||
} else if (isMouseWheel(e)) {
|
||||
// notched mouse wheel — fixed-ratio step per click
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
|
||||
} else {
|
||||
// trackpad two-finger scroll — pan
|
||||
tf.current.x -= e.deltaX;
|
||||
tf.current.y -= e.deltaY;
|
||||
apply();
|
||||
}
|
||||
};
|
||||
|
||||
// Safari sends native gesture* events for trackpad pinch with a smooth
|
||||
// e.scale; preferring these over the ctrl+wheel fallback gives a much
|
||||
// better feel there. No-ops on other browsers. Safari also fires
|
||||
// ctrlKey wheel events during the same pinch — isGesturing makes
|
||||
// onWheel drop those entirely so they neither zoom nor pan.
|
||||
let gsBase = 1;
|
||||
let isGesturing = false;
|
||||
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
|
||||
const onGestureChange = (e) => {
|
||||
e.preventDefault();
|
||||
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
|
||||
};
|
||||
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
|
||||
|
||||
// Drag-pan: middle button anywhere, or primary button on canvas
|
||||
// background (anything that isn't an artboard or an inline editor).
|
||||
let drag = null;
|
||||
const onPointerDown = (e) => {
|
||||
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
|
||||
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
|
||||
e.preventDefault();
|
||||
vp.setPointerCapture(e.pointerId);
|
||||
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
|
||||
vp.style.cursor = 'grabbing';
|
||||
};
|
||||
const onPointerMove = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
tf.current.x += e.clientX - drag.lx;
|
||||
tf.current.y += e.clientY - drag.ly;
|
||||
drag.lx = e.clientX; drag.ly = e.clientY;
|
||||
apply();
|
||||
};
|
||||
const onPointerUp = (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
vp.releasePointerCapture(e.pointerId);
|
||||
drag = null;
|
||||
vp.style.cursor = '';
|
||||
};
|
||||
|
||||
// Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
|
||||
// visible midpoint stays fixed — matching the host's iframe-zoom feel.
|
||||
const onHostMsg = (e) => {
|
||||
const d = e.data;
|
||||
if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') {
|
||||
const r = vp.getBoundingClientRect();
|
||||
zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
|
||||
} else if (d && d.type === '__dc_probe') {
|
||||
// Host's [readyGen] reset asks whether a canvas is present; it
|
||||
// fires on the iframe's native 'load', which for canvases with
|
||||
// images/fonts is after our mount-time announce, so re-announce.
|
||||
// Clear the pan-tick guard so apply() re-posts the current scale
|
||||
// even if it's unchanged — the host just reset dcScale to 1.
|
||||
window.parent.postMessage({ type: '__dc_present' }, '*');
|
||||
lastPostedScale.current = undefined;
|
||||
apply();
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', onHostMsg);
|
||||
// Announce canvas mode so the host toolbar proxies its % control here
|
||||
// instead of scaling the iframe element (which would just shrink the
|
||||
// viewport window of an infinite canvas). The apply() that follows emits
|
||||
// the initial __dc_zoom so the toolbar % is correct before first pinch.
|
||||
// lastPostedScale reset mirrors the __dc_probe handler: the layout
|
||||
// effect's restore-path apply() may already have posted the restored
|
||||
// scale (before __dc_present), so clear the guard to re-post it in order.
|
||||
window.parent.postMessage({ type: '__dc_present' }, '*');
|
||||
lastPostedScale.current = undefined;
|
||||
apply();
|
||||
|
||||
vp.addEventListener('wheel', onWheel, { passive: false });
|
||||
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
|
||||
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
|
||||
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
|
||||
vp.addEventListener('pointerdown', onPointerDown);
|
||||
vp.addEventListener('pointermove', onPointerMove);
|
||||
vp.addEventListener('pointerup', onPointerUp);
|
||||
vp.addEventListener('pointercancel', onPointerUp);
|
||||
return () => {
|
||||
window.removeEventListener('message', onHostMsg);
|
||||
vp.removeEventListener('wheel', onWheel);
|
||||
vp.removeEventListener('gesturestart', onGestureStart);
|
||||
vp.removeEventListener('gesturechange', onGestureChange);
|
||||
vp.removeEventListener('gestureend', onGestureEnd);
|
||||
vp.removeEventListener('pointerdown', onPointerDown);
|
||||
vp.removeEventListener('pointermove', onPointerMove);
|
||||
vp.removeEventListener('pointerup', onPointerUp);
|
||||
vp.removeEventListener('pointercancel', onPointerUp);
|
||||
};
|
||||
}, [apply, minScale, maxScale]);
|
||||
|
||||
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
|
||||
return (
|
||||
<div
|
||||
ref={vpRef}
|
||||
className="design-canvas"
|
||||
style={{
|
||||
height: '100vh', width: '100vw',
|
||||
background: DC.bg,
|
||||
overflow: 'hidden',
|
||||
overscrollBehavior: 'none',
|
||||
touchAction: 'none',
|
||||
position: 'relative',
|
||||
fontFamily: DC.font,
|
||||
boxSizing: 'border-box',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={worldRef}
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0,
|
||||
transformOrigin: '0 0',
|
||||
willChange: 'transform',
|
||||
width: 'max-content', minWidth: '100%',
|
||||
minHeight: '100%',
|
||||
padding: '60px 0 80px',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DCSection — editable title + h-row of artboards in persisted order
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCSection({ id, title, subtitle, children, gap = 48 }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const sid = id ?? title;
|
||||
const all = React.Children.toArray(children);
|
||||
const artboards = all.filter((c) => c && c.type === DCArtboard);
|
||||
const rest = all.filter((c) => !(c && c.type === DCArtboard));
|
||||
const sec = (ctx && sid && ctx.section(sid)) || {};
|
||||
// Must match DesignCanvas's srcKey computation exactly (it filters falsy
|
||||
// IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
|
||||
const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
|
||||
const srcKey = allIds.join('\x1f');
|
||||
const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
|
||||
const srcOrder = allIds.filter((k) => !hidden.includes(k));
|
||||
|
||||
const order = React.useMemo(() => {
|
||||
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
|
||||
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
|
||||
}, [sec.order, srcOrder.join('|')]);
|
||||
|
||||
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
|
||||
|
||||
// marginBottom counter-scales so the on-screen gap between sections stays
|
||||
// constant — otherwise at low zoom the (world-space) gap collapses while
|
||||
// the screen-constant sectionhead below it doesn't, and the title reads as
|
||||
// belonging to the section above. paddingBottom below is just enough for
|
||||
// the 24px artboard-header (abs-positioned above each card) plus ~8px, so
|
||||
// the title sits tight against its own row at every zoom.
|
||||
return (
|
||||
<div data-dc-section={sid}
|
||||
style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
|
||||
<div style={{ padding: '0 60px' }}>
|
||||
<div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
|
||||
<DCEditable tag="div" value={sec.title ?? title}
|
||||
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
|
||||
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
|
||||
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
|
||||
{order.map((k) => (
|
||||
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
|
||||
label={(sec.labels || {})[k] ?? byId[k].props.label}
|
||||
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
|
||||
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
|
||||
onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
|
||||
hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
|
||||
srcKey,
|
||||
}))}
|
||||
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
|
||||
))}
|
||||
</div>
|
||||
{rest}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
|
||||
function DCArtboard() { return null; }
|
||||
|
||||
// Per-artboard export (kind: 'png' | 'html'). Both paths share the same
|
||||
// self-contained clone: computed styles baked in, @font-face / <img> /
|
||||
// inline-style background-image urls inlined as data URIs. PNG wraps the
|
||||
// clone in foreignObject→canvas at 3× the artboard's natural width×height
|
||||
// (same pipeline the host uses for page captures); HTML wraps it in a
|
||||
// minimal standalone document. Both are independent of viewport zoom.
|
||||
async function dcExport(node, w, h, name, kind) {
|
||||
try { await document.fonts.ready; } catch {}
|
||||
const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => {
|
||||
const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b);
|
||||
})).catch(() => url);
|
||||
|
||||
// Collect @font-face rules. ss.cssRules throws SecurityError on
|
||||
// cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch
|
||||
// the CSS text directly (those endpoints send ACAO:*) and regex-extract
|
||||
// the blocks. @import and @media/@supports are walked so nested
|
||||
// @font-face rules aren't missed.
|
||||
const fontRules = [], pending = [], seen = new Set();
|
||||
const scrapeCss = (href) => {
|
||||
if (seen.has(href)) return; seen.add(href);
|
||||
pending.push(fetch(href).then((r) => r.text()).then((css) => {
|
||||
for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href });
|
||||
for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
|
||||
scrapeCss(new URL(m[1], href).href);
|
||||
}).catch(() => {}));
|
||||
};
|
||||
const walk = (rules, base) => {
|
||||
for (const r of rules) {
|
||||
if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base });
|
||||
else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
|
||||
const ibase = r.styleSheet.href || base;
|
||||
try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); }
|
||||
} else if (r.cssRules) walk(r.cssRules, base);
|
||||
}
|
||||
};
|
||||
for (const ss of document.styleSheets) {
|
||||
const base = ss.href || location.href;
|
||||
try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); }
|
||||
}
|
||||
while (pending.length) await pending.shift();
|
||||
const fontCss = (await Promise.all(fontRules.map(async (rule) => {
|
||||
let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g;
|
||||
while ((m = re.exec(rule.css))) {
|
||||
if (m[2].indexOf('data:') === 0) continue;
|
||||
let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; }
|
||||
out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")');
|
||||
}
|
||||
return out;
|
||||
}))).join('\n');
|
||||
|
||||
const cloneStyled = (src) => {
|
||||
if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode('');
|
||||
const dst = src.cloneNode(false);
|
||||
if (src.nodeType === 1) {
|
||||
const cs = getComputedStyle(src); let txt = '';
|
||||
for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
|
||||
dst.setAttribute('style', txt + 'animation:none;transition:none;');
|
||||
if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {}
|
||||
}
|
||||
for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
|
||||
return dst;
|
||||
};
|
||||
const clone = cloneStyled(node);
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
|
||||
// Drop the card's own shadow/radius so the export is a flush w×h rect;
|
||||
// the artboard's own background (if any) is already in the computed style.
|
||||
clone.style.boxShadow = 'none'; clone.style.borderRadius = '0';
|
||||
|
||||
const jobs = [];
|
||||
clone.querySelectorAll('img').forEach((el) => {
|
||||
const s = el.getAttribute('src');
|
||||
if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d)));
|
||||
});
|
||||
[clone, ...clone.querySelectorAll('*')].forEach((el) => {
|
||||
const bg = el.style.backgroundImage; if (!bg) return;
|
||||
let m; const re = /url\(["']?([^"')]+)["']?\)/g;
|
||||
while ((m = re.exec(bg))) {
|
||||
const tok = m[0], url = m[1];
|
||||
if (url.indexOf('data:') === 0) continue;
|
||||
jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); }));
|
||||
}
|
||||
});
|
||||
await Promise.all(jobs);
|
||||
|
||||
const xml = new XMLSerializer().serializeToString(clone);
|
||||
const save = (blob, ext) => {
|
||||
if (!blob) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
};
|
||||
|
||||
if (kind === 'html') {
|
||||
const html = '<!doctype html><html><head><meta charset="utf-8"><title>' + name + '</title>' +
|
||||
(fontCss ? '<style>' + fontCss + '</style>' : '') +
|
||||
'</head><body style="margin:0">' + xml + '</body></html>';
|
||||
return save(new Blob([html], { type: 'text/html' }), 'html');
|
||||
}
|
||||
|
||||
// PNG: the SVG's own width/height must be the output resolution — an
|
||||
// <img>-loaded SVG rasterizes at its intrinsic size, so sizing it at 1×
|
||||
// and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the
|
||||
// w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders
|
||||
// the HTML at full resolution.
|
||||
const px = 3;
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w * px + '" height="' + h * px +
|
||||
'" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject width="' + w + '" height="' + h + '">' +
|
||||
(fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') + xml + '</foreignObject></svg>';
|
||||
const img = new Image();
|
||||
await new Promise((res, rej) => {
|
||||
img.onload = res; img.onerror = () => rej(new Error('svg load failed'));
|
||||
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||||
});
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = w * px; cv.height = h * px;
|
||||
cv.getContext('2d').drawImage(img, 0, 0);
|
||||
cv.toBlob((blob) => save(blob, 'png'), 'image/png');
|
||||
}
|
||||
|
||||
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
|
||||
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
|
||||
const id = rawId ?? rawLabel;
|
||||
const ref = React.useRef(null);
|
||||
const cardRef = React.useRef(null);
|
||||
const menuRef = React.useRef(null);
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const [confirming, setConfirming] = React.useState(false);
|
||||
|
||||
// ⋯ menu: close on any outside pointerdown. Two-click delete lives inside
|
||||
// the menu — first click arms the row, second commits; closing disarms.
|
||||
React.useEffect(() => {
|
||||
if (!menuOpen) { setConfirming(false); return; }
|
||||
const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); };
|
||||
document.addEventListener('pointerdown', off, true);
|
||||
return () => document.removeEventListener('pointerdown', off, true);
|
||||
}, [menuOpen]);
|
||||
|
||||
const doExport = (kind) => {
|
||||
setMenuOpen(false);
|
||||
if (!cardRef.current) return;
|
||||
const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_');
|
||||
dcExport(cardRef.current, width, height, name, kind)
|
||||
.catch((e) => console.error('[design-canvas] export failed:', e));
|
||||
};
|
||||
|
||||
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
|
||||
// their would-be slots in real time via transforms. DOM order only
|
||||
// changes on drop.
|
||||
const onGripDown = (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const me = ref.current;
|
||||
// translateX is applied in local (pre-scale) space but pointer deltas and
|
||||
// getBoundingClientRect().left are screen-space — divide by the viewport's
|
||||
// current scale so the dragged card tracks the cursor at any zoom level.
|
||||
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
|
||||
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
|
||||
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
|
||||
const slotXs = homes.map((h) => h.x);
|
||||
const startIdx = order.indexOf(id);
|
||||
const startX = e.clientX;
|
||||
let liveOrder = order.slice();
|
||||
me.classList.add('dc-dragging');
|
||||
|
||||
const layout = () => {
|
||||
for (const h of homes) {
|
||||
if (h.id === id) continue;
|
||||
const slot = liveOrder.indexOf(h.id);
|
||||
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
|
||||
}
|
||||
};
|
||||
|
||||
const move = (ev) => {
|
||||
const dx = ev.clientX - startX;
|
||||
me.style.transform = `translateX(${dx / scale}px)`;
|
||||
const cur = homes[startIdx].x + dx;
|
||||
let nearest = 0, best = Infinity;
|
||||
for (let i = 0; i < slotXs.length; i++) {
|
||||
const d = Math.abs(slotXs[i] - cur);
|
||||
if (d < best) { best = d; nearest = i; }
|
||||
}
|
||||
if (liveOrder.indexOf(id) !== nearest) {
|
||||
liveOrder = order.filter((k) => k !== id);
|
||||
liveOrder.splice(nearest, 0, id);
|
||||
layout();
|
||||
}
|
||||
};
|
||||
|
||||
const up = () => {
|
||||
document.removeEventListener('pointermove', move);
|
||||
document.removeEventListener('pointerup', up);
|
||||
const finalSlot = liveOrder.indexOf(id);
|
||||
me.classList.remove('dc-dragging');
|
||||
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
|
||||
// After the settle transition, kill transitions + clear transforms +
|
||||
// commit the reorder in the same frame so there's no visual snap-back.
|
||||
setTimeout(() => {
|
||||
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
|
||||
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
for (const h of homes) h.el.style.transition = '';
|
||||
}));
|
||||
}, 180);
|
||||
};
|
||||
document.addEventListener('pointermove', move);
|
||||
document.addEventListener('pointerup', up);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<div className="dc-header" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<div className="dc-labelrow">
|
||||
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
|
||||
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
|
||||
</div>
|
||||
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
|
||||
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
|
||||
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="dc-btns">
|
||||
<div ref={menuRef} style={{ position: 'relative' }}>
|
||||
<button className="dc-kebab" title="More" onClick={() => setMenuOpen((o) => !o)}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><circle cx="2.5" cy="6" r="1.1"/><circle cx="6" cy="6" r="1.1"/><circle cx="9.5" cy="6" r="1.1"/></svg>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="dc-menu" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => doExport('png')}>Download PNG</button>
|
||||
<button onClick={() => doExport('html')}>Download HTML</button>
|
||||
<hr />
|
||||
<button className="dc-danger"
|
||||
onClick={() => { if (confirming) { setMenuOpen(false); onDelete(); } else setConfirming(true); }}>
|
||||
{confirming ? 'Click again to delete' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="dc-expand" onClick={onFocus} title="Focus">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={cardRef} className="dc-card"
|
||||
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline rename — commits on blur or Enter.
|
||||
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
|
||||
const T = tag;
|
||||
return (
|
||||
<T className="dc-editable" contentEditable suppressContentEditableWarning
|
||||
onClick={onClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
|
||||
style={style}>{value}</T>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
|
||||
// sections, Esc or backdrop click to exit.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
|
||||
const ctx = React.useContext(DCCtx);
|
||||
const { sectionId, artboard } = entry;
|
||||
const sec = ctx.section(sectionId);
|
||||
const meta = sectionMeta[sectionId];
|
||||
const peers = meta.slotIds;
|
||||
const aid = artboard.props.id ?? artboard.props.label;
|
||||
const idx = peers.indexOf(aid);
|
||||
const secIdx = sectionOrder.indexOf(sectionId);
|
||||
|
||||
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
|
||||
const goSection = (d) => {
|
||||
// Sections whose artboards are all deleted have slotIds:[] — step past
|
||||
// them to the next non-empty section so ↑/↓ doesn't dead-end.
|
||||
const n = sectionOrder.length;
|
||||
for (let i = 1; i < n; i++) {
|
||||
const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
|
||||
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
|
||||
if (first) { ctx.setFocus(`${ns}/${first}`); return; }
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const k = (e) => {
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
|
||||
};
|
||||
document.addEventListener('keydown', k);
|
||||
return () => document.removeEventListener('keydown', k);
|
||||
});
|
||||
|
||||
const { width = 260, height = 480, children } = artboard.props;
|
||||
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
|
||||
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
|
||||
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
|
||||
|
||||
const [ddOpen, setDd] = React.useState(false);
|
||||
const Arrow = ({ dir, onClick }) => (
|
||||
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
|
||||
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
|
||||
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
|
||||
</button>
|
||||
);
|
||||
|
||||
// Portal to body so position:fixed is the real viewport regardless of any
|
||||
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
|
||||
return ReactDOM.createPortal(
|
||||
<div onClick={() => ctx.setFocus(null)}
|
||||
onWheel={(e) => e.preventDefault()}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
|
||||
fontFamily: DC.font, color: '#fff' }}>
|
||||
|
||||
{/* top bar: section dropdown (left) · close (right) */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button onClick={() => setDd((o) => !o)}
|
||||
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
|
||||
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
|
||||
</span>
|
||||
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
|
||||
</button>
|
||||
{ddOpen && (
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
|
||||
{sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
|
||||
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
|
||||
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
|
||||
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
|
||||
{sectionMeta[sid].title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={() => ctx.setFocus(null)}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
|
||||
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
|
||||
</div>
|
||||
|
||||
{/* card centered, label + index below — only the card itself stops
|
||||
propagation so any backdrop click (including the margins around
|
||||
the card) exits focus */}
|
||||
<div
|
||||
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
|
||||
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
|
||||
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
|
||||
{(sec.labels || {})[aid] ?? artboard.props.label}
|
||||
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Arrow dir="left" onClick={() => go(-1)} />
|
||||
<Arrow dir="right" onClick={() => go(1)} />
|
||||
|
||||
{/* dots */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
|
||||
{peers.map((p, i) => (
|
||||
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
|
||||
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
|
||||
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Post-it — absolute-positioned sticky note
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', top, left, right, bottom, width,
|
||||
background: DC.postitBg, padding: '14px 16px',
|
||||
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
|
||||
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
|
||||
transform: `rotate(${rotate}deg)`,
|
||||
zIndex: 5,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 & 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 & 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 & 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;
|
||||
@@ -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;
|
||||
@@ -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> & <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 & 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;
|
||||
@@ -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'}}>Bē</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;
|
||||
@@ -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 |
Reference in New Issue
Block a user