diff --git a/docs/design/v2/Drover-Go Fluent.html b/docs/design/v2/Drover-Go Fluent.html
new file mode 100644
index 0000000..a90bcd9
--- /dev/null
+++ b/docs/design/v2/Drover-Go Fluent.html
@@ -0,0 +1,51 @@
+
+
+
+
+Drover-Go · Fluent
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/design/v2/Drover-Go GUI (standalone).html b/docs/design/v2/Drover-Go GUI (standalone).html
new file mode 100644
index 0000000..e52e2fa
--- /dev/null
+++ b/docs/design/v2/Drover-Go GUI (standalone).html
@@ -0,0 +1,184 @@
+
+
+
+
+ Drover-Go — Desktop GUI explorations
+
+
+
+
+ This page requires JavaScript to display.
+
+
+
+
+
+
+
+
+
+
+
+
+ DROVER-GO
+
+
+ Unpacking...
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/design/v2/Drover-Go GUI.html b/docs/design/v2/Drover-Go GUI.html
new file mode 100644
index 0000000..561eba6
--- /dev/null
+++ b/docs/design/v2/Drover-Go GUI.html
@@ -0,0 +1,249 @@
+
+
+
+
+Drover-Go — Desktop GUI explorations
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ DROVER-GO
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/design/v2/Drover-Go shortlist.html b/docs/design/v2/Drover-Go shortlist.html
new file mode 100644
index 0000000..d8fe13b
--- /dev/null
+++ b/docs/design/v2/Drover-Go shortlist.html
@@ -0,0 +1,103 @@
+
+
+
+
+Drover-Go — Shortlist
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/design/v2/Drover-Go sketches.html b/docs/design/v2/Drover-Go sketches.html
new file mode 100644
index 0000000..ea6373d
--- /dev/null
+++ b/docs/design/v2/Drover-Go sketches.html
@@ -0,0 +1,27 @@
+
+
+
+
+Drover-Go · Style sketches
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/design/v2/Drover-Go v2.html b/docs/design/v2/Drover-Go v2.html
new file mode 100644
index 0000000..db7c232
--- /dev/null
+++ b/docs/design/v2/Drover-Go v2.html
@@ -0,0 +1,156 @@
+
+
+
+
+Drover-Go — round 2 (dark only)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/design/v2/design-canvas.jsx b/docs/design/v2/design-canvas.jsx
new file mode 100644
index 0000000..9f3fc61
--- /dev/null
+++ b/docs/design/v2/design-canvas.jsx
@@ -0,0 +1,622 @@
+
+// DesignCanvas.jsx — Figma-ish design canvas wrapper
+// Warm gray grid bg + Sections + Artboards + PostIt notes.
+// Artboards are reorderable (grip-drag), labels/titles are inline-editable,
+// and any artboard can be opened in a fullscreen focus overlay (←/→/Esc).
+// State persists to a .design-canvas.state.json sidecar via the host
+// bridge. No assets, no deps.
+//
+// Usage:
+//
+//
+// …
+// …
+//
+//
+
+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)}',
+ '.dc-card{transition:box-shadow .15s,transform .15s}',
+ '.dc-card *{scrollbar-width:none}',
+ '.dc-card *::-webkit-scrollbar{display:none}',
+ '.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px}',
+ '.dc-grip{cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s}',
+ '.dc-grip:hover{background:rgba(0,0,0,.08)}',
+ '.dc-grip:active{cursor:grabbing}',
+ '.dc-labeltext{cursor:pointer;border-radius:4px;padding:3px 6px;display:flex;align-items:center;transition:background .12s}',
+ '.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
+ '.dc-expand{position:absolute;bottom:100%;right:0;margin-bottom:5px;z-index:2;opacity:0;transition:opacity .12s,background .12s;',
+ ' width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
+ ' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center}',
+ '.dc-expand:hover{background:rgba(0,0,0,.06);color:#2a251f}',
+ '[data-dc-slot]:hover .dc-expand{opacity: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, focused
+// artboard). Order/titles/labels 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 srcIds = [];
+ React.Children.forEach(sec.props.children, (ab) => {
+ if (!ab || ab.type !== DCArtboard) return;
+ const aid = ab.props.id ?? ab.props.label;
+ if (!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 (
+
+ {ready && children}
+ {state.focus && registry[state.focus] && (
+
+ )}
+
+ );
+}
+
+// ─────────────────────────────────────────────────────────────
+// 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 });
+
+ const apply = React.useCallback(() => {
+ const { x, y, scale } = tf.current;
+ const el = worldRef.current;
+ if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
+ }, []);
+
+ 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) {
+ // trackpad pinch (or explicit ctrl+wheel)
+ 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 = '';
+ };
+
+ 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 () => {
+ 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 (
+
+ );
+}
+
+// ─────────────────────────────────────────────────────────────
+// 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 srcOrder = artboards.map((a) => a.props.id ?? a.props.label);
+ const sec = (ctx && sid && ctx.section(sid)) || {};
+
+ 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]));
+
+ return (
+
+
+
ctx && sid && ctx.patchSection(sid, { title: v })}
+ style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
+ {subtitle && {subtitle}
}
+
+
+ {order.map((k) => (
+ ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
+ onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
+ onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
+ ))}
+
+ {rest}
+
+ );
+}
+
+// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
+function DCArtboard() { return null; }
+
+function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus }) {
+ const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
+ const id = rawId ?? rawLabel;
+ const ref = React.useRef(null);
+
+ // 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 (
+
+
+
+
+
+
+ e.stopPropagation()}
+ style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
+
+
+
e.stopPropagation()} title="Focus">
+
+
+
+
+ );
+}
+
+// Inline rename — commits on blur or Enter.
+function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
+ const T = tag;
+ return (
+ e.stopPropagation()}
+ onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
+ style={style}>{value}
+ );
+}
+
+// ─────────────────────────────────────────────────────────────
+// 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) => {
+ const ns = sectionOrder[(secIdx + d + sectionOrder.length) % sectionOrder.length];
+ const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
+ if (first) ctx.setFocus(`${ns}/${first}`);
+ };
+
+ 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 }) => (
+ { 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)')}>
+
+
+
+ );
+
+ // 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(
+ 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) */}
+
e.stopPropagation()}
+ style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
+
+
setDd((o) => !o)}
+ style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
+ borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
+
+ {meta.title}
+
+
+ {meta.subtitle && {meta.subtitle} }
+
+ {ddOpen && (
+
+ {sectionOrder.map((sid) => (
+ { 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}
+
+ ))}
+
+ )}
+
+
+
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' }}>×
+
+
+ {/* card centered, label + index below — only the card itself stops
+ propagation so any backdrop click (including the margins around
+ the card) exits focus */}
+
+
e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
+
+
+
e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
+ {(sec.labels || {})[aid] ?? artboard.props.label}
+ {idx + 1} / {peers.length}
+
+
+
+
go(-1)} />
+ go(1)} />
+
+ {/* dots */}
+ e.stopPropagation()}
+ style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
+ {peers.map((p, i) => (
+ 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)' }} />
+ ))}
+
+ ,
+ document.body,
+ );
+}
+
+// ─────────────────────────────────────────────────────────────
+// Post-it — absolute-positioned sticky note
+// ─────────────────────────────────────────────────────────────
+function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
+ return (
+ {children}
+ );
+}
+
+Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
+
diff --git a/docs/design/v2/drover-brutalist.jsx b/docs/design/v2/drover-brutalist.jsx
new file mode 100644
index 0000000..7ed242c
--- /dev/null
+++ b/docs/design/v2/drover-brutalist.jsx
@@ -0,0 +1,335 @@
+// drover-brutalist.jsx — Variant 4: Brutalist.
+// Hard borders, no rounding. Heavy mono. Monochrome + one acid lime accent.
+// Block-style "DOS-meets-zine" feeling. All-caps labels.
+
+const BrutTheme = {
+ d: {
+ bg: '#0c0c0c', panel: '#0c0c0c', alt: '#161616',
+ border: '#f4f4f4', borderDim: '#3a3a3a',
+ text: '#f4f4f4', dim: '#a8a8a8', dimmer: '#6a6a6a',
+ accent: '#c6ff00', danger: '#ff3a52', warn: '#ffaa00', pass: '#7eff84', skip: '#888',
+ inputBg: '#0c0c0c', primaryFg: '#0c0c0c',
+ },
+ l: {
+ bg: '#f4f1ea', panel: '#f4f1ea', alt: '#e9e5d8',
+ border: '#0c0c0c', borderDim: '#bfb9a8',
+ text: '#0c0c0c', dim: '#3a3a3a', dimmer: '#7a7568',
+ accent: '#7d8f00', danger: '#b81e34', warn: '#a86c00', pass: '#1a7e2c', skip: '#7a7568',
+ inputBg: '#f4f1ea', primaryFg: '#f4f1ea',
+ },
+};
+
+function BrutWindow({ mode = 'dark', initial }) {
+ const t = BrutTheme[mode === 'dark' ? 'd' : 'l'];
+ const D = window.useDrover(initial);
+ const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
+ const isActive = D.phase === 'active';
+ const fontMono = '"JetBrains Mono","IBM Plex Mono","Space Mono",ui-monospace,monospace';
+
+ return (
+
+
+
+
+ {/* form */}
+
+
// SOCKS5 PROXY
+
+
+ D.update({ host: e.target.value })}
+ placeholder="95.165.72.59 / example.com"
+ onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={brutInput(t, fontMono)}/>
+
+
+ D.update({ port: e.target.value.replace(/\D/g,'') })}
+ placeholder="12334" inputMode="numeric"
+ onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={brutInput(t, fontMono)}/>
+
+
+
+
{ D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('br-login')?.focus(),30); }}>
+ AUTHENTICATION
+
+
+
+ D.update({ login: e.target.value })} placeholder="user"
+ onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={brutInput(t, fontMono, !D.form.auth)}/>
+
+
+ D.update({ password: e.target.value })} placeholder="******"
+ onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={brutInput(t, fontMono, !D.form.auth)}/>
+
+
+
+
+ {D.phase === 'checking' ? '>> CHECKING…' : '>> CHECK CONNECTION'}
+
+
+
+ {/* status */}
+
+ // STATUS
+
+
+
+ {/* actions */}
+
+
+
+
+
+ {isActive &&
}
+
+
+
+
+
+ );
+}
+
+function BrutTitle({ t }) {
+ const cell = { width: 36, height: 28, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer',
+ borderLeft: `2px solid ${t.border}`, color: t.text };
+ return (
+
+
+ D
+ DROVER-GO
+ [v0.4.2]
+
+
+
+ );
+}
+function BrutLabel({ t, children }) {
+ return {children}
;
+}
+function BField({ t, label, children, style }) {
+ return
+ {label} {children}
+ ;
+}
+function brutInput(t, fontMono, disabled) {
+ return {
+ background: t.inputBg, color: disabled ? t.dimmer : t.text,
+ border: `2px solid ${disabled ? t.borderDim : t.border}`, borderRadius: 0,
+ padding: '7px 9px', fontSize: 12, fontFamily: fontMono, outline:'none',
+ width:'100%', boxSizing:'border-box',
+ };
+}
+function BrutCheckbox({ t, checked, onChange, children }) {
+ return (
+
+
+ {checked && × }
+
+ onChange(e.target.checked)} style={{ display:'none' }}/>
+ {children}
+
+ );
+}
+function BrutPrimary({ t, onClick, disabled, children, style }) {
+ return (
+ { if (!disabled) { e.currentTarget.style.transform='translate(2px,2px)'; e.currentTarget.style.boxShadow=`2px 2px 0 ${t.border}`; }}}
+ onMouseUp={e => { if (!disabled) { e.currentTarget.style.transform=''; e.currentTarget.style.boxShadow=`4px 4px 0 ${t.border}`; }}}
+ onMouseLeave={e => { if (!disabled) { e.currentTarget.style.transform=''; e.currentTarget.style.boxShadow=`4px 4px 0 ${t.border}`; }}}
+ >{children}
+ );
+}
+function BrutStatus({ t, D, palette }) {
+ if (D.phase === 'idle') {
+ return (
+
+
+ READY TO CHECK_
+
+ );
+ }
+ return (
+
+ {D.phase === 'checking'
+ ?
+
+ RUNNING DIAGNOSTICS…
+
+ :
+ {D.lastSummary?.failed === 0
+ ? '▮ ALL CHECKS PASSED. READY TO START.'
+ : `▮ ${D.lastSummary?.failed}/${D.tests.length} FAILED · SOME FEATURES BROKEN`}
+
}
+
+ {D.tests.map((test, i) => {
+ const r = D.results[test.id];
+ const state = r?.result || (D.running === test.id ? 'running' : 'pending');
+ const last = i === D.tests.length - 1;
+ return (
+
+
+
+
+ {test.label}
+
+
+ {r?.metric || (state==='running'?'…':'')}
+
+ {r?.result === 'failed' && (
+ D.toggleExpand(test.id)} style={{
+ background:'transparent', border:'none', cursor:'pointer', padding: 4, color: t.text,
+ }}>
+ )}
+
+ {r?.result === 'failed' && r.expanded && (
+
+
! {r.error}
+
{r.hint}
+
navigator.clipboard?.writeText(`[${test.label}] ${r.error}`)}
+ style={{
+ marginTop: 8, background: 'transparent', border: `1.5px solid ${t.border}`,
+ color: t.text, padding: '3px 8px', fontSize: 10, cursor:'pointer',
+ fontFamily: 'inherit', letterSpacing: 0.5, fontWeight: 700,
+ }}>[ COPY ERROR ]
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
+function BrutStartBtn({ t, D }) {
+ const allFailed = D.lastSummary && D.lastSummary.failed === D.tests.length;
+ const ok = D.phase === 'checked' && !allFailed;
+ const active = D.phase === 'active';
+ const warning = active && (D.lastSummary?.failed || 0) > 0;
+ if (active) {
+ return (
+
+
+ ●{warning ? ' ACTIVE / UDP-WARN' : ' ACTIVE'}
+
+ );
+ }
+ return {'>> START PROXYING'} ;
+}
+function BrutStopBtn({ t, D }) {
+ const enabled = D.phase === 'active';
+ return (
+ {'■ STOP'}
+ );
+}
+function BrutLiveStats({ t, stats }) {
+ const C = ({ icon, val, lbl }) => (
+
+ {icon}{val}
+ {lbl && {lbl} }
+
+ );
+ return (
+
+ } val={window.fmtBytes(stats.up)}/>
+ } val={window.fmtBytes(stats.down)}/>
+
+
+
+
+ );
+}
+function BrutLogs({ t, D }) {
+ return (
+
+
D.setLogsOpen(!D.logsOpen)} style={{
+ width:'100%', padding: '7px 14px', display:'flex', alignItems:'center', gap: 9,
+ background:'transparent', border:'none', color: t.text, cursor:'pointer',
+ fontSize: 11, fontFamily: 'inherit', fontWeight: 700, letterSpacing: 1.2,
+ }}>
+
+ // LOGS
+ {D.logs.length} LINES
+
+ {D.logsOpen && (
+ <>
+
+ {[['COPY ALL', () => navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n'))],
+ ['CLEAR', D.clearLogs], ['OPEN FILE', null]].map(([l, fn]) => (
+ [ {l} ]
+ ))}
+
+
el && (el.scrollTop = el.scrollHeight)}
+ style={{ maxHeight: 130, overflowY: 'auto', padding: '7px 14px', fontSize: 10.5, lineHeight: 1.6, color: t.text }}>
+ {D.logs.map((l,i) => (
+
+ {window.fmtTime(l.t)} {' '}
+ [{l.level}] {' '}
+ {l.msg}
+
+ ))}
+
+ >
+ )}
+
+ );
+}
+
+window.BrutWindow = BrutWindow;
diff --git a/docs/design/v2/drover-classic.jsx b/docs/design/v2/drover-classic.jsx
new file mode 100644
index 0000000..c94387f
--- /dev/null
+++ b/docs/design/v2/drover-classic.jsx
@@ -0,0 +1,440 @@
+// drover-classic.jsx — Variant 1: Classic devtool.
+// Information-dense. Mono metrics. Plain rectangles, hairline borders.
+// Sober palette: neutral grays + one teal accent for primary action / success.
+
+const ClassicTheme = {
+ // dark
+ d: {
+ bg: '#1c1d20',
+ chrome: '#15161a',
+ panel: '#22242a',
+ panelAlt: '#1a1c20',
+ border: '#34373d',
+ borderSoft:'#2a2c32',
+ text: '#dde0e6',
+ dim: '#8a8f99',
+ dimmer: '#5b6068',
+ accent: '#3ea99f', // teal
+ accentDim: '#2a7d76',
+ danger: '#d96565',
+ warn: '#d9a155',
+ pass: '#5cba8b',
+ skip: '#7c8088',
+ inputBg: '#15161a',
+ btnBg: '#2c2f36',
+ btnBgH: '#373b43',
+ primaryBg: '#3ea99f',
+ primaryFg: '#0c1a18',
+ },
+ l: {
+ bg: '#f3f4f6',
+ chrome: '#e8eaef',
+ panel: '#ffffff',
+ panelAlt: '#f8f9fb',
+ border: '#d8dbe1',
+ borderSoft:'#e6e8ec',
+ text: '#1c1f24',
+ dim: '#5c6168',
+ dimmer: '#8a8f97',
+ accent: '#2a7d76',
+ accentDim: '#bdded9',
+ danger: '#c0463f',
+ warn: '#a8731e',
+ pass: '#2f8c5a',
+ skip: '#7c8088',
+ inputBg: '#ffffff',
+ btnBg: '#ffffff',
+ btnBgH: '#f1f2f5',
+ primaryBg: '#2a7d76',
+ primaryFg: '#ffffff',
+ },
+};
+
+function ClassicWindow({ mode = 'dark', initial }) {
+ const t = ClassicTheme[mode === 'dark' ? 'd' : 'l'];
+ const D = window.useDrover(initial);
+ const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
+ const fontMono = '"JetBrains Mono","SF Mono",ui-monospace,Menlo,Consolas,monospace';
+ const fontUI = '"Inter","Segoe UI",system-ui,sans-serif';
+ const isActive = D.phase === 'active';
+ const allChecked = D.phase === 'checked' || D.phase === 'active';
+ const failed = D.lastSummary?.failed ?? 0;
+
+ return (
+
+ {/* ─── title bar ─── */}
+
+
+ {/* ─── content ─── */}
+
+
+ {/* Form */}
+
SOCKS5 Proxy
+
+
+ D.update({ host: e.target.value })}
+ placeholder="95.165.72.59 или example.com"
+ onKeyDown={e => e.key === 'Enter' && D.runCheck()}
+ style={inputStyle(t, fontMono)} />
+
+
+ D.update({ port: e.target.value.replace(/\D/g,'') })}
+ placeholder="12334" inputMode="numeric"
+ onKeyDown={e => e.key === 'Enter' && D.runCheck()}
+ style={inputStyle(t, fontMono)} />
+
+
+
+
{ D.update({ auth: v }); if (v) setTimeout(() => document.getElementById('cls-login')?.focus(), 30); }}>
+ Authentication
+
+
+
+
+ D.update({ login: e.target.value })} placeholder="user"
+ onKeyDown={e => e.key === 'Enter' && D.runCheck()}
+ style={inputStyle(t, fontMono, !D.form.auth)} />
+
+
+ D.update({ password: e.target.value })} placeholder="••••••"
+ onKeyDown={e => e.key === 'Enter' && D.runCheck()}
+ style={inputStyle(t, fontMono, !D.form.auth)} />
+
+
+
+
+ {D.phase === 'checking' ? 'Checking…' : 'Check connection'}
+
+
+ {/* Status */}
+
+
Status
+
+
+ {/* Action buttons */}
+
+
+
+
+
+
+ {isActive &&
}
+
+
+
+
+ {/* Logs collapsible */}
+
+
+ );
+}
+
+// ─── pieces ─────────────────────────────────────────────────────────────────
+function ClassicTitleBar({ t }) {
+ const cellStyle = {
+ width: 38, height: 28, display:'flex', alignItems:'center', justifyContent:'center',
+ color: t.dim, cursor:'pointer',
+ };
+ return (
+
+
+
+ Drover-Go
+ v0.4.2
+
+
+
+
+
e.currentTarget.style.background = '#c0463f'}
+ onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
+
+
+
+
+ );
+}
+
+function SectionLabel({ children, t }) {
+ return {children}
;
+}
+
+function Field({ children, label, t, style }) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+
+function inputStyle(t, fontMono, disabled) {
+ return {
+ background: t.inputBg, color: disabled ? t.dimmer : t.text,
+ border: `1px solid ${t.border}`, borderRadius: 3, padding: '7px 9px',
+ fontFamily: fontMono, fontSize: 12, outline: 'none', width: '100%', boxSizing: 'border-box',
+ transition: 'border-color .12s, box-shadow .12s',
+ };
+}
+
+function Checkbox({ checked, onChange, children, t }) {
+ return (
+
+
+ {checked && }
+
+ onChange(e.target.checked)} style={{ display:'none' }}/>
+ {children}
+
+ );
+}
+
+function PrimaryBtn({ t, onClick, disabled, children, style }) {
+ return (
+
+ {children}
+
+ );
+}
+
+// ─── status panel ──────────────────────────────────────────────────────────
+function ClassicStatus({ t, D, palette, fontMono }) {
+ const idle = D.phase === 'idle';
+ if (idle) {
+ return (
+
+
+ Ready to check
+
+ );
+ }
+ return (
+
+ {/* header */}
+
+ {D.phase === 'checking'
+ ? <>
+
+ Running diagnostics…
+
+ {Object.keys(D.results).length}/{D.tests.length}
+
+ >
+ : D.lastSummary?.failed === 0
+ ? All checks passed. Ready to start.
+ : {D.lastSummary?.failed} of {D.tests.length} checks failed. Some features won't work. }
+
+ {/* tests */}
+
+ {D.tests.map((test, i) => {
+ const r = D.results[test.id];
+ const state = r?.result || (D.running === test.id ? 'running' : 'pending');
+ const isLast = i === D.tests.length - 1;
+ return (
+
+
+
+
+ {test.label}
+
+
+ {r?.metric || (state === 'running' ? '...' : '')}
+
+ {r?.result === 'failed' && (
+ D.toggleExpand(test.id)} style={iconBtnStyle(t)} title="Подробнее">
+
+
+ )}
+
+ {r?.result === 'failed' && r.expanded && (
+
+
{r.error}
+
{r.hint}
+
+ navigator.clipboard?.writeText(`[${test.label}] ${r.error} — ${r.metric}`)}
+ style={smallBtn(t, fontMono)}>
+ copy
+
+
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
+
+function iconBtnStyle(t) {
+ return {
+ width: 20, height: 20, padding: 0, border:'none', background:'transparent',
+ cursor:'pointer', display:'inline-flex', alignItems:'center', justifyContent:'center',
+ borderRadius: 2,
+ };
+}
+function smallBtn(t, fontMono) {
+ return {
+ display:'inline-flex', alignItems:'center', gap: 4, padding: '3px 7px',
+ background: t.btnBg, border: `1px solid ${t.border}`, color: t.dim,
+ borderRadius: 3, fontFamily: fontMono, fontSize: 10.5, cursor:'pointer',
+ };
+}
+
+// crude color mix for dark/light. expects hex (#rrggbb), bg can be hex too. amount=share-of-bg.
+function mode_mix(fg, bg, amt) {
+ const a = hexToRgb(fg), b = hexToRgb(bg);
+ return `rgb(${Math.round(a.r*(1-amt)+b.r*amt)},${Math.round(a.g*(1-amt)+b.g*amt)},${Math.round(a.b*(1-amt)+b.b*amt)})`;
+}
+function hexToRgb(h) {
+ const v = h.replace('#','');
+ return { r: parseInt(v.slice(0,2),16), g: parseInt(v.slice(2,4),16), b: parseInt(v.slice(4,6),16) };
+}
+
+// ─── start/stop ────────────────────────────────────────────────────────────
+function ClassicStartBtn({ t, D, fontMono }) {
+ const phase = D.phase;
+ const summary = D.lastSummary;
+ const allFailed = summary && summary.failed === D.tests.length;
+ const checkedOk = phase === 'checked' && !allFailed;
+ const active = phase === 'active';
+ const warning = active && (summary?.failed || 0) > 0;
+
+ if (active) {
+ return (
+
+
+ Active{warning ? ' · UDP fallback' : ''}
+
+ );
+ }
+ return (
+
+ Start proxying
+
+ );
+}
+
+function ClassicStopBtn({ t, D }) {
+ const enabled = D.phase === 'active';
+ return (
+
+ Stop
+
+ );
+}
+
+function ClassicLiveStats({ t, stats, fontMono }) {
+ const cell = (icon, val) => (
+
+ {icon}{val}
+
+ );
+ return (
+
+ {cell(, window.fmtBytes(stats.up))}
+ {cell(, window.fmtBytes(stats.down))}
+ {cell(TCP , stats.tcp)}
+ {cell(UDP , stats.udp)}
+ {cell(↑t , window.fmtUptime(stats.uptimeS))}
+
+ );
+}
+
+// ─── logs ──────────────────────────────────────────────────────────────────
+function ClassicLogs({ t, D, fontMono }) {
+ return (
+
+
D.setLogsOpen(!D.logsOpen)} style={{
+ width:'100%', padding: '8px 14px', display:'flex', alignItems:'center', gap:8,
+ background:'transparent', border:'none', color: t.dim, cursor:'pointer',
+ fontSize: 11, fontFamily: fontMono, letterSpacing: 0.3,
+ }}>
+
+ Logs
+ {D.logs.length} lines
+
+ {D.logsOpen && (
+
+
+ navigator.clipboard?.writeText(D.logs.map(l => `[${l.level}] ${l.msg}`).join('\n'))}>copy all
+ clear
+ open log file
+
+
el && (el.scrollTop = el.scrollHeight)}>
+ {D.logs.map((l, i) => (
+
+ {window.fmtTime(l.t)}
+ {' '}
+ [{l.level}]
+ {' '}
+ {l.msg}
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+window.ClassicWindow = ClassicWindow;
diff --git a/docs/design/v2/drover-cli.jsx b/docs/design/v2/drover-cli.jsx
new file mode 100644
index 0000000..b73436d
--- /dev/null
+++ b/docs/design/v2/drover-cli.jsx
@@ -0,0 +1,263 @@
+// drover-cli.jsx — Cyber CLI: terminal aesthetic, phosphor green, ASCII feel.
+// All-mono. No round corners. Subtle CRT scanline overlay.
+
+const CLI = {
+ bg: '#0a0d0a', chrome: '#070a07', panel: '#0d110d', panel2: '#111511',
+ border: '#1f2a1f', borderSoft: '#162016',
+ text: '#bfe7c0', // phosphor green-white
+ dim: '#5a8a5e', dimmer: '#3a5a3d',
+ accent: '#5cf08a', accentDim: '#2a8a44',
+ danger: '#ff6b6b', warn: '#ffd166', pass: '#5cf08a', skip: '#6a8a6e',
+ inputBg: '#0a0e0a',
+};
+
+function CliWindow({ initial }) {
+ const t = CLI;
+ const D = window.useDrover(initial);
+ const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
+ const fontMono = '"JetBrains Mono","IBM Plex Mono","Cascadia Mono",ui-monospace,monospace';
+ const isActive = D.phase === 'active';
+
+ return (
+
+ {/* CRT scanlines */}
+
+ {/* vignette */}
+
+
+ {/* title bar — like a terminal tab */}
+
+
+ $
+ drover-go
+ — ~ — 80×24
+
+
+ {['settings','min','close'].map(k => (
+ {k==='close'?'×':k==='min'?'−':'⚙'}
+ ))}
+
+
+
+
+ {/* prompt-style form */}
+
┌── socks5 proxy ─────────────────────────────────────────
+
+
+ host:
+ D.update({ host: v })}
+ placeholder="95.165.72.59 / example.com" onSubmit={D.runCheck} style={{ flex: 1 }}/>
+ port:
+ D.update({ port: v.replace(/\D/g,'') })}
+ placeholder="12334" onSubmit={D.runCheck} style={{ width: 70 }}/>
+
+
+
+ auth: {' '}
+ {
+ D.update({ auth: !D.form.auth });
+ if (!D.form.auth) setTimeout(()=>document.getElementById('cli-login')?.focus(),30);
+ }}
+ style={{
+ color: D.form.auth ? t.accent : t.dim, fontWeight: 600, cursor:'pointer',
+ borderBottom: `1px dotted ${t.accent}`,
+ }}>{D.form.auth ? '[ on ]' : '[ off ]'}
+
+
+ {D.form.auth && (
+
+ user:
+ D.update({ login: v })}
+ placeholder="login" onSubmit={D.runCheck} style={{ flex: 1 }}/>
+ pass:
+ D.update({ password: v })} type="password"
+ placeholder="••••••" onSubmit={D.runCheck} style={{ flex: 1 }}/>
+
+ )}
+
+
+
+ {D.phase==='checking'
+ ? <>{'>'} running…_ >
+ : <>{'> check_connection --strict'}>}
+
+
+ {/* status */}
+
+
┌── status ─────────────────────────────────────────────────
+ {D.phase === 'idle'
+ ?
+ {'> '} ready to check_
+
+ :
+ {D.phase === 'checking'
+ ?
{'> '} running diagnostics...
+ : (D.lastSummary?.failed === 0
+ ?
[ ✓ ] all checks passed. ready to start.
+ :
[ ! ] {D.lastSummary?.failed}/{D.tests.length} failed. some features won't work.
+ )}
+
+ {D.tests.map(test => {
+ const r = D.results[test.id];
+ const state = r?.result || (D.running === test.id ? 'running' : 'pending');
+ const sym = state==='passed'?'✓':state==='failed'?'✗':state==='skipped'?'~':state==='running'?'›':'·';
+ const c = state==='passed'?t.pass:state==='failed'?t.danger:state==='skipped'?t.skip:state==='running'?t.accent:t.dimmer;
+ return (
+
+
+ {sym}
+ {test.label}
+ {r?.metric || (state==='running'?'…':'')}
+ {r?.result === 'failed' && (
+ D.toggleExpand(test.id)} style={{
+ background:'transparent', border:'none', color: t.dim, cursor:'pointer', padding: 0,
+ }}>{r.expanded?'[−]':'[+]'}
+ )}
+
+ {r?.result === 'failed' && r.expanded && (
+
+
! {r.error}
+
{r.hint}
+
+ )}
+
+ );
+ })}
+
+
+ }
+
+ {/* actions */}
+
+
+ {(() => {
+ const allFailed = D.lastSummary && D.lastSummary.failed === D.tests.length;
+ const ok = D.phase === 'checked' && !allFailed;
+ const warning = isActive && (D.lastSummary?.failed||0) > 0;
+ if (isActive) {
+ const c = warning ? t.warn : t.accent;
+ return (
+
+
+ [ ACTIVE{warning ? ' / UDP-WARN' : ''} ]
+
+ );
+ }
+ return (
+
{'>'} start_proxying
+ );
+ })()}
+
{'■'} stop
+
+ {isActive && (
+
+ ↑ {window.fmtBytes(D.stats.up).padEnd(10)}
+ ↓ {window.fmtBytes(D.stats.down).padEnd(10)}
+ tcp={D.stats.tcp}
+ udp={D.stats.udp}
+ up={window.fmtUptime(D.stats.uptimeS)}
+
+ )}
+
+
+ {/* logs */}
+
+
D.setLogsOpen(!D.logsOpen)} style={{
+ width:'100%', padding:'6px 14px', display:'flex', alignItems:'center', gap: 8,
+ background:'transparent', border:'none', color: t.dim, cursor:'pointer',
+ fontFamily: fontMono, fontSize: 11, letterSpacing: 0.6,
+ }}>
+ {D.logsOpen?'▼':'▶'}
+ tail -f drover.log
+ {D.logs.length} lines
+
+ {D.logsOpen && (
+ <>
+
+ {[['copy', () => navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n'))],
+ ['clear', D.clearLogs], ['file', null]].map(([l, fn]) => (
+ {`[${l}]`}
+ ))}
+
+
el && (el.scrollTop = el.scrollHeight)}
+ style={{ maxHeight: 110, overflowY:'auto', padding:'5px 14px',
+ fontFamily: fontMono, fontSize: 10, lineHeight: 1.55, color: t.dim }}>
+ {D.logs.map((l,i) => (
+
+ {window.fmtTime(l.t)} {' '}
+ {l.level} {' '}
+ {l.msg}
+
+ ))}
+
+ >
+ )}
+
+
+ );
+}
+
+function cliHead(t) { return { color: t.dim, fontSize: 10.5, marginBottom: 2, letterSpacing: 0.3 }; }
+function CliRow({ children, style }) {
+ return {children}
;
+}
+function CliInput({ value, onChange, placeholder, type, onSubmit, style, t, id }) {
+ return onChange(e.target.value)} placeholder={placeholder}
+ onKeyDown={e => e.key === 'Enter' && onSubmit?.()}
+ style={{
+ background: t.inputBg, color: t.accent, border:'none',
+ borderBottom:`1px solid ${t.borderSoft}`, borderRadius: 0,
+ padding:'3px 4px', fontFamily: 'inherit', fontSize: 12,
+ outline:'none', boxSizing:'border-box', textShadow:`0 0 4px ${t.accent}60`, ...style,
+ }}/>;
+}
+
+window.CliWindow = CliWindow;
diff --git a/docs/design/v2/drover-compact.jsx b/docs/design/v2/drover-compact.jsx
new file mode 100644
index 0000000..e8fbb62
--- /dev/null
+++ b/docs/design/v2/drover-compact.jsx
@@ -0,0 +1,293 @@
+// drover-v2.jsx — Round 2: four new dark-only variants.
+// Reuses window.useDrover / StatusDot / icons / fmt helpers from drover-shared.jsx.
+
+// =============================================================================
+// V5 — COMPACT PRO. High-density, table-like. Wireshark/HTOP feel. Mono numbers.
+// =============================================================================
+const CPT = {
+ bg: '#101216', chrome: '#0a0c0f', panel: '#15181d', panel2: '#1a1d23',
+ border: '#262a31', borderSoft: '#1d2026',
+ text: '#e1e3e8', dim: '#838892', dimmer: '#535862',
+ accent: '#7aa9ff', danger: '#e5685a', warn: '#d6a64a', pass: '#5fc888', skip: '#6a6f78',
+ inputBg: '#0c0e12', primaryFg: '#0b1426',
+};
+
+function CompactWindow({ initial }) {
+ const t = CPT;
+ const D = window.useDrover(initial);
+ const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
+ const fontMono = '"JetBrains Mono","SF Mono",ui-monospace,Consolas,monospace';
+ const fontUI = '"Inter","Segoe UI",system-ui,sans-serif';
+ const isActive = D.phase === 'active';
+
+ return (
+
+ {/* title bar */}
+
+
+
+ drover-go
+ 0.4.2
+
+ · {D.form.host}:{D.form.port}{D.form.auth ? ' · auth' : ''}
+
+
+
+
+
+
+
+
+
+
+ {/* Row 1: form, single tight line */}
+
+
SOCKS5
+
+ D.update({ host: v })} placeholder="host" style={{ flex: 1 }}
+ onSubmit={D.runCheck}/>
+ :
+ D.update({ port: v.replace(/\D/g,'') })}
+ placeholder="port" style={{ width: 64 }} onSubmit={D.runCheck}/>
+ {
+ D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('cpt-login')?.focus(),30);
+ }}>auth
+
+ {D.form.auth && (
+
+ D.update({ login: v })} placeholder="login"
+ style={{ flex: 1 }} onSubmit={D.runCheck}/>
+ D.update({ password: v })} placeholder="password"
+ style={{ flex: 1 }} onSubmit={D.runCheck}/>
+
+ )}
+
+ {'>>'} {D.phase==='checking'?'CHECKING…':'check connection'}
+
+
+
+ {/* Row 2: status table, full width */}
+
+
+ STATUS
+
+ {D.phase === 'idle' && 'idle'}
+ {D.phase === 'checking' && `${Object.keys(D.results).length}/${D.tests.length}`}
+ {(D.phase === 'checked' || D.phase === 'active') &&
+ (D.lastSummary?.failed === 0 ? 'all-pass' : `${D.lastSummary?.failed}/${D.tests.length} fail`)}
+
+
+ {D.phase === 'idle'
+ ?
+ › ready to check_
+
+ :
}
+
+
+ {/* Row 3: actions */}
+
+
+
+
+
+ {isActive && (
+
+ {window.fmtBytes(D.stats.up)}
+ {window.fmtBytes(D.stats.down)}
+ tcp:{D.stats.tcp}
+ udp:{D.stats.udp}
+ up:{window.fmtUptime(D.stats.uptimeS)}
+
+ )}
+
+
+
+ {/* Logs */}
+
+
+ );
+}
+
+function CompactCell({ children, t, hover }) {
+ const [h, setH] = React.useState(false);
+ return setH(true)} onMouseLeave={()=>setH(false)} style={{
+ width: 32, height: 28, display:'flex', alignItems:'center', justifyContent:'center',
+ cursor:'pointer', background: h && hover ? hover : 'transparent', transition:'background .1s',
+ }}>{children}
;
+}
+function cptHead(t) {
+ return {
+ fontSize: 9.5, letterSpacing: 1.5, color: t.dim, fontWeight: 700,
+ marginBottom: 6, display:'flex', alignItems:'center',
+ };
+}
+function CptInput({ value, onChange, placeholder, type, style, onSubmit, t, fontMono, id }) {
+ return onChange(e.target.value)} placeholder={placeholder}
+ onKeyDown={e => e.key === 'Enter' && onSubmit?.()}
+ style={{
+ background: t.inputBg, color: t.text, border:`1px solid ${t.border}`,
+ borderRadius: 2, padding:'5px 7px', fontFamily: fontMono, fontSize: 11.5,
+ outline:'none', boxSizing:'border-box', ...style,
+ }}/>;
+}
+function CptCheck({ checked, onChange, t, children }) {
+ return (
+
+ [{checked ? 'x' : ' '}]
+ onChange(e.target.checked)} style={{display:'none'}}/>
+ {children}
+
+ );
+}
+function CompactStatusTable({ t, D, palette, fontMono }) {
+ return (
+
+ {D.tests.map((test) => {
+ const r = D.results[test.id];
+ const state = r?.result || (D.running === test.id ? 'running' : 'pending');
+ return (
+
+
+ {
+ state==='passed'?'✓':state==='failed'?'✗':state==='skipped'?'–':state==='running'?'›':'·'
+ }
+
+ {test.label}
+ {r?.metric || (state==='running'?'…':'')}
+ {r?.result === 'failed' && (
+ D.toggleExpand(test.id)} style={{
+ background:'transparent', border:'none', cursor:'pointer', padding: 2, color: t.dim,
+ }}>
+ )}
+
+ {r?.result === 'failed' && r.expanded && (
+
+ )}
+
+ );
+ })}
+
+ );
+}
+function CompactStartBtn({ t, D, fontMono }) {
+ const allFailed = D.lastSummary && D.lastSummary.failed === D.tests.length;
+ const ok = D.phase === 'checked' && !allFailed;
+ const active = D.phase === 'active';
+ const warning = active && (D.lastSummary?.failed || 0) > 0;
+ if (active) {
+ const c = warning ? t.warn : t.pass;
+ return (
+
+
+ ACTIVE{warning ? ' · UDP-FALLBACK' : ''}
+
+ );
+ }
+ return (
+ {'>'} start proxying
+ );
+}
+function CompactStopBtn({ t, D, fontMono }) {
+ const enabled = D.phase === 'active';
+ return (
+ {'■'} stop
+ );
+}
+function CompactLogs({ t, D, fontMono }) {
+ return (
+
+
D.setLogsOpen(!D.logsOpen)} style={{
+ width:'100%', padding:'6px 12px', display:'flex', alignItems:'center', gap: 7,
+ background:'transparent', border:'none', color: t.dim, cursor:'pointer',
+ fontFamily: fontMono, fontSize: 10.5, letterSpacing: 1,
+ }}>
+
+ LOGS
+ {D.logs.length} lines
+
+ {D.logsOpen && (
+ <>
+
+ {[['copy', () => navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n'))],
+ ['clear', D.clearLogs], ['file', null]].map(([l, fn]) => (
+ {l}
+ ))}
+
+
el && (el.scrollTop = el.scrollHeight)}
+ style={{ maxHeight: 110, overflowY:'auto', padding:'5px 12px',
+ fontFamily: fontMono, fontSize: 10, lineHeight: 1.55, color: t.dim, background: t.panel }}>
+ {D.logs.map((l,i) => (
+
+ {window.fmtTime(l.t)} {' '}
+ {l.level.padEnd(5)} {' '}
+ {l.msg}
+
+ ))}
+
+ >
+ )}
+
+ );
+}
+
+window.CompactWindow = CompactWindow;
diff --git a/docs/design/v2/drover-fluent-live.jsx b/docs/design/v2/drover-fluent-live.jsx
new file mode 100644
index 0000000..d26a8bb
--- /dev/null
+++ b/docs/design/v2/drover-fluent-live.jsx
@@ -0,0 +1,416 @@
+// drover-fluent-live.jsx — Fluent / Win11 live variant.
+// System-native: Segoe UI Variable, acrylic-blue (#0067c0), light cards on a tinted bg.
+
+const FLUENT_THEME = {
+ l: {
+ bg: '#f3f3f3',
+ chrome: '#fafafa',
+ panel: '#ffffff',
+ panelAlt: '#f7f7f7',
+ border: 'rgba(0,0,0,.06)',
+ borderHard:'#d6d6d6',
+ text: '#1a1a1a',
+ dim: '#666666',
+ dimmer: '#999999',
+ accent: '#0067c0',
+ accentHover:'#005a9e',
+ danger: '#c42b1c',
+ warn: '#9d5d00',
+ pass: '#107c10',
+ skip: '#888888',
+ inputBg: '#fafafa',
+ inputBorder:'#888',
+ activeBg: '#dff6dd',
+ activeBorder:'#9bc99b',
+ warnBg: '#fff4ce',
+ warnBorder:'#dba81a',
+ },
+ d: {
+ bg: '#202020',
+ chrome: '#181818',
+ panel: '#2b2b2b',
+ panelAlt: '#252525',
+ border: 'rgba(255,255,255,.06)',
+ borderHard:'#3a3a3a',
+ text: '#f0f0f0',
+ dim: '#9b9b9b',
+ dimmer: '#6a6a6a',
+ accent: '#4cc2ff',
+ accentHover:'#6cd0ff',
+ danger: '#ff6b6b',
+ warn: '#fce100',
+ pass: '#6ccb5f',
+ skip: '#7a7a7a',
+ inputBg: '#2c2c2c',
+ inputBorder:'#666',
+ activeBg: '#1d2f1c',
+ activeBorder:'#3d6e3a',
+ warnBg: '#3a3017',
+ warnBorder:'#7a6320',
+ },
+};
+
+function FluentWindow({ mode = 'light', initial }) {
+ const themeKey = mode === 'dark' ? 'd' : 'l';
+ const t = FLUENT_THEME[themeKey];
+ const D = window.useDrover(initial);
+ const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
+ const fontUI = "'Segoe UI Variable','Segoe UI',system-ui,-apple-system,sans-serif";
+ const fontMono = "'Cascadia Mono','Cascadia Code',Consolas,'JetBrains Mono',ui-monospace,monospace";
+ const isActive = D.phase === 'active';
+
+ return (
+
+ );
+}
+
+function FluentTitleBar({ t, mode }) {
+ return (
+
+
+
+ Drover-Go
+ 0.4.2
+
+
+
+
+
+
+
+ );
+}
+function FluentTitleBtn({ children, t, hoverBg, hoverFg }) {
+ const [hover, setHover] = React.useState(false);
+ return (
+ setHover(true)} onMouseLeave={() => setHover(false)}
+ style={{ width: 46, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center',
+ cursor: 'pointer',
+ background: hover ? (hoverBg || 'rgba(127,127,127,.12)') : 'transparent',
+ color: hover && hoverFg ? hoverFg : 'inherit',
+ }}>{children}
+ );
+}
+
+function FluentCard({ t, children, style }) {
+ return (
+ {children}
+ );
+}
+function FluentCardTitle({ t, children }) {
+ return {children}
;
+}
+function FluentField({ t, label, children, style }) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+function fluentInputStyle(t, fontMono, disabled) {
+ return {
+ height: 30, background: t.inputBg, color: disabled ? t.dimmer : t.text,
+ border: `1px solid ${t.border}`, borderBottom: `1.5px solid ${t.inputBorder}`,
+ borderRadius: 4, padding: '0 10px',
+ fontFamily: fontMono, fontSize: 12.5, outline: 'none', width: '100%', boxSizing: 'border-box',
+ transition: 'border-color .12s',
+ };
+}
+function FluentCheckbox({ t, checked, onChange, children }) {
+ return (
+
+
+ {checked && }
+
+ onChange(e.target.checked)} style={{ display: 'none' }} />
+ {children}
+
+ );
+}
+function FluentPrimaryBtn({ t, onClick, disabled, children, style }) {
+ const [hover, setHover] = React.useState(false);
+ return (
+ setHover(true)} onMouseLeave={() => setHover(false)}
+ style={{
+ height: 34, width: '100%', borderRadius: 4, border: 'none',
+ background: disabled ? t.panelAlt : (hover ? t.accentHover : t.accent),
+ color: disabled ? t.dimmer : '#fff', fontWeight: 600, fontSize: 13,
+ boxShadow: disabled ? 'none' : 'inset 0 -1px 0 rgba(0,0,0,.12)',
+ cursor: disabled ? 'not-allowed' : 'pointer',
+ transition: 'background .12s', marginTop: 4, ...style,
+ }}>{children}
+ );
+}
+
+function FluentStatus({ t, D, palette, fontMono, themeKey }) {
+ if (D.phase === 'idle') {
+ return (
+
+
+ Ready to check
+
+ );
+ }
+ return (
+ <>
+
+ {D.phase === 'checking'
+ ? <>
+
+ Running diagnostics…
+
+ {Object.keys(D.results).length}/{D.tests.length}
+
+ >
+ : D.lastSummary?.failed === 0
+ ? All checks passed. Ready to start.
+ : {D.lastSummary?.failed} of {D.tests.length} checks failed. Some features won't work. }
+
+
+ {D.tests.map((test) => {
+ const r = D.results[test.id];
+ const state = r?.result || (D.running === test.id ? 'running' : 'pending');
+ return (
+
+
+
+
+ {test.label}
+
+
+ {r?.metric || (state === 'running' ? '…' : '')}
+
+ {r?.result === 'failed' && (
+ D.toggleExpand(test.id)}
+ style={{ width: 20, height: 20, padding: 0, border: 'none', background: 'transparent', cursor: 'pointer' }}>
+
+
+ )}
+
+ {r?.result === 'failed' && r.expanded && (
+
+
{r.error}
+
{r.hint}
+
navigator.clipboard?.writeText(`[${test.label}] ${r.error} — ${r.metric}`)}
+ style={{ marginTop: 6, padding: '3px 8px', fontSize: 11, fontFamily: fontMono,
+ background: t.panelAlt, color: t.dim, border: `1px solid ${t.borderHard}`,
+ borderRadius: 3, cursor: 'pointer' }}>
+ copy error
+
+
+ )}
+
+ );
+ })}
+
+ >
+ );
+}
+
+function FluentStartBtn({ t, D, fontMono }) {
+ const phase = D.phase;
+ const summary = D.lastSummary;
+ const allFailed = summary && summary.failed === D.tests.length;
+ const checkedOk = phase === 'checked' && !allFailed;
+ const active = phase === 'active';
+ const warning = active && (summary?.failed || 0) > 0;
+ if (active) {
+ return (
+
+
+ Active{warning ? ' · UDP fallback' : ''}
+
+ );
+ }
+ return (
+
+ Start proxying
+
+ );
+}
+function FluentStopBtn({ t, D }) {
+ const enabled = D.phase === 'active';
+ const [hover, setHover] = React.useState(false);
+ return (
+ setHover(true)} onMouseLeave={() => setHover(false)}
+ style={{
+ flex: 1, height: 34, borderRadius: 4, fontWeight: 600, fontSize: 12.5,
+ background: enabled && hover ? t.panelAlt : t.panel,
+ color: enabled ? t.text : t.dimmer,
+ border: `1px solid ${t.borderHard}`, cursor: enabled ? 'pointer' : 'not-allowed',
+ transition: 'background .12s',
+ }}>Stop
+ );
+}
+function FluentLiveStats({ t, stats, fontMono }) {
+ const cell = (label, val) => (
+
+ {label}{val}
+
+ );
+ return (
+
+ {cell( , window.fmtBytes(stats.up))}
+ {cell( , window.fmtBytes(stats.down))}
+ {cell(TCP , stats.tcp)}
+ {cell(UDP , stats.udp)}
+ {cell(↑t , window.fmtUptime(stats.uptimeS))}
+
+ );
+}
+
+function FluentLogs({ t, D, fontMono }) {
+ return (
+
+
D.setLogsOpen(!D.logsOpen)} style={{
+ width: '100%', padding: '8px 14px', display: 'flex', alignItems: 'center', gap: 8,
+ background: 'transparent', border: 'none', color: t.dim, cursor: 'pointer',
+ fontSize: 11.5, fontFamily: fontMono,
+ }}>
+
+ Logs
+ {D.logs.length} lines
+
+ {D.logsOpen && (
+
+
+ {[
+ ['Copy all', () => navigator.clipboard?.writeText(D.logs.map(l => `[${l.level}] ${l.msg}`).join('\n'))],
+ ['Clear', D.clearLogs],
+ ['Open log file', () => {}],
+ ].map(([l, fn], i) => (
+ {l}
+ ))}
+
+
el && (el.scrollTop = el.scrollHeight)}>
+ {D.logs.map((l, i) => (
+
+ {window.fmtTime(l.t)}
+ {' '}
+ [{l.level}]
+ {' '}
+ {l.msg}
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+window.FluentWindow = FluentWindow;
diff --git a/docs/design/v2/drover-glass.jsx b/docs/design/v2/drover-glass.jsx
new file mode 100644
index 0000000..29e5a41
--- /dev/null
+++ b/docs/design/v2/drover-glass.jsx
@@ -0,0 +1,348 @@
+// drover-glass.jsx — Variant 3: Glassmorphism.
+// Soft animated gradient backdrop. Frosted blur on every panel. Subtle glow on active.
+
+const GlassTheme = {
+ d: {
+ bgGrad: 'radial-gradient(120% 80% at 0% 0%, #2b1d4a 0%, #131326 50%, #0d1024 100%)',
+ glassBg: 'rgba(255,255,255,0.06)',
+ glassBg2: 'rgba(255,255,255,0.10)',
+ border: 'rgba(255,255,255,0.14)',
+ borderSoft: 'rgba(255,255,255,0.08)',
+ text: '#f3f1ff', dim: '#bdbcd6', dimmer: '#7e7d99',
+ accent: '#9b8bff', accentGlow: '#9b8bff',
+ danger: '#ff8a98', warn: '#ffc46b', pass: '#7ee0b3', skip: '#9aa0aa',
+ inputBg: 'rgba(0,0,0,0.18)', primaryFg: '#1a1538',
+ },
+ l: {
+ bgGrad: 'radial-gradient(110% 70% at 0% 0%, #c8d8ff 0%, #f0e8ff 55%, #ffeaf2 100%)',
+ glassBg: 'rgba(255,255,255,0.55)',
+ glassBg2: 'rgba(255,255,255,0.75)',
+ border: 'rgba(255,255,255,0.7)',
+ borderSoft: 'rgba(255,255,255,0.5)',
+ text: '#1a1538', dim: '#5e5b7a', dimmer: '#8e8cab',
+ accent: '#5e4bcf', accentGlow: '#9b8bff',
+ danger: '#c0463f', warn: '#a06a14', pass: '#2c8a5a', skip: '#8a8aa0',
+ inputBg: 'rgba(255,255,255,0.6)', primaryFg: '#ffffff',
+ },
+};
+
+function GlassWindow({ mode = 'dark', initial }) {
+ const t = GlassTheme[mode === 'dark' ? 'd' : 'l'];
+ const D = window.useDrover(initial);
+ const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
+ const fontUI = '"Inter","Segoe UI",system-ui,sans-serif';
+ const fontMono = '"JetBrains Mono",ui-monospace,monospace';
+ const isActive = D.phase === 'active';
+
+ return (
+
+ {/* gradient orbs */}
+
+
+
+ {/* title */}
+
+
+
+
+ SOCKS5 Proxy
+
+
+ D.update({ host: e.target.value })}
+ placeholder="95.165.72.59 или example.com"
+ onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={glassInput(t, fontUI)}/>
+
+
+ D.update({ port: e.target.value.replace(/\D/g,'') })}
+ placeholder="12334" inputMode="numeric"
+ onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={glassInput(t, fontUI)}/>
+
+
+
+ { D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('gl-login')?.focus(),30); }}>
+ Authentication
+
+
+
+ D.update({ login: e.target.value })} placeholder="user"
+ onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={glassInput(t, fontUI, !D.form.auth)}/>
+
+
+ D.update({ password: e.target.value })} placeholder="••••••"
+ onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={glassInput(t, fontUI, !D.form.auth)}/>
+
+
+
+
+ {D.phase === 'checking' ? 'Checking…' : 'Check connection'}
+
+
+
+
+
+ Status
+
+
+
+
+
+
+
+
+
+ {isActive && }
+
+
+
+
+
+
+ );
+}
+
+function GlassTitle({ t }) {
+ const cell = { width: 44, height: 32, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', color: t.dim };
+ return (
+
+
+
+ Drover-Go
+ 0.4.2
+
+
+
+ );
+}
+
+function GlassPanel({ t, children, style }) {
+ return {children}
;
+}
+function GlassHeader({ t, children }) {
+ return {children}
;
+}
+function GField({ t, label, children, style }) {
+ return
+ {label} {children}
+ ;
+}
+function glassInput(t, fontUI, disabled) {
+ return {
+ background: t.inputBg, color: disabled ? t.dimmer : t.text,
+ border: `1px solid ${t.borderSoft}`, borderRadius: 8, padding: '8px 11px',
+ fontSize: 13, fontFamily: fontUI, outline:'none', width:'100%', boxSizing:'border-box',
+ backdropFilter: 'blur(8px)',
+ };
+}
+function GlassToggle({ t, checked, onChange, children }) {
+ return (
+
+
+
+
+ onChange(e.target.checked)} style={{ display:'none' }}/>
+ {children}
+
+ );
+}
+function GlassPrimary({ t, onClick, disabled, children, style }) {
+ return (
+ {children}
+ );
+}
+function GlassStatus({ t, D, palette, fontMono }) {
+ if (D.phase === 'idle') {
+ return (
+
+
+ Ready to check
+
+ );
+ }
+ const allOk = D.lastSummary?.failed === 0;
+ return (
+
+ {D.phase === 'checking'
+ ?
+
+ Running diagnostics…
+
+ :
{allOk ? 'All checks passed. Ready to start.' : `${D.lastSummary?.failed} of ${D.tests.length} checks failed. Some features won't work.`}
}
+
+ {D.tests.map(test => {
+ const r = D.results[test.id];
+ const state = r?.result || (D.running === test.id ? 'running' : 'pending');
+ return (
+
+
+
+ {test.label}
+
+ {r?.metric}
+
+ {r?.result === 'failed' && (
+ D.toggleExpand(test.id)} style={{
+ background:'transparent', border:'none', cursor:'pointer', padding: 4, color: t.dim,
+ }}>
+ )}
+
+ {r?.result === 'failed' && r.expanded && (
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
+function GlassStartBtn({ t, D }) {
+ const allFailed = D.lastSummary && D.lastSummary.failed === D.tests.length;
+ const checkedOk = D.phase === 'checked' && !allFailed;
+ const active = D.phase === 'active';
+ const warning = active && (D.lastSummary?.failed || 0) > 0;
+ if (active) {
+ const c = warning ? t.warn : t.pass;
+ return (
+
+
+ Active{warning ? ' · UDP fallback' : ''}
+
+ );
+ }
+ return Start proxying ;
+}
+function GlassStopBtn({ t, D }) {
+ const enabled = D.phase === 'active';
+ return (
+ Stop
+ );
+}
+function GlassLiveStats({ t, stats, fontMono }) {
+ const C = ({ icon, val, lbl }) => (
+
+ {icon}{val}
+ {lbl && {lbl} }
+
+ );
+ return (
+
+ } val={window.fmtBytes(stats.up)}/>
+ } val={window.fmtBytes(stats.down)}/>
+
+
+
+
+ );
+}
+function GlassLogs({ t, D, fontMono }) {
+ return (
+
+
D.setLogsOpen(!D.logsOpen)} style={{
+ width:'100%', padding: '9px 16px', display:'flex', alignItems:'center', gap:10,
+ background:'transparent', border:'none', color: t.dim, cursor:'pointer', fontSize: 12,
+ }}>
+
+ Logs
+ {D.logs.length}
+
+ {D.logsOpen && (
+ <>
+
+ {['Copy all','Clear','Open log file'].map((l,i) => (
+ navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n')) : i===1 ? D.clearLogs : undefined}
+ style={{
+ background:'rgba(255,255,255,0.05)', border:`1px solid ${t.borderSoft}`,
+ borderRadius: 6, padding: '4px 9px', fontSize: 11, color: t.dim, cursor:'pointer', fontFamily: fontMono,
+ }}>{l}
+ ))}
+
+
el && (el.scrollTop = el.scrollHeight)}
+ style={{ maxHeight: 130, overflowY: 'auto', padding: '8px 16px',
+ fontFamily: fontMono, fontSize: 11, lineHeight: 1.6, color: t.dim }}>
+ {D.logs.map((l,i) => (
+
+ {window.fmtTime(l.t)} {' '}
+ [{l.level}] {' '}
+ {l.msg}
+
+ ))}
+
+ >
+ )}
+
+ );
+}
+
+window.GlassWindow = GlassWindow;
diff --git a/docs/design/v2/drover-hero-live.jsx b/docs/design/v2/drover-hero-live.jsx
new file mode 100644
index 0000000..66f3e93
--- /dev/null
+++ b/docs/design/v2/drover-hero-live.jsx
@@ -0,0 +1,424 @@
+// drover-hero-live.jsx — Big toggle / VPN-style variant.
+// Centerpiece: a giant connect button. Form lives in an expandable section below.
+// Compact form (host:port) shown collapsed in active state; full form in idle.
+
+const HERO_THEME = {
+ d: {
+ bg: 'radial-gradient(ellipse at top, #142340 0%, #0e1426 60%, #080d1c 100%)',
+ chrome: 'transparent',
+ panel: '#15192a',
+ panelAlt: '#1a1f33',
+ border: 'rgba(255,255,255,.08)',
+ borderHard:'rgba(255,255,255,.18)',
+ text: '#ffffff',
+ textDim: '#cbd1d9',
+ dim: '#7a8499',
+ dimmer: '#5a6178',
+ accent: '#5dd4b3',
+ accentDeep:'#2da085',
+ accentDarker:'#1a6e5b',
+ danger: '#ff6b6b',
+ warn: '#f3c764',
+ pass: '#5dd4b3',
+ skip: '#7a8499',
+ },
+ l: {
+ bg: 'linear-gradient(180deg, #f3f7fb 0%, #e8edf4 100%)',
+ chrome: 'transparent',
+ panel: '#ffffff',
+ panelAlt: '#f5f7fb',
+ border: 'rgba(0,0,0,.07)',
+ borderHard:'rgba(0,0,0,.15)',
+ text: '#0e1830',
+ textDim: '#3a4356',
+ dim: '#6a7383',
+ dimmer: '#9aa3b2',
+ accent: '#1aa787',
+ accentDeep:'#0f8c70',
+ accentDarker:'#0a6655',
+ danger: '#c0463f',
+ warn: '#a8731e',
+ pass: '#1aa787',
+ skip: '#7a8499',
+ },
+};
+
+function HeroWindow({ mode = 'dark', initial }) {
+ const themeKey = mode === 'dark' ? 'd' : 'l';
+ const t = HERO_THEME[themeKey];
+ const D = window.useDrover(initial);
+ const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
+ const fontUI = "'Inter','Segoe UI',system-ui,sans-serif";
+ const fontMono = "'JetBrains Mono','SF Mono',ui-monospace,Consolas,monospace";
+
+ const [showForm, setShowForm] = React.useState(false);
+ const [showDetails, setShowDetails] = React.useState(false);
+
+ const phase = D.phase;
+ const summary = D.lastSummary;
+ const allFailed = summary && summary.failed === D.tests.length;
+ const failed = summary?.failed ?? 0;
+ const isActive = phase === 'active';
+ const warning = isActive && failed > 0;
+
+ // big-button click handler. Two-step: if idle/checked, go.
+ const handleBigClick = () => {
+ if (phase === 'active') return D.stopProxy();
+ if (phase === 'checking') return;
+ if (phase === 'idle') {
+ // run check, then auto-start if it passed
+ (async () => {
+ await D.runCheck();
+ })();
+ return;
+ }
+ if (phase === 'checked' && !allFailed) D.startProxy();
+ if (phase === 'checked' && allFailed) D.runCheck();
+ };
+
+ // After runCheck completes from idle, auto-start if all passed.
+ const prevPhase = React.useRef(phase);
+ React.useEffect(() => {
+ if (prevPhase.current === 'checking' && phase === 'checked') {
+ const sum = D.lastSummary;
+ if (sum && sum.failed === 0) {
+ // small delay so user perceives the "checked" state briefly
+ setTimeout(() => D.startProxy(), 600);
+ }
+ }
+ prevPhase.current = phase;
+ }, [phase]);
+
+ // Big-button label / colors
+ let buttonState;
+ if (phase === 'active') buttonState = warning ? 'active-warn' : 'active';
+ else if (phase === 'checking') buttonState = 'checking';
+ else if (phase === 'checked' && allFailed) buttonState = 'failed';
+ else buttonState = 'idle';
+
+ const ringColor = ({
+ idle: t.accent,
+ checking: t.accent,
+ active: t.accent,
+ 'active-warn': t.warn,
+ failed: t.danger,
+ })[buttonState];
+
+ const headline = ({
+ idle: 'Tap to connect',
+ checking: 'Verifying…',
+ active: 'Discord is protected',
+ 'active-warn': 'Connected · UDP fallback',
+ failed: 'Connection failed',
+ })[buttonState];
+
+ const subline = ({
+ idle: `via ${D.form.host}`,
+ checking: `running 7 checks…`,
+ active: `via ${D.form.host} · ${window.fmtUptime(D.stats.uptimeS)}`,
+ 'active-warn': `voice/screen disabled · ${window.fmtUptime(D.stats.uptimeS)}`,
+ failed: `${failed} of ${D.tests.length} checks failed`,
+ })[buttonState];
+
+ return (
+
+ {/* Title bar */}
+
+
+
Drover-Go
+
0.4.2
+
+ {phase === 'active' && }
+ {phase === 'idle' && 'idle'}
+ {phase === 'checking' && 'checking…'}
+ {phase === 'checked' && (allFailed ? 'failed' : 'ready')}
+ {phase === 'active' && (warning ? 'connected · warn' : 'connected')}
+
+
+
+
+
+
+
+
+ {/* Big button + headlines */}
+
+
+
+
+
{headline}
+
{subline}
+
+
+ {/* Stats — only when active */}
+ {isActive && (
+
+
+
+
+
+
+ )}
+
+ {/* Diagnostic mini-list during check or after */}
+ {(phase === 'checking' || (phase === 'checked' && !isActive)) && (
+
+
+ {D.tests.map((test) => {
+ const r = D.results[test.id];
+ const state = r?.result || (D.running === test.id ? 'running' : 'pending');
+ return (
+
+
+
+ {test.label}
+
+ {r?.metric || (state === 'running' ? '…' : '')}
+
+ {r?.result === 'failed' && (
+ D.toggleExpand(test.id)}
+ style={{ width: 18, height: 18, padding: 0, border: 'none', background: 'transparent', cursor: 'pointer' }}>
+
+
+ )}
+
+ {r?.result === 'failed' && r.expanded && (
+
+ )}
+
+ );
+ })}
+
+
+ )}
+
+
+ {/* Bottom: form (idle) or footer chips (active) */}
+
+
+
setShowForm(s => !s)}
+ style={{
+ width: '100%', padding: '11px 14px', display: 'flex', alignItems: 'center', gap: 10,
+ background: 'transparent', border: 'none', cursor: 'pointer', color: t.text,
+ fontSize: 12.5, fontFamily: fontUI, textAlign: 'left',
+ }}>
+ S5
+
+
Proxy
+
{D.form.host}:{D.form.port}{D.form.auth ? ' · auth' : ''}
+
+
+
+ {showForm && (
+
+
+ D.update({ host: v })}
+ onEnter={D.runCheck} placeholder="95.165.72.59 или example.com" label="Host" style={{ flex: 1 }} />
+ D.update({ port: v.replace(/\D/g,'') })}
+ onEnter={D.runCheck} placeholder="12334" label="Port" style={{ width: 90 }} />
+
+
+
+ {D.form.auth && }
+
+ { D.update({ auth: e.target.checked }); if (e.target.checked) setTimeout(() => document.getElementById('hero-login')?.focus(), 30); }}
+ style={{ display: 'none' }} />
+ Authentication
+
+
+ D.update({ login: v })} onEnter={D.runCheck}
+ placeholder="user" label="Login" style={{ flex: 1 }} disabled={!D.form.auth} />
+ D.update({ password: v })} onEnter={D.runCheck}
+ placeholder="••••••" label="Password" style={{ flex: 1 }} disabled={!D.form.auth} />
+
+
+ )}
+
+
+ {/* Logs strip */}
+
setShowDetails(s => !s)}
+ style={{
+ width: '100%', marginTop: 8, padding: '6px 4px', display: 'flex', alignItems: 'center', gap: 6,
+ background: 'transparent', border: 'none', cursor: 'pointer', color: t.dim,
+ fontSize: 11, fontFamily: fontMono, textAlign: 'left',
+ }}>
+
+ Logs
+ {D.logs.length} lines
+
+ {showDetails && (
+
el && (el.scrollTop = el.scrollHeight)}>
+ {D.logs.map((l, i) => (
+
+ {window.fmtTime(l.t)} {' '}
+ [{l.level}] {' '}
+ {l.msg}
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+// ─── Big toggle button ──────────────────────────────────────────────────
+function BigToggle({ t, state, onClick, progress = 0 }) {
+ // size = 168 px circle; outer pulsing rings
+ const isActive = state === 'active' || state === 'active-warn';
+ const isChecking = state === 'checking';
+ const isFailed = state === 'failed';
+ const isWarn = state === 'active-warn';
+
+ const accent = isWarn ? t.warn : isFailed ? t.danger : t.accent;
+ const accentDeep = isWarn ? t.warn : isFailed ? t.danger : t.accentDeep;
+ const accentDarker = isWarn ? t.warn : isFailed ? t.danger : t.accentDarker;
+
+ const ring = 168;
+ const stroke = 5;
+ const r = (ring - stroke) / 2;
+ const c = 2 * Math.PI * r;
+
+ return (
+
+ {/* pulsing ring on active */}
+ {isActive && (
+
+ )}
+ {/* progress ring while checking */}
+ {isChecking && (
+
+
+
+ {/* spinner segment */}
+
+
+ )}
+ ⏻
+
+ {isActive ? 'ACTIVE' : isChecking ? 'TESTING' : isFailed ? 'RETRY' : 'OFF'}
+
+
+
+ );
+}
+
+function HeroStat({ icon, v, u, c, mono, t }) {
+ return (
+
+ );
+}
+
+function HeroTitleBtn({ children, t, hoverBg }) {
+ const [hover, setHover] = React.useState(false);
+ return (
+ setHover(true)} onMouseLeave={() => setHover(false)}
+ style={{
+ width: 26, height: 26, display: 'flex', alignItems: 'center', justifyContent: 'center',
+ cursor: 'pointer', borderRadius: 5,
+ background: hover ? (hoverBg || 'rgba(127,127,127,.16)') : 'transparent',
+ }}>{children}
+ );
+}
+
+function HeroInput({ t, fontMono, value, onChange, onEnter, placeholder, label, style, type, disabled, id }) {
+ const [focus, setFocus] = React.useState(false);
+ return (
+
+ {label}
+ onChange(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && onEnter?.()}
+ onFocus={() => setFocus(true)} onBlur={() => setFocus(false)}
+ placeholder={placeholder}
+ style={{
+ height: 32, background: t.panelAlt, color: disabled ? t.dimmer : t.text,
+ border: `1px solid ${focus ? t.accent : t.border}`,
+ borderRadius: 7, padding: '0 10px',
+ fontFamily: fontMono, fontSize: 12, outline: 'none', boxSizing: 'border-box',
+ transition: 'border-color .12s, box-shadow .12s',
+ boxShadow: focus ? `0 0 0 3px ${t.accent}22` : 'none',
+ }} />
+
+ );
+}
+
+function fmtCompact(n) {
+ if (n < 1024) return n.toFixed(0);
+ if (n < 1024 * 1024) return (n / 1024).toFixed(0);
+ return (n / 1024 / 1024).toFixed(1);
+}
+function fmtUnit(n) {
+ if (n < 1024) return 'B/s';
+ if (n < 1024 * 1024) return 'KB/s';
+ return 'MB/s';
+}
+
+window.HeroWindow = HeroWindow;
diff --git a/docs/design/v2/drover-minimal.jsx b/docs/design/v2/drover-minimal.jsx
new file mode 100644
index 0000000..41a772e
--- /dev/null
+++ b/docs/design/v2/drover-minimal.jsx
@@ -0,0 +1,358 @@
+// drover-minimal.jsx — Variant 2: Minimal / Fluent-aligned.
+// Windows 11 Mica feel. Generous spacing. Subtle accents. Segoe UI Variable.
+// Soft cards on a tinted background, accent: a calm slate-blue.
+
+const MinTheme = {
+ d: {
+ bg: '#1e1f23', mica: 'rgba(255,255,255,0.02)', chrome: 'rgba(255,255,255,0.03)',
+ panel: 'rgba(255,255,255,0.04)', panel2: 'rgba(255,255,255,0.06)',
+ border: 'rgba(255,255,255,0.08)', borderStrong: 'rgba(255,255,255,0.14)',
+ text: '#f3f3f3', dim: '#b0b3ba', dimmer: '#7a7d85',
+ accent: '#76b3ff', accentBg: 'rgba(118,179,255,0.16)',
+ danger: '#ff7a7a', warn: '#ffb86b', pass: '#7ad29c', skip: '#9aa0aa',
+ inputBg: 'rgba(255,255,255,0.04)', inputBorder: 'rgba(255,255,255,0.16)',
+ primaryBg: '#76b3ff', primaryFg: '#0d1722',
+ },
+ l: {
+ bg: '#f6f6f8', mica: 'rgba(255,255,255,0.5)', chrome: 'rgba(255,255,255,0.65)',
+ panel: '#ffffff', panel2: '#fafafc',
+ border: 'rgba(0,0,0,0.06)', borderStrong: 'rgba(0,0,0,0.14)',
+ text: '#1c1d20', dim: '#4f5460', dimmer: '#8a8f97',
+ accent: '#3a72c7', accentBg: 'rgba(58,114,199,0.10)',
+ danger: '#c0463f', warn: '#9c6a14', pass: '#2c8a5a', skip: '#7c8088',
+ inputBg: '#ffffff', inputBorder: 'rgba(0,0,0,0.18)',
+ primaryBg: '#3a72c7', primaryFg: '#ffffff',
+ },
+};
+
+function MinimalWindow({ mode = 'light', initial }) {
+ const t = MinTheme[mode === 'dark' ? 'd' : 'l'];
+ const D = window.useDrover(initial);
+ const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
+ const isActive = D.phase === 'active';
+ const fontUI = '"Segoe UI Variable","Segoe UI",system-ui,-apple-system,sans-serif';
+ const fontMono = '"Cascadia Mono","Consolas",ui-monospace,monospace';
+
+ return (
+
+ {/* title */}
+
+
+
+ {/* Form card */}
+
+ SOCKS5 Proxy
+
+
+ D.update({ host: e.target.value })}
+ placeholder="95.165.72.59 или example.com"
+ onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={minInput(t, fontUI)}/>
+
+
+ D.update({ port: e.target.value.replace(/\D/g,'') })}
+ placeholder="12334" inputMode="numeric"
+ onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={minInput(t, fontUI)}/>
+
+
+
+
+ { D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('min-login')?.focus(),30); }}>
+ Authentication
+
+
+
+ D.update({ login: e.target.value })} placeholder="user"
+ onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={minInput(t, fontUI, !D.form.auth)}/>
+
+
+ D.update({ password: e.target.value })} placeholder="••••••"
+ onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={minInput(t, fontUI, !D.form.auth)}/>
+
+
+
+
+
+ {D.phase === 'checking' ? 'Checking…' : 'Check connection'}
+
+
+
+
+
+ {/* Status card */}
+
+ Status
+
+
+
+
+
+ {/* Action card */}
+
+
+
+
+
+ {isActive && }
+
+
+
+
+ {/* Logs */}
+
+
+ );
+}
+
+function MinTitle({ t }) {
+ const cell = { width: 46, height: 32, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', color: t.dim };
+ return (
+
+
+
+ Drover-Go
+ 0.4.2
+
+
+
+
+
{ e.currentTarget.style.background = '#c0463f'; e.currentTarget.style.color = 'white'; }}
+ onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = t.dim; }}>
+
+
+
+
+ );
+}
+function Card({ t, children, style }) {
+ return {children}
;
+}
+function CardHeader({ t, children }) {
+ return {children}
;
+}
+function MinField({ t, label, children, style }) {
+ return
+ {label}
+ {children}
+ ;
+}
+function minInput(t, fontUI, disabled) {
+ return {
+ background: t.inputBg, color: disabled ? t.dimmer : t.text,
+ border: `1px solid ${t.inputBorder}`, borderRadius: 5, padding: '8px 10px',
+ fontSize: 13, fontFamily: fontUI, outline:'none', width:'100%', boxSizing:'border-box',
+ };
+}
+function MinToggle({ t, checked, onChange, children }) {
+ return (
+
+
+
+
+ onChange(e.target.checked)} style={{ display:'none' }}/>
+ {children}
+
+ );
+}
+function MinStatus({ t, D, palette, fontMono }) {
+ if (D.phase === 'idle') {
+ return (
+
+
+ Ready to check
+
+ );
+ }
+ return (
+
+
+ {D.phase === 'checking'
+ ? <>
+
+ Running diagnostics…
+ >
+ : D.lastSummary?.failed === 0
+ ? All checks passed. Ready to start.
+ : {D.lastSummary?.failed} of {D.tests.length} checks failed. Some features won't work. }
+
+
+
+ {D.tests.map(test => {
+ const r = D.results[test.id];
+ const state = r?.result || (D.running === test.id ? 'running' : 'pending');
+ return (
+
+
+
+ {test.label}
+
+ {r?.metric}
+
+ {r?.result === 'failed' && (
+ D.toggleExpand(test.id)} style={{
+ background:'transparent', border:'none', cursor:'pointer', padding: 4, color: t.dim,
+ }} title="Подробнее">
+
+
+ )}
+
+ {r?.result === 'failed' && r.expanded && (
+
+
{r.error}
+
{r.hint}
+
navigator.clipboard?.writeText(`${test.label}: ${r.error}`)}
+ style={{
+ marginTop: 6, background:'transparent', border:`1px solid ${t.border}`,
+ color: t.dim, borderRadius: 4, padding: '3px 8px', fontSize: 11, cursor:'pointer',
+ display:'inline-flex', gap:4, alignItems:'center', fontFamily: fontMono,
+ }}>
+ copy error
+
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
+function SummaryPill({ t, kind, children }) {
+ const c = kind === 'ok' ? t.pass : t.warn;
+ return {children}
;
+}
+
+function MinStartBtn({ t, D, fontUI }) {
+ const phase = D.phase;
+ const allFailed = D.lastSummary && D.lastSummary.failed === D.tests.length;
+ const checkedOk = phase === 'checked' && !allFailed;
+ const active = phase === 'active';
+ const warning = active && (D.lastSummary?.failed || 0) > 0;
+ if (active) {
+ const c = warning ? t.warn : t.pass;
+ return (
+
+
+ Active{warning ? ' · UDP fallback' : ''}
+
+ );
+ }
+ return (
+ Start proxying
+ );
+}
+function MinStopBtn({ t, D, fontUI }) {
+ const enabled = D.phase === 'active';
+ return (
+ Stop
+ );
+}
+function MinLiveStats({ t, stats, fontMono }) {
+ const Cell = ({ icon, val, lbl }) => (
+
+ {icon}
+ {val}
+ {lbl && {lbl} }
+
+ );
+ return (
+
+ } val={window.fmtBytes(stats.up)}/>
+ } val={window.fmtBytes(stats.down)}/>
+ |
+ |
+ |
+ | |
+ );
+}
+function MinLogs({ t, D, fontMono }) {
+ return (
+
+
D.setLogsOpen(!D.logsOpen)} style={{
+ width:'100%', padding: '9px 16px', display:'flex', alignItems:'center', gap:10,
+ background:'transparent', border:'none', color: t.dim, cursor:'pointer', fontSize: 12,
+ }}>
+
+ Logs
+ {D.logs.length}
+
+ {D.logsOpen && (
+ <>
+
+ {['Copy all','Clear','Open log file'].map((l,i) => (
+ navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n')) : i===1 ? D.clearLogs : undefined}
+ style={{
+ background:'transparent', border:`1px solid ${t.border}`, borderRadius: 4,
+ padding: '4px 9px', fontSize: 11, color: t.dim, cursor:'pointer', fontFamily: fontMono,
+ }}>{l}
+ ))}
+
+
el && (el.scrollTop = el.scrollHeight)}
+ style={{ maxHeight: 130, overflowY: 'auto', padding: '8px 16px',
+ fontFamily: fontMono, fontSize: 11, lineHeight: 1.6, color: t.dim, background: t.panel }}>
+ {D.logs.map((l,i) => (
+
+ {window.fmtTime(l.t)} {' '}
+ [{l.level}] {' '}
+ {l.msg}
+
+ ))}
+
+ >
+ )}
+
+ );
+}
+
+window.MinimalWindow = MinimalWindow;
diff --git a/docs/design/v2/drover-shared.jsx b/docs/design/v2/drover-shared.jsx
new file mode 100644
index 0000000..7632f64
--- /dev/null
+++ b/docs/design/v2/drover-shared.jsx
@@ -0,0 +1,308 @@
+// drover-shared.jsx — shared hooks, state machine, types for all Drover-Go variants.
+// Each variant imports these via window globals and renders them in its own visual language.
+
+// ─── Test catalog ──────────────────────────────────────────────────────────
+const ALL_TESTS = [
+ { id: 'tcp', label: 'TCP reachability', desc: 'TCP-соединение до прокси установлено' },
+ { id: 'greet', label: 'SOCKS5 greeting', desc: 'Прокси отвечает по протоколу SOCKS5' },
+ { id: 'auth', label: 'SOCKS5 authentication', desc: 'Аутентификация по логину/паролю принята', authOnly: true },
+ { id: 'connect', label: 'TCP CONNECT to Discord', desc: 'Прокси проксирует TCP к gateway' },
+ { id: 'udp', label: 'UDP ASSOCIATE', desc: 'Прокси выдал UDP-релей' },
+ { id: 'stun', label: 'UDP round-trip via STUN', desc: 'UDP-пакеты ходят туда-обратно' },
+ { id: 'api', label: 'Discord API reachable', desc: 'HTTPS до discord.com через прокси' },
+];
+
+// Pre-baked scenarios so the prototype feels alive. Each entry per test:
+// { result: 'passed'|'failed'|'skipped', metric: '12 ms' | 'ok' | …, error?: 'short msg', hint?: 'what to try' }
+const SCENARIOS = {
+ // Default happy path (no auth)
+ happy: {
+ tcp: { result: 'passed', metric: '14 ms' },
+ greet: { result: 'passed', metric: 'SOCKS5/0x05' },
+ connect: { result: 'passed', metric: 'gateway.discord.gg' },
+ udp: { result: 'passed', metric: 'relay 95.165.72.59:54321' },
+ stun: { result: 'passed', metric: '38 ms RTT' },
+ api: { result: 'passed', metric: '204 OK · 89 ms' },
+ },
+ // With auth
+ happyAuth: {
+ tcp: { result: 'passed', metric: '14 ms' },
+ greet: { result: 'passed', metric: 'SOCKS5/0x05' },
+ auth: { result: 'passed', metric: 'user/pass · ok' },
+ connect: { result: 'passed', metric: 'gateway.discord.gg' },
+ udp: { result: 'passed', metric: 'relay 95.165.72.59:54321' },
+ stun: { result: 'passed', metric: '38 ms RTT' },
+ api: { result: 'passed', metric: '204 OK · 89 ms' },
+ },
+ // UDP fails — common Discord scenario
+ udpFail: {
+ tcp: { result: 'passed', metric: '17 ms' },
+ greet: { result: 'passed', metric: 'SOCKS5/0x05' },
+ connect: { result: 'passed', metric: 'gateway.discord.gg' },
+ udp: { result: 'failed', metric: 'X\'07 cmd not supported',
+ error: 'Прокси не поддерживает UDP ASSOCIATE.',
+ hint: 'Голос и демонстрация экрана работать не будут. Текст и API — будут. Попробуйте другой SOCKS5-сервер с поддержкой UDP.' },
+ stun: { result: 'skipped', metric: 'требует UDP ASSOCIATE' },
+ api: { result: 'passed', metric: '204 OK · 92 ms' },
+ },
+};
+
+function getTests(authEnabled) {
+ return ALL_TESTS.filter(t => !t.authOnly || authEnabled);
+}
+
+// ─── Drover state hook ─────────────────────────────────────────────────────
+// Owns: form values, diagnostic phase, per-test results, drover-active state,
+// live stats counter, log buffer.
+// phase: 'idle' | 'checking' | 'checked' | 'active'
+function useDrover(initial = {}) {
+ const [form, setForm] = React.useState({
+ host: '95.165.72.59',
+ port: '12334',
+ auth: false,
+ login: '',
+ password: '',
+ ...initial,
+ });
+ const [phase, setPhase] = React.useState('idle');
+ const [results, setResults] = React.useState({}); // testId -> {result, metric, error, hint, expanded}
+ const [running, setRunning] = React.useState(null); // currently-running test id
+ const [scenario, setScenario] = React.useState('happy'); // for tweaks
+ const [stats, setStats] = React.useState({ up: 0, down: 0, tcp: 0, udp: 0, uptimeS: 0 });
+ const [logs, setLogs] = React.useState(() => seedLogs());
+ const [logsOpen, setLogsOpen] = React.useState(false);
+ const tests = getTests(form.auth);
+ const lastSummary = React.useMemo(() => {
+ if (phase !== 'checked' && phase !== 'active') return null;
+ const ids = tests.map(t => t.id);
+ const failed = ids.filter(id => results[id]?.result === 'failed').length;
+ return { total: ids.length, failed };
+ }, [phase, results, tests]);
+
+ // ── actions ────────────────────────────────────────────────────────────
+ function update(patch) { setForm(f => ({ ...f, ...patch })); }
+
+ function pushLog(level, msg) {
+ setLogs(l => [...l.slice(-499), { t: Date.now(), level, msg }]);
+ }
+
+ async function runCheck() {
+ if (phase === 'checking') return;
+ setPhase('checking');
+ setResults({});
+ setRunning(null);
+ pushLog('INFO', `connect ${form.host}:${form.port}${form.auth ? ' (auth)' : ''}`);
+ const list = getTests(form.auth);
+ const sc = form.auth && scenario === 'happy' ? SCENARIOS.happyAuth : SCENARIOS[scenario];
+ for (const t of list) {
+ setRunning(t.id);
+ // shorter when scenario is failing past skipped tests
+ const r = sc[t.id];
+ if (r?.result === 'skipped') {
+ await sleep(120);
+ } else {
+ await sleep(380 + Math.random()*220);
+ }
+ setResults(prev => ({ ...prev, [t.id]: { ...r, expanded: r?.result === 'failed' } }));
+ pushLog(r?.result === 'failed' ? 'ERROR' : r?.result === 'skipped' ? 'WARN' : 'INFO',
+ `${t.label}: ${r?.result}${r?.metric ? ' · ' + r.metric : ''}`);
+ }
+ setRunning(null);
+ setPhase('checked');
+ }
+
+ function startProxy() {
+ if (phase !== 'checked') return;
+ if (lastSummary?.failed === tests.length) return;
+ setPhase('active');
+ pushLog('INFO', 'drover: bound 127.0.0.1:1080 · routing discord traffic');
+ }
+
+ function stopProxy() {
+ if (phase !== 'active') return;
+ setPhase('checked');
+ setStats({ up: 0, down: 0, tcp: 0, udp: 0, uptimeS: 0 });
+ pushLog('INFO', 'drover: stopped');
+ }
+
+ // Live stats while active
+ React.useEffect(() => {
+ if (phase !== 'active') return;
+ const id = setInterval(() => {
+ setStats(s => ({
+ up: Math.max(0, s.up + (Math.random()*40000 - 18000)),
+ down: Math.max(0, s.down + (Math.random()*120000 - 50000)),
+ tcp: Math.max(2, Math.min(28, s.tcp + (Math.random() < 0.3 ? (Math.random()<.5?-1:1) : 0))),
+ udp: Math.max(0, Math.min(8, s.udp + (Math.random() < 0.2 ? (Math.random()<.5?-1:1) : 0))),
+ uptimeS: s.uptimeS + 1,
+ }));
+ }, 1000);
+ // initial values
+ setStats({ up: 42000, down: 180000, tcp: 6, udp: 2, uptimeS: 0 });
+ return () => clearInterval(id);
+ }, [phase]);
+
+ // Toggle a test's expanded explanation
+ function toggleExpand(id) {
+ setResults(r => ({ ...r, [id]: { ...r[id], expanded: !r[id]?.expanded } }));
+ }
+
+ return {
+ form, update,
+ phase, setPhase,
+ tests, results, running,
+ scenario, setScenario,
+ stats,
+ logs, logsOpen, setLogsOpen, pushLog, clearLogs: () => setLogs([]),
+ lastSummary,
+ runCheck, startProxy, stopProxy,
+ toggleExpand,
+ };
+}
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+function seedLogs() {
+ const t = Date.now();
+ return [
+ { t: t-9200, level: 'INFO', msg: 'drover-go v0.4.2 starting' },
+ { t: t-9100, level: 'INFO', msg: 'config: ~/.drover/config.toml' },
+ { t: t-9000, level: 'INFO', msg: 'no active session' },
+ ];
+}
+
+function fmtBytes(n) {
+ if (n < 1024) return n.toFixed(0) + ' B/s';
+ if (n < 1024*1024) return (n/1024).toFixed(1) + ' KB/s';
+ return (n/1024/1024).toFixed(2) + ' MB/s';
+}
+function fmtUptime(s) {
+ const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), ss = s%60;
+ if (h) return `${h}h ${m}m`;
+ if (m) return `${m}m ${ss}s`;
+ return `${ss}s`;
+}
+function fmtTime(t) {
+ const d = new Date(t);
+ return d.toLocaleTimeString('en-GB', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3,'0');
+}
+
+// ─── Shared icons (small, original) ────────────────────────────────────────
+// Drover-Go mark: a downward chevron through a ring — "tunneled traffic".
+function BrandMark({ size = 16, color = 'currentColor', strokeWidth = 1.6 }) {
+ const s = size;
+ return (
+
+
+
+
+
+ );
+}
+
+function IconGear({ size=14, color='currentColor' }) {
+ return (
+
+
+
+
+ );
+}
+function IconMin({ size=14, color='currentColor' }) {
+ return ;
+}
+function IconClose({ size=14, color='currentColor' }) {
+ return ;
+}
+function IconChevron({ size=12, color='currentColor', dir='down' }) {
+ const r = { down: 0, up: 180, left: 90, right: -90 }[dir];
+ return
+
+ ;
+}
+function IconCopy({ size=12, color='currentColor' }) {
+ return
+
+
+ ;
+}
+function IconArrowUp({ size=10, color='currentColor' }) {
+ return
+
+ ;
+}
+function IconArrowDown({ size=10, color='currentColor' }) {
+ return
+
+ ;
+}
+
+// ─── Test row state icons (per visual variant supplies its own colors) ─────
+function StatusDot({ state, palette, size = 12 }) {
+ // state: 'pending' | 'running' | 'passed' | 'failed' | 'skipped'
+ const c = palette[state] || palette.pending;
+ if (state === 'running') {
+ return (
+
+
+
+
+
+
+ );
+ }
+ if (state === 'passed') {
+ return
+
+
+ ;
+ }
+ if (state === 'failed') {
+ return
+
+
+ ;
+ }
+ if (state === 'skipped') {
+ return
+
+
+ ;
+ }
+ // pending
+ return
+
+ ;
+}
+
+// CSS for the spinner — injected once.
+if (typeof document !== 'undefined' && !document.getElementById('drv-shared-css')) {
+ const s = document.createElement('style');
+ s.id = 'drv-shared-css';
+ s.textContent = `
+ @keyframes drv-spin { to { transform: rotate(360deg); } }
+ @keyframes drv-pulse { 0%,100% { opacity:1; transform:scale(1);} 50% { opacity:.55; transform:scale(0.7);} }
+ @keyframes drv-blink { 0%,100% { opacity:1;} 50% { opacity:.35;} }
+ @keyframes drv-fadein { from { opacity:0; transform:translateY(-2px);} to { opacity:1; transform:none;} }
+ .drv-fadein { animation: drv-fadein .18s ease-out; }
+ .drv-pulsedot { animation: drv-pulse 1.4s ease-in-out infinite; }
+ .drv-shimmer::after {
+ content:''; position:absolute; inset:0; background: linear-gradient(90deg,transparent,rgba(255,255,255,.25),transparent);
+ transform:translateX(-100%); animation: drv-shim 1.6s linear infinite;
+ }
+ @keyframes drv-shim { to { transform: translateX(100%); } }
+ /* Hide scrollbars for log panes inside artboards */
+ .drv-log::-webkit-scrollbar { width:6px; }
+ .drv-log::-webkit-scrollbar-thumb { background: rgba(127,127,127,.35); border-radius: 3px; }
+ `;
+ document.head.appendChild(s);
+}
+
+// Expose globals
+Object.assign(window, {
+ useDrover, getTests, ALL_TESTS, SCENARIOS,
+ fmtBytes, fmtUptime, fmtTime,
+ BrandMark, StatusDot,
+ IconGear, IconMin, IconClose, IconChevron, IconCopy, IconArrowUp, IconArrowDown,
+});
diff --git a/docs/design/v2/drover-sketches.jsx b/docs/design/v2/drover-sketches.jsx
new file mode 100644
index 0000000..e0721b1
--- /dev/null
+++ b/docs/design/v2/drover-sketches.jsx
@@ -0,0 +1,903 @@
+// Drover-Go · style sketches v2 — "feels like real software" set.
+// Each artboard explores a DIFFERENT software UI paradigm + element vocabulary,
+// not just a different palette. 480×640 fixed Win11 window unless noted.
+
+const W = 480;
+const H = 640;
+
+const Win = ({ children, bg = '#fff', radius = 8, ring = 'rgba(0,0,0,.18)' }) => (
+ {children}
+);
+
+const Mark = ({ size = 14, color = '#0c8c7a' }) => (
+
+
+
+
+
+);
+
+const TitleButtons = ({ color = '#666' }) => (
+
+ ⚙
+ —
+ ×
+
+);
+
+// ═════════════════════════════════════════════════════════════════════════
+// 1 · Fluent / Win11 native — the safe baseline
+// ═════════════════════════════════════════════════════════════════════════
+const SketchFluent = () => {
+ const f = "'Segoe UI Variable','Segoe UI',system-ui,sans-serif";
+ return (
+
+
+
+ Drover-Go
+ 0.4.2
+
+
+
+
+
SOCKS5 Proxy
+
+
+
+
+
+
+ Authentication
+
+
Check connection
+
+
+
Status
+
+
+ Running diagnostics…
+
+ {[
+ ['✓', 'TCP reachability', '12 ms', '#107c10'],
+ ['✓', 'SOCKS5 greeting', 'ok', '#107c10'],
+ ['◐', 'TCP CONNECT to Discord', '…', '#0067c0'],
+ ['·', 'UDP ASSOCIATE', '', '#bbb'],
+ ['·', 'UDP round-trip via STUN', '', '#bbb'],
+ ['·', 'Discord API reachable', '', '#bbb'],
+ ].map(([ic, name, val, c], i) => (
+
+ {ic}
+ {name}
+ {val}
+
+ ))}
+
+
+ Start proxying
+ Stop
+
+
+
+
+ );
+};
+const FluentField = ({ label, value, flex, width }) => (
+
+ {label}
+ {value}
+
+);
+
+// ═════════════════════════════════════════════════════════════════════════
+// 2 · Sidebar nav (VS Code / Postman / Tower) — left rail with sections
+// ═════════════════════════════════════════════════════════════════════════
+const SketchSidebar = () => {
+ const f = "'Inter', system-ui, sans-serif";
+ const m = "'JetBrains Mono', monospace";
+ return (
+
+
+
+ Drover-Go
+ — proxy: 95.165.72.59:12334
+
+
+
+ {/* sidebar */}
+
+ {[['◉', 'Proxy', true], ['◇', 'Diagnose'], ['≋', 'Traffic'], ['≣', 'Logs'], ['⚙', 'Settings']].map(([ic, l, on], i) => (
+
+ {ic}
+ {l}
+
+ ))}
+
+
+ {/* breadcrumb / tabs */}
+
+
● Proxy
+
diagnostic.run
+
+
+
+
Endpoint
+
+
SOCKS5
+
95.165.72.59
+
:12334
+
Run ▸
+
+
+
+
Diagnostic results
+
+ {[
+ ['✓', 'tcp_reachability', '12 ms', '#21d07a'],
+ ['✓', 'socks5_greeting', 'ok', '#21d07a'],
+ ['✓', 'socks5_authentication', 'ok', '#21d07a'],
+ ['✓', 'tcp_connect_discord', '38 ms', '#21d07a'],
+ ['✗', 'udp_associate', 'EPERM: blocked', '#ff5e5e'],
+ ['—', 'udp_stun_rtt', 'skipped', '#666'],
+ ['—', 'discord_api', 'skipped', '#666'],
+ ].map(([ic, n, v, c], i) => (
+
+ {ic}
+ {n}
+ {v}
+
+ ))}
+
+
+ ⚠ 1 of 7 checks failed. UDP voice/screenshare won't work.
+
+
+
+ {/* status bar */}
+
+ ● 6/7
+ idle
+ UTF-8 · LF · drover.toml
+
+
+
+
+ );
+};
+
+// ═════════════════════════════════════════════════════════════════════════
+// 3 · Step wizard / Tab progression — DigiCert / Tunnelblick connect-flow
+// ═════════════════════════════════════════════════════════════════════════
+const SketchWizard = () => {
+ const f = "'Inter', system-ui, sans-serif";
+ return (
+
+
+
+ Drover-Go · Setup
+
+
+
+ {/* stepper */}
+
+ {[['1', 'Configure', true], ['2', 'Verify', true], ['3', 'Connect', false]].map(([n, l, done], i) => (
+
+
+
+ {done && i !== 1 ? '✓' : n}
+
+ {l}
+
+ {i < 2 &&
}
+
+ ))}
+
+
+
+
Verifying your proxy
+
We're running 7 checks against 95.165.72.59:12334 to make sure Discord will work end-to-end.
+
+ {/* progress + ring */}
+
+
+
+
+ 4/7
+
+
+
Testing UDP relay…
+
This usually takes 5–15 seconds.
+
+
+
+ {[
+ ['✓', 'TCP reachability', '12 ms', '#21a655'],
+ ['✓', 'SOCKS5 greeting', 'ok', '#21a655'],
+ ['✓', 'SOCKS5 authentication', 'ok', '#21a655'],
+ ['✓', 'TCP CONNECT to Discord', '38 ms', '#21a655'],
+ ['◐', 'UDP ASSOCIATE', 'running…', '#1e6fd9'],
+ ['·', 'UDP round-trip via STUN', '', '#bbb'],
+ ['·', 'Discord API reachable', '', '#bbb'],
+ ].map(([ic, n, v, c], i) => (
+
+ {ic}
+ {n}
+ {v}
+
+ ))}
+
+
+
+ Back
+ Continue →
+
+
+
+ );
+};
+
+// ═════════════════════════════════════════════════════════════════════════
+// 4 · Network monitor — gauges, sparklines, multi-pane (Wireshark / Activity)
+// ═════════════════════════════════════════════════════════════════════════
+const SketchMonitor = () => {
+ const f = "'Inter', system-ui, sans-serif";
+ const m = "'JetBrains Mono', monospace";
+ return (
+
+
+
+ Drover-Go
+ ● ACTIVE 4m 12s
+
+
+
+ {/* compact endpoint */}
+
+
+ 95.165.72.59:12334
+ SOCKS5 · auth
+ Edit
+
+ {/* meters */}
+
+
+
+
+ {/* sparkline panel */}
+
+
+ Throughput · last 60s
+ peak 3.4 MB/s
+
+
+
+
+
+
+ {/* connections table */}
+
+
+ Proto
+ Endpoint
+ Up
+ Down
+
+ {[
+ ['UDP', 'voice-eu-rtc-1.discord', '142 KB/s', '2.4 MB/s', '#1e6fd9'],
+ ['TCP', 'gateway.discord:443', '8 KB/s', '12 KB/s', '#0c8c7a'],
+ ['TCP', 'cdn.discordapp:443', '0', '380 KB/s', '#0c8c7a'],
+ ['TCP', 'media.discordapp:443', '0', '8 KB/s', '#0c8c7a'],
+ ].map((r, i) => (
+
+ {r[0]}
+ {r[1]}
+ {r[2]}
+ {r[3]}
+
+ ))}
+
+
+ ● Active · 4m 12s
+ Stop
+
+
+
+ );
+};
+const Gauge = ({ label, v, unit, pct, c }) => (
+
+
+ {label}
+
+
+ {v}
+ {unit}
+
+
+
+);
+
+// ═════════════════════════════════════════════════════════════════════════
+// 5 · Inspector / Properties — devtools-style key:value list
+// ═════════════════════════════════════════════════════════════════════════
+const SketchInspector = () => {
+ const f = "'Inter', system-ui, sans-serif";
+ const m = "'JetBrains Mono', monospace";
+ return (
+
+
+
+ Drover-Go
+ 0.4.2
+
+
+ {/* tabs */}
+
+ {['Inspector', 'Network', 'Console', 'Settings'].map((t, i) => (
+
{t}
+ ))}
+
+
+
+
+ } editable />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Run diagnostic
+ ▸ Start proxying
+ 14 props · saved
+
+
+ );
+};
+const Group = ({ title, expanded, children }) => (
+
+
+ {expanded ? '▼' : '▶'} {title}
+
+
{children}
+
+);
+const KV = ({ k, v, t, color, editable }) => (
+
+ {k}
+ {v}
+ {t && {t} }
+
+);
+
+// ═════════════════════════════════════════════════════════════════════════
+// 6 · Command palette + form — Raycast / Linear / cmd+k feel
+// ═════════════════════════════════════════════════════════════════════════
+const SketchPalette = () => {
+ const f = "'Inter', system-ui, sans-serif";
+ const m = "'JetBrains Mono', monospace";
+ return (
+
+
+
+ Drover-Go
+
+
+
+ {/* command bar */}
+
+ ⌘
+
+ esc
+
+ {/* suggestion */}
+
+
+ ▸
+ Check connection
+ ↵
+
+
+ ●
+ Start proxying
+ ⇧↵
+
+
+ {/* Form below — proxy quick-edit */}
+
+
Proxy
+
+
Last run · 7/7 passed
+ {[
+ ['tcp_reachability', '12 ms'],
+ ['socks5_greeting', 'ok'],
+ ['udp_associate', 'relay 1.2.3.4'],
+ ['udp_stun_rtt', '24 ms'],
+ ['discord_api', '200'],
+ ].map(([n, v], i) => (
+
+ ✓
+ {n}
+ {v}
+
+ ))}
+
+
+ ↵ run
+ ⇧↵ start
+ idle · ready
+
+
+
+ );
+};
+const PalField = ({ label, value, flex, width }) => (
+
+ {label}
+ {value}
+
+);
+
+// ═════════════════════════════════════════════════════════════════════════
+// 7 · Big toggle / Hero — TunnelBear / NordVPN big-button connect
+// ═════════════════════════════════════════════════════════════════════════
+const SketchHero = () => {
+ const f = "'Inter', system-ui, sans-serif";
+ const m = "'JetBrains Mono', monospace";
+ return (
+
+
+
+ Drover-Go
+ ● connected
+
+
+
+ {/* Big circular button */}
+
+
+
Discord is protected
+
via 95.165.72.59 · 4m 12s
+
+ {/* Stats row */}
+
+ {[
+ ['↑', '142', 'KB/s'],
+ ['↓', '2.8', 'MB/s'],
+ ['◇', '14', 'tcp'],
+ ['◈', '3', 'udp'],
+ ].map(([k, v, u], i) => (
+
+ ))}
+
+
Disconnect
+
+ ▸ Logs (3 new)
+
+
+
+
+ );
+};
+
+// ═════════════════════════════════════════════════════════════════════════
+// 8 · Toolbar + ribbon — old-school Office / Audacity feel
+// ═════════════════════════════════════════════════════════════════════════
+const SketchToolbar = () => {
+ const f = "'Segoe UI', system-ui, sans-serif";
+ const m = "'Consolas', 'JetBrains Mono', monospace";
+ return (
+
+
+
+ Drover-Go
+ — [Connected]
+
+
+ {/* menu bar */}
+
+ {['File', 'Edit', 'Run', 'View', 'Tools', 'Help'].map(m => (
+ {m}
+ ))}
+
+ {/* ribbon */}
+
+
+
+
+
Host:
+
95.165.72.59
+
Port:
+
12334
+
+
+
✓
+
Authentication
+
User:
+
alice
+
+
+
+
+ {[
+ ['✓', 'TCP reachability', 'PASS · 12 ms'],
+ ['✓', 'SOCKS5 greeting', 'PASS · ok'],
+ ['✓', 'SOCKS5 authentication', 'PASS · ok'],
+ ['✓', 'TCP CONNECT to Discord', 'PASS · 38 ms'],
+ ['✓', 'UDP ASSOCIATE', 'PASS · relay 1.2.3.4'],
+ ['✓', 'UDP round-trip via STUN', 'PASS · 24 ms'],
+ ['✓', 'Discord API reachable', 'PASS · 200'],
+ ].map((r, i) => (
+
+ {r[0]}
+ {r[1]}
+ {r[2]}
+
+ ))}
+
+
+
+ {/* status bar */}
+
+ Ready
+ ● 7/7
+ ↑ 142 KB/s ↓ 2.8 MB/s
+ uptime 4m 12s
+
+
+ );
+};
+const Tool = ({ icon, label, primary, disabled }) => (
+
+ {icon}
+ {label}
+
+);
+const Fieldset = ({ label, children, flex }) => (
+
+ {label}
+ {children}
+
+);
+
+// ═════════════════════════════════════════════════════════════════════════
+// 9 · Modern dev tool — Studio (Linear-ish, restrained dark)
+// ═════════════════════════════════════════════════════════════════════════
+const SketchStudio = () => {
+ const f = "'Inter', sans-serif";
+ return (
+
+
+
+ Drover-Go
+ 0.4.2
+
+
+
+
+
SOCKS5 Proxy
+
+
+
+
+
+ ✓
+ Authentication
+
+
Check connection
+
+
+
+ Status ● All systems
+
+
+ {[
+ ['TCP reachability', '12 ms'],
+ ['SOCKS5 greeting', 'ok'],
+ ['SOCKS5 authentication', 'ok'],
+ ['TCP CONNECT to Discord', '38 ms'],
+ ['UDP ASSOCIATE', 'relay 1.2.3.4'],
+ ['UDP round-trip via STUN', '24 ms'],
+ ['Discord API reachable', '200'],
+ ].map(([n, v], i) => (
+
+ ✓
+ {n}
+ {v}
+
+ ))}
+
+
+
+ Start proxying
+ Stop
+
+
+
+ );
+};
+const StField = ({ label, value, flex, width }) => (
+
+ {label}
+ {value}
+
+);
+
+// ═════════════════════════════════════════════════════════════════════════
+// 10 · Pipeline / DAG — visualize the test chain as a flow
+// ═════════════════════════════════════════════════════════════════════════
+const SketchPipeline = () => {
+ const f = "'Inter', system-ui, sans-serif";
+ const m = "'JetBrains Mono', monospace";
+ const Node = ({ x, y, label, status, sub }) => {
+ const c = status === 'pass' ? '#21a655' : status === 'fail' ? '#e85a4f' : status === 'run' ? '#1e6fd9' : '#aaa';
+ const bg = status === 'pass' ? '#e8f7ee' : status === 'fail' ? '#fdebe9' : status === 'run' ? '#eaf1fb' : '#f4f4f4';
+ return (
+
+
+
+ {label}
+ {sub}
+
+ );
+ };
+ const Arrow = ({ from, to, dashed }) => (
+
+ );
+ return (
+
+
+
+ Drover-Go
+ · pipeline view
+
+
+
+ {/* compact endpoint */}
+
+ SOCKS5
+
+
+ Run ▸
+
+ {/* DAG */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ● 4/7 running
+
+ Cancel
+ Start proxying
+
+
+
+
+ );
+};
+
+// ═════════════════════════════════════════════════════════════════════════
+// 11 · Bauhaus — geometric blocks, primary colors, asymmetric grid
+// ═════════════════════════════════════════════════════════════════════════
+const SketchBauhaus = () => {
+ const f = "'Space Grotesk', sans-serif";
+ return (
+
+
+
+
+
+ DROVER-GO
+ v0.4.2 · ⚙ · — · ×
+
+
+
+
FORM 01
+
SOCKS5 PROXY
+
+
+
+
+
+
CHECK →
+
● START
+
+
STATUS · 7/7
+
ALL CHECKS PASSED
+
+ {[
+ ['TCP reach', '12ms'],
+ ['SOCKS5 greet', 'ok'],
+ ['SOCKS5 auth', 'ok'],
+ ['CONNECT', '38ms'],
+ ['UDP assoc', 'ok'],
+ ['UDP STUN', '24ms'],
+ ['API', '200'],
+ ].map(([n, v], i) => (
+
+
+ {n}
+ {v}
+
+ ))}
+
+
+
+
+ );
+};
+const BaField = ({ label, value, flex, width, dark }) => (
+
+ {label}
+ {value}
+
+);
+
+// ═════════════════════════════════════════════════════════════════════════
+// 12 · Brutalist — hard borders, mono, acid lime
+// ═════════════════════════════════════════════════════════════════════════
+const SketchBrutalist = () => {
+ const m = "'Geist Mono', 'JetBrains Mono', monospace";
+ return (
+
+
+
▶ DROVER-GO
+
v0.4.2
+
+ [⚙] [—] [X]
+
+
+
+
+
// SOCKS5 PROXY
+
+
+
+
+
[X] AUTHENTICATION
+
+
+
+
+
► CHECK CONNECTION
+
+
+
// STATUS · 7/7 OK
+ {[
+ ['[OK]', 'TCP_REACHABILITY', '12ms'],
+ ['[OK]', 'SOCKS5_GREETING', 'ok'],
+ ['[OK]', 'SOCKS5_AUTH', 'ok'],
+ ['[OK]', 'CONNECT_DISCORD', '38ms'],
+ ['[OK]', 'UDP_ASSOCIATE', 'relay 1.2.3.4'],
+ ['[OK]', 'UDP_STUN_RTT', '24ms'],
+ ['[OK]', 'DISCORD_API', '200'],
+ ].map(([s, n, v], i) => (
+
+ {s}
+ {n}
+ {v}
+
+ ))}
+
+
+ ► START
+ ■ STOP
+
+
+
+ );
+};
+const BrField = ({ label, value, flex, width }) => (
+
+ {label}
+ {value}
+
+);
+
+// ═════════════════════════════════════════════════════════════════════════
+// SketchesApp
+// ═════════════════════════════════════════════════════════════════════════
+
+const SKETCHES = [
+ { id: 'fluent', label: '01 · Fluent / Win11 native', el: },
+ { id: 'sidebar', label: '02 · IDE sidebar (VS Code-ish)', el: },
+ { id: 'wizard', label: '03 · Wizard / stepper', el: },
+ { id: 'monitor', label: '04 · Network monitor (gauges)', el: },
+ { id: 'inspector', label: '05 · Inspector / properties', el: },
+ { id: 'palette', label: '06 · Command palette (cmd+k)', el: },
+ { id: 'hero', label: '07 · Big toggle (VPN-style)', el: },
+ { id: 'toolbar', label: '08 · Toolbar / ribbon (classic)', el: },
+ { id: 'studio', label: '09 · Studio (Linear-ish)', el: },
+ { id: 'pipeline', label: '10 · Pipeline / DAG view', el: },
+ { id: 'bauhaus', label: '11 · Bauhaus blocks', el: },
+ { id: 'brutalist', label: '12 · Brutalist (mono+lime)', el: },
+];
+
+const SketchesApp = () => (
+
+
+
+
What changed
+
Removed the "marketing" stylings (ticket, synthwave, soft pastel, editorial serif). Replaced with software-native paradigms — each card explores a different way to organize the same UI , not just a different paint job:
+
+ Layout patterns: sidebar IDE · wizard/stepper · pipeline/DAG · ribbon · palette+form · big-toggle hero
+ Element vocab: gauges + sparklines · key/value inspector · fieldsets · tabs+breadcrumbs · status bar · toolbar buttons
+ Aesthetic: Fluent (Win11) · classic Office · Linear/Studio · brutalist · Bauhaus blocks
+
+
Tell me which paradigm/aesthetic combo to take forward.
+
+
+
+ {SKETCHES.map(s => (
+
+ {s.el}
+
+ ))}
+
+
+);
+
+// Expose individual sketches so other files (e.g. shortlist) can render them.
+Object.assign(window, {
+ SketchFluent, SketchSidebar, SketchWizard, SketchMonitor,
+ SketchInspector, SketchPalette, SketchHero, SketchToolbar,
+ SketchStudio, SketchPipeline, SketchBauhaus, SketchBrutalist,
+});
diff --git a/docs/design/v2/drover-studio.jsx b/docs/design/v2/drover-studio.jsx
new file mode 100644
index 0000000..d09db9a
--- /dev/null
+++ b/docs/design/v2/drover-studio.jsx
@@ -0,0 +1,309 @@
+// drover-studio.jsx — Studio: modern, calm, Linear-ish. Subtle blue accent.
+
+const STD = {
+ bg: '#0e0f12', chrome: '#0a0b0d', panel: '#15171b', panel2: '#1a1c21',
+ border: '#22252b', borderSoft: '#1a1c21',
+ text: '#e7e9ee', dim: '#9095a0', dimmer: '#5e6470',
+ accent: '#6c8aff', accentSoft: 'rgba(108,138,255,0.14)',
+ danger: '#ff7d70', danSoft: 'rgba(255,125,112,0.12)',
+ warn: '#f5b13d', warnSoft: 'rgba(245,177,61,0.12)',
+ pass: '#6cd391', passSoft: 'rgba(108,211,145,0.12)',
+ skip: '#7e848e',
+ inputBg: '#0e0f12',
+ primaryFg: '#0c1226',
+};
+
+function StudioWindow({ initial }) {
+ const t = STD;
+ const D = window.useDrover(initial);
+ const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
+ const fontUI = '"Inter","Segoe UI",system-ui,-apple-system,sans-serif';
+ const fontMono = '"JetBrains Mono",ui-monospace,monospace';
+ const isActive = D.phase === 'active';
+
+ return (
+
+
+
+
+
+
+
+ D.update({ host: v })} onSubmit={D.runCheck}
+ placeholder="95.165.72.59 или example.com"/>
+
+
+ D.update({ port: v.replace(/\D/g,'') })} onSubmit={D.runCheck}
+ placeholder="12334" inputMode="numeric"/>
+
+
+
+
+ { D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('std-login')?.focus(),30); }}>
+ Authentication
+
+
+
+ D.update({ login: v })} onSubmit={D.runCheck} placeholder="user" disabled={!D.form.auth}/>
+
+
+ D.update({ password: v })} onSubmit={D.runCheck} placeholder="••••••"
+ disabled={!D.form.auth}/>
+
+
+
+
+ {D.phase==='checking' ? 'Checking…' : 'Check connection'}
+
+
+
+
+ {D.phase === 'idle'
+ ?
+
+ Ready to check
+
+ : }
+
+
+
+
+
+
+
+
+ {isActive && (
+
+ } val={window.fmtBytes(D.stats.up)} fontMono={fontMono} t={t}/>
+ } val={window.fmtBytes(D.stats.down)} fontMono={fontMono} t={t}/>
+
+
+
+
+ )}
+
+
+
+
+
+ );
+}
+
+function StdTitle({ t }) {
+ const cell = { width: 44, height: 32, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', color: t.dim };
+ return (
+
+
+
+ Drover-Go
+ 0.4.2
+
+
+
+ );
+}
+
+function StdSection({ t, title, right, children }) {
+ return (
+
+ {(title || right) && (
+
+ {title &&
{title}
}
+ {right &&
{right}
}
+
+ )}
+
+ {children}
+
+
+ );
+}
+function StdField({ t, label, children, style }) {
+ return
+ {label} {children}
+ ;
+}
+function StdInput({ t, fontUI, value, onChange, type, placeholder, onSubmit, disabled, id, inputMode }) {
+ return onChange(e.target.value)} placeholder={placeholder}
+ onKeyDown={e => e.key === 'Enter' && onSubmit?.()}
+ style={{
+ background: t.inputBg, color: disabled ? t.dimmer : t.text,
+ border:`1px solid ${t.border}`, borderRadius: 5, padding:'8px 10px',
+ fontSize: 13, fontFamily: fontUI, outline:'none', width:'100%', boxSizing:'border-box',
+ }}/>;
+}
+function StdCheck({ t, checked, onChange, children }) {
+ return (
+
+
+ {checked && }
+
+ onChange(e.target.checked)} style={{display:'none'}}/>
+ {children}
+
+ );
+}
+function StdStatus({ t, D, palette, fontMono }) {
+ const allOk = D.lastSummary?.failed === 0;
+ return (
+
+ {D.phase === 'checking'
+ ?
+
+ Running diagnostics…
+
+ :
+ {allOk ? 'All checks passed. Ready to start.'
+ : `${D.lastSummary?.failed} of ${D.tests.length} checks failed. Some features won't work.`}
+
}
+
+ {D.tests.map(test => {
+ const r = D.results[test.id];
+ const state = r?.result || (D.running === test.id ? 'running' : 'pending');
+ return (
+
+
+
+ {test.label}
+ {r?.metric}
+ {r?.result === 'failed' && (
+ D.toggleExpand(test.id)} style={{
+ background:'transparent', border:'none', cursor:'pointer', padding: 4, color: t.dim,
+ }}>
+ )}
+
+ {r?.result === 'failed' && r.expanded && (
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
+function StdStartBtn({ t, D, fontUI }) {
+ const allFailed = D.lastSummary && D.lastSummary.failed === D.tests.length;
+ const ok = D.phase === 'checked' && !allFailed;
+ const active = D.phase === 'active';
+ const warning = active && (D.lastSummary?.failed || 0) > 0;
+ if (active) {
+ const c = warning ? t.warn : t.pass;
+ return (
+
+
+ Active{warning ? ' · UDP fallback' : ''}
+
+ );
+ }
+ return Start proxying ;
+}
+function StdStopBtn({ t, D, fontUI }) {
+ const enabled = D.phase === 'active';
+ return Stop ;
+}
+function StdStat({ icon, val, lbl, fontMono, t }) {
+ return
+ {icon}{val}
+ {lbl && {lbl} }
+
;
+}
+function StdLogs({ t, D, fontMono }) {
+ return (
+
+
D.setLogsOpen(!D.logsOpen)} style={{
+ width:'100%', padding:'9px 16px', display:'flex', alignItems:'center', gap: 9,
+ background:'transparent', border:'none', color: t.dim, cursor:'pointer', fontSize: 12,
+ }}>
+
+ Logs
+ {D.logs.length}
+
+ {D.logsOpen && (
+ <>
+
+ {[['Copy all', () => navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n'))],
+ ['Clear', D.clearLogs], ['Open log file', null]].map(([l, fn]) => (
+ {l}
+ ))}
+
+
el && (el.scrollTop = el.scrollHeight)}
+ style={{ maxHeight: 130, overflowY:'auto', padding:'8px 16px',
+ fontFamily: fontMono, fontSize: 11, lineHeight: 1.6, color: t.dim, background: t.panel }}>
+ {D.logs.map((l,i) => (
+
+ {window.fmtTime(l.t)} {' '}
+ [{l.level}] {' '}
+ {l.msg}
+
+ ))}
+
+ >
+ )}
+
+ );
+}
+
+window.StudioWindow = StudioWindow;
diff --git a/docs/design/v2/drover-wizard-live.jsx b/docs/design/v2/drover-wizard-live.jsx
new file mode 100644
index 0000000..5a210af
--- /dev/null
+++ b/docs/design/v2/drover-wizard-live.jsx
@@ -0,0 +1,455 @@
+// drover-wizard-live.jsx — Wizard / stepper variant.
+// 3 steps: Configure → Verify → Connect. State derives from D.phase.
+// Step 1 = idle (form). Step 2 = checking/checked (diagnostics). Step 3 = active (running proxy).
+
+const WIZARD_THEME = {
+ l: {
+ bg: '#fafafa',
+ chrome: '#ffffff',
+ panel: '#ffffff',
+ panelAlt: '#f6f7f9',
+ border: '#ececec',
+ borderHard:'#dcdcdc',
+ text: '#1a1a1a',
+ dim: '#666666',
+ dimmer: '#9aa0a6',
+ accent: '#1e6fd9',
+ accentSoft:'#eef4fc',
+ danger: '#c0463f',
+ warn: '#a8731e',
+ pass: '#21a655',
+ skip: '#888888',
+ },
+ d: {
+ bg: '#0f1115',
+ chrome: '#171a20',
+ panel: '#1a1d23',
+ panelAlt: '#13151a',
+ border: '#2a2d33',
+ borderHard:'#3a3d45',
+ text: '#e6e8ec',
+ dim: '#9aa0a6',
+ dimmer: '#5d6168',
+ accent: '#5b9bf0',
+ accentSoft:'#1a253a',
+ danger: '#e57373',
+ warn: '#d9a155',
+ pass: '#5cba8b',
+ skip: '#7c8088',
+ },
+};
+
+function WizardWindow({ mode = 'light', initial }) {
+ const themeKey = mode === 'dark' ? 'd' : 'l';
+ const t = WIZARD_THEME[themeKey];
+ const D = window.useDrover(initial);
+ const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
+ const fontUI = "'Inter','Segoe UI',system-ui,sans-serif";
+ const fontMono = "'JetBrains Mono','SF Mono',ui-monospace,Consolas,monospace";
+
+ // Determine current step from phase
+ const phase = D.phase;
+ const step = phase === 'idle' ? 1
+ : (phase === 'checking' || phase === 'checked') ? 2
+ : 3; // active
+
+ return (
+
+
+
+ {/* Stepper */}
+
+ {[[1, 'Configure'], [2, 'Verify'], [3, 'Connect']].map(([n, label], i) => {
+ const done = step > n;
+ const current = step === n;
+ const dim = step < n;
+ return (
+
+
+ {done ? '✓' : n}
+ {label}
+
+ {i < 2 && n ? t.accent : t.borderHard,
+ margin: '0 10px', transition: 'background .2s',
+ }} />}
+
+ );
+ })}
+
+
+ {/* Step content */}
+
+ {step === 1 && }
+ {step === 2 && }
+ {step === 3 && }
+
+
+ {/* Footer */}
+
+ {
+ if (step === 2) D.stopProxy?.(), D.setPhase('idle');
+ if (step === 3) D.stopProxy();
+ }} disabled={step === 1}>
+ {step === 3 ? 'Disconnect' : 'Back'}
+
+ {
+ if (step === 1) D.runCheck();
+ else if (step === 2) D.startProxy();
+ }}
+ disabled={
+ (step === 1 && (D.phase === 'checking')) ||
+ (step === 2 && (D.phase === 'checking' || D.lastSummary?.failed === D.tests.length)) ||
+ (step === 3)
+ }>
+ {step === 1 && 'Verify →'}
+ {step === 2 && (D.phase === 'checking' ? 'Verifying…' : 'Connect →')}
+ {step === 3 && 'Connected'}
+
+
+
+ );
+}
+
+function WizardTitleBar({ t }) {
+ return (
+
+
+
Drover-Go · Setup
+
+
+
+
+
+
+ );
+}
+function WizTitleBtn({ children, t, hoverBg, hoverFg }) {
+ const [hover, setHover] = React.useState(false);
+ return (
+
setHover(true)} onMouseLeave={() => setHover(false)}
+ style={{ width: 38, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
+ cursor: 'pointer',
+ background: hover ? (hoverBg || 'rgba(127,127,127,.1)') : 'transparent',
+ color: hover && hoverFg ? hoverFg : 'inherit',
+ borderRadius: 4,
+ }}>{children}
+ );
+}
+
+// ─── Step 1: Configure ───────────────────────────────────────────────────
+function WizConfigure({ t, D, fontMono }) {
+ const inputStyle = (disabled) => ({
+ height: 36, background: t.panel, color: disabled ? t.dimmer : t.text,
+ border: `1px solid ${t.borderHard}`, borderRadius: 6, padding: '0 12px',
+ fontFamily: fontMono, fontSize: 13, outline: 'none', width: '100%', boxSizing: 'border-box',
+ transition: 'border-color .12s, box-shadow .12s',
+ });
+ const onFocus = (e) => { e.target.style.borderColor = t.accent; e.target.style.boxShadow = `0 0 0 3px ${t.accentSoft}`; };
+ const onBlur = (e) => { e.target.style.borderColor = t.borderHard; e.target.style.boxShadow = 'none'; };
+ return (
+ <>
+
+
Configure your proxy
+
+ Введите адрес SOCKS5-сервера. На следующем шаге мы проверим, что Discord будет работать через него.
+
+
+
+
+ i
+ Нажмите Verify → , чтобы продолжить.
+
+ >
+ );
+}
+
+// ─── Step 2: Verify ───────────────────────────────────────────────────
+function WizVerify({ t, D, fontMono, palette, themeKey }) {
+ const phase = D.phase;
+ const total = D.tests.length;
+ const completed = Object.keys(D.results).length;
+ const failed = D.lastSummary?.failed ?? 0;
+ const ringFrac = phase === 'checked' ? 1 : completed / total;
+ const ringDash = 163.4;
+ return (
+ <>
+
+
+ {phase === 'checking' ? 'Verifying your proxy' : (failed === 0 ? 'All checks passed' : 'Some checks failed')}
+
+
+ {phase === 'checking'
+ ? <>Запускаем 7 проверок против {D.form.host}:{D.form.port} >
+ : (failed === 0
+ ? <>Прокси работает. Discord — голос, чат и демонстрация — будет работать через него.>
+ : <>{failed} из {total} проверок не прошли. Часть функций работать не будет.>)}
+
+
+
+ {/* progress + ring */}
+
+
+
+ 0 ? t.danger : phase === 'checked' ? t.pass : t.accent}
+ strokeWidth="6" strokeLinecap="round"
+ strokeDasharray={ringDash}
+ strokeDashoffset={ringDash * (1 - ringFrac)}
+ transform="rotate(-90 32 32)"
+ style={{ transition: 'stroke-dashoffset .35s, stroke .2s' }} />
+
+ {phase === 'checked' ? `${total - failed}/${total}` : `${completed}/${total}`}
+
+
+
+
+ {phase === 'checking' ? <>Testing {D.tests.find(x => x.id === D.running)?.label || '…'} > : (failed === 0 ? 'Готово к подключению' : 'Завершено с ошибками')}
+
+
+ {phase === 'checking' ? 'This usually takes 5–15 seconds.' : 'См. отчёт ниже.'}
+
+
+
+
+ {/* test list */}
+
+ {D.tests.map((test) => {
+ const r = D.results[test.id];
+ const state = r?.result || (D.running === test.id ? 'running' : 'pending');
+ return (
+
+
+
+ {test.label}
+
+ {r?.metric || (state === 'running' ? 'running…' : '')}
+
+ {r?.result === 'failed' && (
+ D.toggleExpand(test.id)}
+ style={{ width: 20, height: 20, padding: 0, border: 'none', background: 'transparent', cursor: 'pointer' }}>
+
+
+ )}
+
+ {r?.result === 'failed' && r.expanded && (
+
+
{r.error}
+
{r.hint}
+
navigator.clipboard?.writeText(`[${test.label}] ${r.error} — ${r.metric}`)}
+ style={{ marginTop: 6, padding: '3px 8px', fontSize: 11, fontFamily: fontMono,
+ background: t.panel, color: t.dim, border: `1px solid ${t.borderHard}`,
+ borderRadius: 4, cursor: 'pointer' }}>copy error
+
+ )}
+
+ );
+ })}
+
+ >
+ );
+}
+
+// ─── Step 3: Connect (active) ───────────────────────────────────────────
+function WizConnect({ t, D, fontMono }) {
+ const stats = D.stats;
+ const failed = D.lastSummary?.failed ?? 0;
+ return (
+ <>
+
+
+ 0 ? t.warn : t.pass,
+ display: 'inline-block',
+ }} />
+ {failed > 0 ? 'Connected · UDP fallback' : 'Connected'}
+
+
+ Discord routes through {D.form.host}:{D.form.port}
+ {' · '}{window.fmtUptime(stats.uptimeS)}
+
+
+
+ {/* stats grid */}
+
+
+
+
+ 0 ? t.warn : t.pass} />
+
+
+ {/* logs panel inline */}
+
+
+ RECENT LOG
+ navigator.clipboard?.writeText(D.logs.map(l => `[${l.level}] ${l.msg}`).join('\n'))}
+ style={{ marginLeft: 'auto', padding: '2px 8px', fontSize: 11, fontFamily: fontMono,
+ background: 'transparent', color: t.dim, border: `1px solid ${t.borderHard}`,
+ borderRadius: 4, cursor: 'pointer' }}>copy
+ clear
+
+
el && (el.scrollTop = el.scrollHeight)}>
+ {D.logs.slice(-30).map((l, i) => (
+
+ {window.fmtTime(l.t)}
+ {' '}
+ [{l.level}]
+ {' '}
+ {l.msg}
+
+ ))}
+
+
+ >
+ );
+}
+
+function WizStat({ t, fontMono, label, value, accent }) {
+ return (
+
+ );
+}
+
+function WizardPrimaryBtn({ t, onClick, disabled, children }) {
+ const [hover, setHover] = React.useState(false);
+ return (
+
setHover(true)} onMouseLeave={() => setHover(false)}
+ style={{
+ height: 32, padding: '0 18px', borderRadius: 6, border: 'none',
+ background: disabled ? t.panelAlt : (hover ? '#1a5fbf' : t.accent),
+ color: disabled ? t.dimmer : '#fff',
+ fontWeight: 600, fontSize: 13, cursor: disabled ? 'not-allowed' : 'pointer',
+ boxShadow: disabled ? 'none' : 'inset 0 -1px 0 rgba(0,0,0,.18)',
+ }}>{children}
+ );
+}
+function WizardSecondaryBtn({ t, onClick, disabled, children }) {
+ const [hover, setHover] = React.useState(false);
+ return (
+
setHover(true)} onMouseLeave={() => setHover(false)}
+ style={{
+ height: 32, padding: '0 14px', borderRadius: 6,
+ background: hover && !disabled ? t.panelAlt : t.panel,
+ color: disabled ? t.dimmer : t.text,
+ border: `1px solid ${t.borderHard}`,
+ fontSize: 13, cursor: disabled ? 'not-allowed' : 'pointer',
+ }}>{children}
+ );
+}
+
+window.WizardWindow = WizardWindow;
diff --git a/docs/design/v2/drover-workshop.jsx b/docs/design/v2/drover-workshop.jsx
new file mode 100644
index 0000000..8a0b5c4
--- /dev/null
+++ b/docs/design/v2/drover-workshop.jsx
@@ -0,0 +1,322 @@
+// drover-workshop.jsx — Workshop: industrial slate + amber accent. Subtle texture.
+
+const WSH = {
+ bg: '#181a1d', chrome: '#121417', panel: '#1f2226', panel2: '#23272c',
+ border: '#2c3036', borderSoft: '#222529',
+ text: '#e5e3df', dim: '#9a958c', dimmer: '#5e5b56',
+ accent: '#e89a3c', accentSoft: 'rgba(232,154,60,0.15)',
+ danger: '#d56654', warn: '#d4a248', pass: '#7fb37b', skip: '#7c7872',
+ inputBg: '#141619',
+ primaryFg: '#1a120a',
+};
+
+function WorkshopWindow({ initial }) {
+ const t = WSH;
+ const D = window.useDrover(initial);
+ const palette = { pending: t.dimmer, running: t.accent, passed: t.pass, failed: t.danger, skipped: t.skip };
+ const fontUI = '"IBM Plex Sans","Inter",system-ui,sans-serif';
+ const fontMono = '"IBM Plex Mono","JetBrains Mono",ui-monospace,monospace';
+ const isActive = D.phase === 'active';
+
+ return (
+
+ {/* subtle grain */}
+
+
+ {/* title — workshop label tag */}
+
+
+
+ Drover-Go
+ v0.4.2
+
+
+ {[
, , ].map((ic,i) => (
+ {ic}
+ ))}
+
+
+
+
+ {/* form */}
+
SOCKS5 PROXY
+
+
+
+ D.update({ host: v })}
+ placeholder="95.165.72.59 / example.com" onSubmit={D.runCheck} fontUI={fontUI}/>
+
+
+ D.update({ port: v.replace(/\D/g,'') })}
+ placeholder="12334" inputMode="numeric" onSubmit={D.runCheck} fontUI={fontUI}/>
+
+
+
+
{ D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('wsh-login')?.focus(),30); }}>
+ AUTHENTICATION
+
+
+
+ D.update({ login: v })} placeholder="user" onSubmit={D.runCheck} fontUI={fontUI}/>
+
+
+ D.update({ password: v })} placeholder="••••••" onSubmit={D.runCheck} fontUI={fontUI}/>
+
+
+
+
{D.phase==='checking' ? 'Checking…' : 'Check connection'}
+
+
+
+
STATUS
+
+ {D.phase === 'idle'
+ ?
+
+ Ready to check
+
+ :
}
+
+
+
+
+
+
+
+
+ {isActive && (
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+ );
+}
+
+function WshHead({ t, children, right }) {
+ return (
+
+
+
+ {children}
+
+ {right &&
{right}
}
+
+ );
+}
+function WshField({ t, label, children, style }) {
+ return
+ {label} {children}
+ ;
+}
+function WshInput({ t, value, onChange, type, placeholder, onSubmit, disabled, id, fontUI, inputMode }) {
+ return
onChange(e.target.value)} placeholder={placeholder}
+ onKeyDown={e => e.key === 'Enter' && onSubmit?.()}
+ style={{
+ background: t.inputBg, color: disabled ? t.dimmer : t.text,
+ border:`1px solid ${t.border}`, borderRadius: 1, padding:'8px 10px',
+ fontSize: 13, fontFamily: fontUI, outline:'none', width:'100%', boxSizing:'border-box',
+ }}/>;
+}
+function WshSwitch({ t, checked, onChange, children }) {
+ return (
+
+
+
+
+ onChange(e.target.checked)} style={{ display:'none' }}/>
+ {children}
+
+ );
+}
+function WshStatus({ t, D, palette, fontMono }) {
+ return (
+
+ {D.phase === 'checking'
+ ?
+
+ Running diagnostics…
+
+ :
+ {D.lastSummary?.failed === 0
+ ? 'All checks passed. Ready to start.'
+ : `${D.lastSummary?.failed} of ${D.tests.length} checks failed. Some features won't work.`}
+
}
+
+ {D.tests.map(test => {
+ const r = D.results[test.id];
+ const state = r?.result || (D.running === test.id ? 'running' : 'pending');
+ return (
+
+
+
+ {test.label}
+ {r?.metric}
+ {r?.result === 'failed' && (
+ D.toggleExpand(test.id)} style={{
+ background:'transparent', border:'none', cursor:'pointer', padding: 4, color: t.dim,
+ }}>
+ )}
+
+ {r?.result === 'failed' && r.expanded && (
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
+function WshStartBtn({ t, D, fontUI }) {
+ const allFailed = D.lastSummary && D.lastSummary.failed === D.tests.length;
+ const ok = D.phase === 'checked' && !allFailed;
+ const active = D.phase === 'active';
+ const warning = active && (D.lastSummary?.failed||0) > 0;
+ if (active) {
+ const c = warning ? t.warn : t.pass;
+ return (
+
+
+ Active{warning ? ' · UDP warn' : ''}
+
+ );
+ }
+ return
Start proxying ;
+}
+function WshStopBtn({ t, D, fontUI }) {
+ const enabled = D.phase === 'active';
+ return
Stop ;
+}
+function Stat({ val, lbl, t, fontMono, hl }) {
+ return
+ {lbl}
+ {val}
+
;
+}
+function WshLogs({ t, D, fontMono }) {
+ return (
+
+
D.setLogsOpen(!D.logsOpen)} style={{
+ width:'100%', padding:'9px 16px', display:'flex', alignItems:'center', gap: 10,
+ background:'transparent', border:'none', color: t.dim, cursor:'pointer', fontSize: 11,
+ letterSpacing: 1.5, textTransform:'uppercase',
+ }}>
+
+ Logs
+ {D.logs.length} lines
+
+ {D.logsOpen && (
+ <>
+
+ {[['copy', () => navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n'))],
+ ['clear', D.clearLogs], ['file', null]].map(([l, fn]) => (
+ {l}
+ ))}
+
+
el && (el.scrollTop = el.scrollHeight)}
+ style={{ maxHeight: 130, overflowY:'auto', padding:'8px 16px',
+ fontFamily: fontMono, fontSize: 10.5, lineHeight: 1.6, color: t.dim, background: t.panel }}>
+ {D.logs.map((l,i) => (
+
+ {window.fmtTime(l.t)} {' '}
+ [{l.level}] {' '}
+ {l.msg}
+
+ ))}
+
+ >
+ )}
+
+ );
+}
+
+window.WorkshopWindow = WorkshopWindow;