4 Commits

Author SHA1 Message Date
root 113616b039 docs/design/v2: add 12-variant React design archive
Release / release (push) Successful in 3m19s
Stash the full claude.ai/design output (12 JSX variants — brutalist,
classic, cli, compact, fluent-live, glass, hero-live, minimal,
sketches, studio, wizard-live, workshop — plus shared hooks and a
standalone HTML preview) for reference when we get to the Wails
frontend in Phase 6/7.

Source archive: C:\Users\root\Downloads\app(1).zip (~1MB).

Not wired into any build target yet — current GUI is the temporary
MessageBox stub. Pulling these in is the goal of the Wails phase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 03:12:02 +03:00
root 5da30ad058 GUI subsystem: -H=windowsgui + AttachConsole, MB_TOPMOST on test window
Build / test (push) Successful in 1m13s
Build / build-windows (push) Successful in 55s
drover.exe is now a GUI subsystem binary:
  - Double-click no longer flashes a console window — a clean
    smoke-test message box opens immediately.
  - When run from cmd / PowerShell, AttachConsole reattaches stdout
    and stderr to the parent terminal so '--version', 'check', etc.
    still print as expected.
  - MB_TOPMOST flag added to MessageBox so the window can't be
    obscured by other windows on launch (this was the actual cause
    of "I clicked but nothing happened" reports).

Verified locally: built with GOOS=windows GOARCH=amd64 -H=windowsgui;
running drover-gui.exe --version prints to PowerShell, drover-gui.exe
gui shows the message box on top of the active window.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 03:11:50 +03:00
root 7306f6be6d workflows: add actions/cache for Go modules + apt packages
Build / test (push) Successful in 1m34s
Build / build-windows (push) Successful in 54s
Release / release (push) Successful in 3m12s
Pay ~10s per job to install Node so actions/cache@v4 works, then
restore Go module cache (~/go/pkg/mod + ~/.cache/go-build) and apt
package cache (/var/cache/apt/archives + /var/lib/apt/lists).

Expected impact:
- Cold run (first push after this commit): same as before, plus ~10s
  Node install. Cache populates.
- Warm runs: Go modules instant instead of ~15s download, apt install
  ~10s instead of ~60s. Net save ~60s per run.

Cache keys:
- Go: go-${runner.os}-${hashFiles('**/go.sum')} — invalidates on any
  go.sum change.
- Apt: apt-trixie-wine-innoextract-v1 — bump version (-v2, -v3) when
  the package list in 'Install Wine + Inno Setup' step changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 03:04:31 +03:00
root 15e4156802 main.go: open test window on bare 'drover' (no subcommand)
Build / test (push) Successful in 19s
Build / build-windows (push) Successful in 6s
Release / release (push) Successful in 1m48s
Without this, double-clicking drover.exe printed CLI help to stdout —
fine in a terminal but invisible to a user who just clicked an icon.
Adding a RunE on the root command opens the smoke-test message box
when no subcommand was given. CLI flags (--version, --help) and
explicit subcommands keep their old behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 03:02:02 +03:00
26 changed files with 6702 additions and 13 deletions
+38 -10
View File
@@ -17,25 +17,36 @@ concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true
# We deliberately avoid actions/cache@v4 and actions/upload-artifact@v4 in this
# workflow. They are Node.js-based and the runner image (golang:*) has no Node.
# Installing Node would add ~10s per job for every run; for now we accept
# that go module downloads happen on each invocation (~20-30s once warm).
# Real release artifacts are produced by release.yml via curl + Forgejo API.
# Node.js is installed at the start of every job so actions/cache@v4
# (which uses Node) can run. Installing Node costs ~10s; the resulting
# Go module + apt cache restore typically saves 30-60s on warm runs.
jobs:
test:
runs-on: go
steps:
- name: Install Node (required by actions/cache)
run: |
apt-get update >/dev/null
apt-get install -y --no-install-recommends nodejs >/dev/null
- name: Checkout
env:
# Gitea Actions doesn't auto-export GITHUB_TOKEN to the shell;
# we have to read it from secrets and surface it explicitly.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git clone --no-checkout "https://x-access-token:${GITHUB_TOKEN}@git.okcu.io/${GITHUB_REPOSITORY}.git" /tmp/src
git -C /tmp/src checkout "$GITHUB_SHA"
cp -a /tmp/src/. .
- name: Cache Go modules
uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: go-${{ runner.os }}-${{ hashFiles('**/go.sum') }}
restore-keys: go-${{ runner.os }}-
- name: Vet
run: go vet ./...
- name: Test with race
@@ -50,15 +61,28 @@ jobs:
runs-on: go
needs: test
steps:
- name: Install Node (required by actions/cache)
run: |
apt-get update >/dev/null
apt-get install -y --no-install-recommends nodejs >/dev/null
- name: Checkout
env:
# Gitea Actions doesn't auto-export GITHUB_TOKEN to the shell;
# we have to read it from secrets and surface it explicitly.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git clone --no-checkout "https://x-access-token:${GITHUB_TOKEN}@git.okcu.io/${GITHUB_REPOSITORY}.git" /tmp/src
git -C /tmp/src checkout "$GITHUB_SHA"
cp -a /tmp/src/. .
- name: Cache Go modules
uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: go-${{ runner.os }}-${{ hashFiles('**/go.sum') }}
restore-keys: go-${{ runner.os }}-
- name: Cross-compile drover.exe (windows/amd64)
env:
GOOS: windows
@@ -68,7 +92,11 @@ jobs:
SHORT_SHA="${GITHUB_SHA:0:7}"
BUILD_DATE="$(date -u +%Y-%m-%d)"
mkdir -p bin
go build -trimpath -ldflags="-s -w \
# -H=windowsgui = subsystem WINDOWS, double-click no longer
# flashes a console window. main.go calls AttachConsole on
# startup so CLI invocations from cmd/PowerShell still print
# to the parent terminal.
go build -trimpath -ldflags="-s -w -H=windowsgui \
-X main.Version=dev-${SHORT_SHA} \
-X main.Commit=${SHORT_SHA} \
-X main.BuildDate=${BUILD_DATE}" \
+30 -1
View File
@@ -16,6 +16,11 @@ jobs:
release:
runs-on: go
steps:
- name: Install Node (required by actions/cache)
run: |
apt-get update >/dev/null
apt-get install -y --no-install-recommends nodejs >/dev/null
- name: Checkout
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -24,6 +29,26 @@ jobs:
git -C /tmp/src checkout "$GITHUB_SHA"
cp -a /tmp/src/. .
# Cache Go modules — saves ~10-20s on warm runs.
- name: Cache Go modules
uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: go-${{ runner.os }}-${{ hashFiles('**/go.sum') }}
restore-keys: go-${{ runner.os }}-
# Cache apt downloads — saves ~50s on the wine + innoextract install.
# Bump the cache key (-v2, -v3, ...) when the package list changes.
- name: Cache apt packages
uses: actions/cache@v4
with:
path: |
/var/cache/apt/archives
/var/lib/apt/lists
key: apt-trixie-wine-innoextract-v1
- name: Extract version from tag
id: version
run: |
@@ -42,7 +67,11 @@ jobs:
BUILD_DATE="$(date -u +%Y-%m-%d)"
SHORT_SHA="${GITHUB_SHA:0:7}"
mkdir -p dist
go build -trimpath -ldflags="-s -w \
# -H=windowsgui makes drover.exe a GUI subsystem binary so the
# double-click experience doesn't flash a console window. main.go
# calls AttachConsole on startup so CLI runs still print to the
# parent terminal when launched from cmd/PowerShell.
go build -trimpath -ldflags="-s -w -H=windowsgui \
-X main.Version=${{ steps.version.outputs.version }} \
-X main.Commit=${SHORT_SHA} \
-X main.BuildDate=${BUILD_DATE}" \
+5
View File
@@ -0,0 +1,5 @@
//go:build !windows
package main
func attachToParentConsole() {}
+46
View File
@@ -0,0 +1,46 @@
//go:build windows
package main
import (
"os"
"syscall"
"golang.org/x/sys/windows"
)
// attachToParentConsole reconnects stdin/stdout/stderr to the console of the
// process that launched us, if any. It is a no-op when the binary was started
// from Explorer (double-click) — there is no parent console to attach to.
//
// Why we need this: the binary is built with -H=windowsgui, which means
// Windows treats it as a GUI application and does NOT allocate a console
// when it starts. That gives a clean double-click experience (no flashing
// console window). But it also disconnects fmt.Println output when the
// user runs us from cmd.exe / PowerShell. Calling AttachConsole here
// re-attaches us to the parent's console so CLI output works as expected.
func attachToParentConsole() {
const ATTACH_PARENT_PROCESS = ^uint32(0) // (DWORD)-1
kernel32 := windows.NewLazySystemDLL("kernel32.dll")
attachConsole := kernel32.NewProc("AttachConsole")
r, _, _ := attachConsole.Call(uintptr(ATTACH_PARENT_PROCESS))
if r == 0 {
// AttachConsole failed — most likely there is no parent console
// (we were started from Explorer). That's fine: GUI mode is the
// default UX and stdout/stderr just go nowhere.
return
}
// Re-bind os.Stdout / os.Stderr to the freshly-attached console.
if h, err := syscall.GetStdHandle(syscall.STD_OUTPUT_HANDLE); err == nil && h != 0 {
os.Stdout = os.NewFile(uintptr(h), "stdout")
}
if h, err := syscall.GetStdHandle(syscall.STD_ERROR_HANDLE); err == nil && h != 0 {
os.Stderr = os.NewFile(uintptr(h), "stderr")
}
if h, err := syscall.GetStdHandle(syscall.STD_INPUT_HANDLE); err == nil && h != 0 {
os.Stdin = os.NewFile(uintptr(h), "stdin")
}
}
+4 -2
View File
@@ -38,8 +38,10 @@ func showTestWindow() {
bodyW, _ := windows.UTF16PtrFromString(body)
titleW, _ := windows.UTF16PtrFromString(title)
// MB_OK | MB_ICONINFORMATION | MB_SETFOREGROUND
const flags = 0x00000000 | 0x00000040 | 0x00010000
// MB_OK | MB_ICONINFORMATION | MB_SETFOREGROUND | MB_TOPMOST
// MB_TOPMOST is essential — without it the message box can pop up
// behind other windows and the user thinks nothing happened.
const flags = 0x00000000 | 0x00000040 | 0x00010000 | 0x00040000
messageBox.Call(
0,
+13
View File
@@ -22,6 +22,12 @@ var (
var configPath string
func main() {
// On Windows the binary is linked with -H=windowsgui so a double-click
// doesn't flash a console window. When the user runs us from cmd or
// PowerShell we still want stdout/stderr to land in their terminal —
// AttachConsole(ATTACH_PARENT_PROCESS) wires that up. No-op elsewhere.
attachToParentConsole()
// Inject our build version so the updater package can stamp it on the
// User-Agent header it sends to git.okcu.io.
updater.SetVersion(Version)
@@ -39,6 +45,13 @@ func newRootCmd() *cobra.Command {
Version: fmt.Sprintf("%s (commit %s, built %s)", Version, Commit, BuildDate),
SilenceUsage: true,
SilenceErrors: false,
// No subcommand and no flags = end-user double-clicked the exe;
// open the smoke-test window instead of dumping CLI help to a
// console they didn't ask for.
RunE: func(cmd *cobra.Command, args []string) error {
showTestWindow()
return nil
},
}
// Custom version template: "drover-go vX.Y.Z (commit abc1234, built 2026-05-01)".
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8"/>
<title>Drover-Go · Fluent</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
html, body { margin: 0; padding: 0; background: #f0eee9; font-family: 'Inter', -apple-system, "Segoe UI", system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
*{ box-sizing: border-box; }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="drover-shared.jsx"></script>
<script type="text/babel" src="drover-fluent-live.jsx"></script>
<script type="text/babel">
function App() {
const { DesignCanvas, DCSection, DCArtboard } = window;
const W = 480, H = 640;
return (
<DesignCanvas>
<DCSection id="fluent" title="01 · Fluent / Win11 native"
subtitle="Системные шрифты Segoe UI Variable, акриловые карточки, Win11 acrylic-blue. Полный интерактив. Light + dark.">
<DCArtboard id="fluent-light" label="light · interactive" width={W} height={H}>
<window.FluentWindow mode="light" />
</DCArtboard>
<DCArtboard id="fluent-dark" label="dark · interactive" width={W} height={H}>
<window.FluentWindow mode="dark" />
</DCArtboard>
</DCSection>
</DesignCanvas>
);
}
function tryMount() {
if (window.FluentWindow && window.DesignCanvas) {
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
} else { setTimeout(tryMount, 50); }
}
tryMount();
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
+249
View File
@@ -0,0 +1,249 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8"/>
<title>Drover-Go — Desktop GUI explorations</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<style>
html, body { margin: 0; padding: 0; background: #f0eee9; font-family: -apple-system, "Segoe UI", system-ui, sans-serif; }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<template id="__bundler_thumbnail" data-bg-color="#f0eee9">
<svg viewBox="0 0 1200 800" xmlns="http://www.w3.org/2000/svg">
<rect width="1200" height="800" fill="#f0eee9"/>
<g transform="translate(600 400)">
<circle r="160" fill="none" stroke="#2a7d76" stroke-width="22"/>
<path d="M -90 -50 L 0 40 L 90 -50" fill="none" stroke="#2a7d76" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 0 40 L 0 130" fill="none" stroke="#2a7d76" stroke-width="22" stroke-linecap="round"/>
</g>
<text x="600" y="660" text-anchor="middle" font-family="ui-monospace, monospace" font-size="36" font-weight="700" fill="#2a251f" letter-spacing="6">DROVER-GO</text>
</svg>
</template>
<div id="root"></div>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="drover-shared.jsx"></script>
<script type="text/babel" src="drover-classic.jsx"></script>
<script type="text/babel" src="drover-minimal.jsx"></script>
<script type="text/babel" src="drover-glass.jsx"></script>
<script type="text/babel" src="drover-brutalist.jsx"></script>
<script type="text/babel">
const { useState, useEffect } = React;
// For each variant we want to show several states side-by-side. To get
// distinct states without manual interaction in the artboards, we use a
// small "preset" wrapper that performs scripted setup against the variant's
// useDrover hook by lifting it up: we pass an `initial` form and an
// imperative control prop that the variant doesn't need — instead we just
// re-mount with different presets to land at idle / checking / passed /
// active / failed / failed+active states.
//
// We achieve this with a small Driver component that wraps the variant and
// drives it through actions on mount.
function Driver({ Component, mode, preset }) {
// preset: 'idle' | 'checking' | 'passed' | 'active' | 'failed' | 'active-warn' | 'auth-passed'
const ref = React.useRef(null);
const [boot, setBoot] = React.useState(false);
React.useEffect(() => { setBoot(true); }, []);
return (
<PresetHost Component={Component} mode={mode} preset={preset}/>
);
}
// Re-implements the variant render but injects a controller that drives
// the inner useDrover state via portal. Simpler approach: forward an
// `onReady` ref to the inner D state — but the variants don't expose D.
// Easiest working approach: wrap each variant and intercept by re-mounting
// through React + run actions via a *parallel* useDrover instance is not
// possible. So: we expose a tiny "preset" API directly inside each variant
// via the `initial` argument that mutates on first paint.
// The variants all accept an `initial` form. We extend the contract: the
// variants now also accept `__preset` and `__scenario` to seed the state
// machine on mount. To do that without rewriting all four files, we use a
// shim: PresetVariant wraps a variant and uses an effect on the first
// render of the inner DOM to dispatch synthetic clicks/programmatic state
// via a "Programmatic" pattern: each variant exposes its useDrover-result
// through a global hook map. Cleaner: just patch useDrover to broadcast.
// ── implementation: monkey-patch useDrover to record latest D per slot ──
if (!window.__drvSlots) {
const orig = window.useDrover;
window.__drvSlots = new Map();
window.useDrover = function(initial) {
const D = orig(initial);
const slot = (initial && initial.__slot) || null;
React.useEffect(() => {
if (slot) window.__drvSlots.set(slot, D);
});
return D;
};
}
function PresetHost({ Component, mode, preset }) {
const slot = React.useId();
React.useEffect(() => {
let cancelled = false;
const wait = () => new Promise(r => requestAnimationFrame(r));
(async () => {
await wait(); await wait();
const D = window.__drvSlots.get(slot);
if (!D || cancelled) return;
if (preset === 'idle') return;
if (preset === 'checking') {
// Set scenario, kick check, but don't await — we want it mid-flight
D.runCheck();
return;
}
if (preset === 'passed') {
D.setLogsOpen?.(false);
D.runCheck();
// wait for completion
for (let i = 0; i < 60 && !cancelled; i++) {
await new Promise(r => setTimeout(r, 200));
const cur = window.__drvSlots.get(slot);
if (cur?.phase === 'checked') break;
}
return;
}
if (preset === 'auth-passed') {
D.update({ auth: true, login: 'drover', password: 'secret123' });
await wait();
const cur1 = window.__drvSlots.get(slot);
cur1.runCheck();
for (let i = 0; i < 60 && !cancelled; i++) {
await new Promise(r => setTimeout(r, 200));
const cur = window.__drvSlots.get(slot);
if (cur?.phase === 'checked') break;
}
return;
}
if (preset === 'failed') {
// We need to switch scenario before runCheck.
D.setScenario?.('udpFail');
await wait();
const cur1 = window.__drvSlots.get(slot);
cur1.runCheck();
for (let i = 0; i < 60 && !cancelled; i++) {
await new Promise(r => setTimeout(r, 200));
const cur = window.__drvSlots.get(slot);
if (cur?.phase === 'checked') break;
}
return;
}
if (preset === 'active') {
D.runCheck();
for (let i = 0; i < 60 && !cancelled; i++) {
await new Promise(r => setTimeout(r, 200));
const cur = window.__drvSlots.get(slot);
if (cur?.phase === 'checked') break;
}
await wait();
window.__drvSlots.get(slot)?.startProxy?.();
// let stats accumulate for a bit
await new Promise(r => setTimeout(r, 1500));
return;
}
if (preset === 'active-warn') {
D.setScenario?.('udpFail');
await wait();
window.__drvSlots.get(slot)?.runCheck?.();
for (let i = 0; i < 60 && !cancelled; i++) {
await new Promise(r => setTimeout(r, 200));
const cur = window.__drvSlots.get(slot);
if (cur?.phase === 'checked') break;
}
await wait();
window.__drvSlots.get(slot)?.startProxy?.();
await new Promise(r => setTimeout(r, 1500));
return;
}
})();
return () => { cancelled = true; window.__drvSlots.delete(slot); };
}, [preset, slot]);
return <Component mode={mode} initial={{ __slot: slot }} />;
}
// ─── Section header card with reasoning ────────────────────────────────
function ReasoningCard() {
return (
<div style={{
width: 520, padding: '16px 18px',
background: '#ffffff', border: '1px solid rgba(0,0,0,0.08)', borderRadius: 10,
fontSize: 13, lineHeight: 1.55, color: '#2a251f',
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
}}>
<div style={{ fontWeight: 700, marginBottom: 4, letterSpacing: 0.2 }}>Drover-Go · GUI explorations</div>
<div style={{ color: '#5c5650' }}>
Один экран · 480×640 · фикс. размер для Win11. Четыре стилистики, dark + light, по нескольку состояний:
idle checking all-passed active. Отдельные борды показывают сценарий с провалом UDP
и активный режим с предупреждением. Все артборды живые: кликабельны, имеют focus / hover / disabled.
</div>
</div>
);
}
function App() {
const { DesignCanvas, DCSection, DCArtboard } = window;
const W = 480, H = 640;
const variants = [
{ id: 'classic', Component: window.ClassicWindow, title: 'Classic devtool',
subtitle: 'Hairlines, dense mono metrics. Сдержанный teal-акцент. Под аудиторию sysadmin/devs.' },
{ id: 'minimal', Component: window.MinimalWindow, title: 'Minimal · Fluent',
subtitle: 'Под Windows 11. Карточки с микро-границей, мягкий slate-blue, system feel.' },
{ id: 'glass', Component: window.GlassWindow, title: 'Glassmorphism',
subtitle: 'Frosted blur поверх градиента, glow на active. Для тех, кому хочется vibe.' },
{ id: 'brutal', Component: window.BrutWindow, title: 'Brutalist',
subtitle: 'Жёсткие границы, без скруглений, моно. Acid-lime акцент, hard-shadow на кнопках.' },
];
const presets = [
{ id: 'idle', label: 'idle (dark)', mode: 'dark', preset: 'idle' },
{ id: 'idle-light', label: 'idle (light)', mode: 'light', preset: 'idle' },
{ id: 'checking', label: 'checking… (dark)', mode: 'dark', preset: 'checking' },
{ id: 'passed', label: 'all passed (dark)', mode: 'dark', preset: 'passed' },
{ id: 'auth-passed', label: 'with auth (light)', mode: 'light', preset: 'auth-passed' },
{ id: 'failed', label: 'UDP failed (light)', mode: 'light', preset: 'failed' },
{ id: 'active', label: 'active proxy (dark)', mode: 'dark', preset: 'active' },
{ id: 'active-warn', label: 'active · UDP warn (light)', mode: 'light', preset: 'active-warn' },
];
return (
<>
<DesignCanvas>
<DCSection id="intro" title="About" subtitle="Brief & approach">
<DCArtboard id="intro-card" label="reasoning" width={520} height={140}>
<ReasoningCard/>
</DCArtboard>
</DCSection>
{variants.map(v => (
<DCSection key={v.id} id={v.id} title={v.title} subtitle={v.subtitle}>
{presets.map(p => (
<DCArtboard key={p.id} id={`${v.id}-${p.id}`} label={p.label} width={W} height={H}>
<PresetHost Component={v.Component} mode={p.mode} preset={p.preset}/>
</DCArtboard>
))}
</DCSection>
))}
</DesignCanvas>
</>
);
}
// Wait until all script files loaded
function tryMount() {
if (window.ClassicWindow && window.MinimalWindow && window.GlassWindow && window.BrutWindow && window.DesignCanvas) {
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
} else {
setTimeout(tryMount, 50);
}
}
tryMount();
</script>
</body>
</html>
+103
View File
@@ -0,0 +1,103 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8"/>
<title>Drover-Go — Shortlist</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
html, body { margin: 0; padding: 0; background: #f0eee9; font-family: 'Inter', -apple-system, "Segoe UI", system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
*{ box-sizing: border-box; }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="drover-shared.jsx"></script>
<script type="text/babel" src="drover-classic.jsx"></script>
<script type="text/babel" src="drover-fluent-live.jsx"></script>
<script type="text/babel" src="drover-wizard-live.jsx"></script>
<script type="text/babel" src="drover-hero-live.jsx"></script>
<script type="text/babel">
const { useState, useEffect } = React;
function App() {
const { DesignCanvas, DCSection, DCArtboard } = window;
const W = 480, H = 640;
return (
<DesignCanvas>
<DCSection id="intro" title="Drover-Go · Shortlist" subtitle="Four directions you picked. Classic devtool is fully interactive (try it). The other three are sketches.">
<DCArtboard id="intro-card" label="about" width={520} height={120}>
<div data-dc-static style={{
width: 520, padding: '16px 18px',
background: '#ffffff', border: '1px solid rgba(0,0,0,0.08)', borderRadius: 10,
fontSize: 13, lineHeight: 1.55, color: '#2a251f',
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
}}>
<div style={{ fontWeight: 700, marginBottom: 4 }}>Shortlist · 4 directions · все живые</div>
<div style={{ color: '#5c5650' }}>
Все 4 варианта полноценный интерактив. Кликай, заполняй форму, жми Check Start.
Разные UI-парадигмы (классика / Win11 / стэппер / VPN-кнопка), но одна и та же стейт-машина.
</div>
</div>
</DCArtboard>
</DCSection>
<DCSection id="classic" title="Classic devtool" subtitle="Hairlines, dense mono metrics, sober teal. Полноценный интерактив — кликай, тестируй. Dark + light.">
<DCArtboard id="classic-dark" label="dark · interactive" width={W} height={H}>
<window.ClassicWindow mode="dark" />
</DCArtboard>
<DCArtboard id="classic-light" label="light · interactive" width={W} height={H}>
<window.ClassicWindow mode="light" />
</DCArtboard>
</DCSection>
<DCSection id="fluent" title="01 · Fluent / Win11 native" subtitle="Системные шрифты, акриловые карточки, Win11 acrylic-blue. Полный интерактив. Light + dark.">
<DCArtboard id="fluent-light" label="light · interactive" width={W} height={H}>
<window.FluentWindow mode="light" />
</DCArtboard>
<DCArtboard id="fluent-dark" label="dark · interactive" width={W} height={H}>
<window.FluentWindow mode="dark" />
</DCArtboard>
</DCSection>
<DCSection id="wizard" title="03 · Wizard / stepper" subtitle="3 шага: Configure → Verify → Connect. Шаги переключаются автоматически по состоянию (idle/checking/active). Light + dark.">
<DCArtboard id="wizard-light" label="light · interactive" width={W} height={H}>
<window.WizardWindow mode="light" />
</DCArtboard>
<DCArtboard id="wizard-dark" label="dark · interactive" width={W} height={H}>
<window.WizardWindow mode="dark" />
</DCArtboard>
</DCSection>
<DCSection id="hero" title="07 · Big toggle (VPN-style)" subtitle="Большая круглая кнопка — нажал, проверка пошла, при успехе авто-старт. Поля прокси — в раскрывающемся блоке снизу.">
<DCArtboard id="hero-dark" label="dark · interactive" width={W} height={H}>
<window.HeroWindow mode="dark" />
</DCArtboard>
<DCArtboard id="hero-light" label="light · interactive" width={W} height={H}>
<window.HeroWindow mode="light" />
</DCArtboard>
</DCSection>
</DesignCanvas>
);
}
function tryMount() {
if (window.ClassicWindow && window.FluentWindow && window.WizardWindow && window.HeroWindow && window.DesignCanvas) {
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
} else {
setTimeout(tryMount, 50);
}
}
tryMount();
</script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Drover-Go · Style sketches</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&family=DM+Mono:wght@400;500&family=Instrument+Serif&family=Fraunces:opsz,wght@9..144,400;9..144,600&family=VT323&family=Major+Mono+Display&display=swap" rel="stylesheet" />
<style>
html,body{margin:0;padding:0;background:#f0eee9;font-family:'Inter',system-ui,sans-serif;color:#1a1a1a;-webkit-font-smoothing:antialiased}
*{box-sizing:border-box}
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="drover-sketches.jsx"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<SketchesApp />);
</script>
</body>
</html>
+156
View File
@@ -0,0 +1,156 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8"/>
<title>Drover-Go — round 2 (dark only)</title>
<style>
html, body { margin: 0; padding: 0; background: #f0eee9; font-family: -apple-system, "Segoe UI", system-ui, sans-serif; }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="drover-shared.jsx"></script>
<script type="text/babel" src="drover-compact.jsx"></script>
<script type="text/babel" src="drover-cli.jsx"></script>
<script type="text/babel" src="drover-studio.jsx"></script>
<script type="text/babel" src="drover-workshop.jsx"></script>
<script type="text/babel">
// Patch useDrover to record latest D per slot id (for preset driving).
if (!window.__drvSlots) {
const orig = window.useDrover;
window.__drvSlots = new Map();
window.useDrover = function(initial) {
const D = orig(initial);
const slot = (initial && initial.__slot) || null;
React.useEffect(() => { if (slot) window.__drvSlots.set(slot, D); });
return D;
};
}
function PresetHost({ Component, preset }) {
const slot = React.useId();
React.useEffect(() => {
let cancelled = false;
const wait = () => new Promise(r => requestAnimationFrame(r));
(async () => {
await wait(); await wait();
const D = window.__drvSlots.get(slot);
if (!D || cancelled) return;
if (preset === 'idle') return;
if (preset === 'checking') { D.runCheck(); return; }
const waitChecked = async () => {
for (let i = 0; i < 60 && !cancelled; i++) {
await new Promise(r => setTimeout(r, 200));
if (window.__drvSlots.get(slot)?.phase === 'checked') break;
}
};
if (preset === 'passed') { D.runCheck(); await waitChecked(); return; }
if (preset === 'auth-passed') {
D.update({ auth: true, login: 'drover', password: 'secret123' });
await wait();
window.__drvSlots.get(slot)?.runCheck();
await waitChecked(); return;
}
if (preset === 'failed') {
D.setScenario?.('udpFail'); await wait();
window.__drvSlots.get(slot)?.runCheck();
await waitChecked(); return;
}
if (preset === 'active') {
D.runCheck(); await waitChecked(); await wait();
window.__drvSlots.get(slot)?.startProxy?.();
await new Promise(r => setTimeout(r, 1500));
return;
}
if (preset === 'active-warn') {
D.setScenario?.('udpFail'); await wait();
window.__drvSlots.get(slot)?.runCheck(); await waitChecked(); await wait();
window.__drvSlots.get(slot)?.startProxy?.();
await new Promise(r => setTimeout(r, 1500));
return;
}
})();
return () => { cancelled = true; window.__drvSlots.delete(slot); };
}, [preset, slot]);
return <Component initial={{ __slot: slot }} />;
}
function ReasoningCard() {
return (
<div style={{
width: 540, padding: '16px 18px', background: '#ffffff',
border: '1px solid rgba(0,0,0,0.08)', borderRadius: 10,
fontSize: 13, lineHeight: 1.55, color: '#2a251f',
}}>
<div style={{ fontWeight: 700, marginBottom: 4 }}>Drover-Go · round 2</div>
<div style={{ color: '#5c5650' }}>
Только dark. 4 новых направления, более сдержанных и функциональных:
Compact Pro (плотный, мониторинговый), CLI (терминал/фосфор),
Studio (Linear-ish, спокойный), Workshop (industrial slate + amber).
Каждый 6 состояний: idle checking all-passed with-auth UDP-failed active active-warn.
Отметь, какие нравятся расширим в light + tweaks.
</div>
</div>
);
}
function App() {
const { DesignCanvas, DCSection, DCArtboard } = window;
const W = 480, H = 640;
const variants = [
{ id: 'compact', Component: window.CompactWindow,
title: '1 · Compact Pro', subtitle: 'Плотный, мониторинговый. Wireshark-ish. Минимум воздуха.' },
{ id: 'cli', Component: window.CliWindow,
title: '2 · Cyber CLI', subtitle: 'Фосфорный терминал. CRT-сканлайны. Всё через `> command --flag`.' },
{ id: 'studio', Component: window.StudioWindow,
title: '3 · Studio', subtitle: 'Современный, calm, Linear-ish. Тонкие карты, синий accent.' },
{ id: 'workshop', Component: window.WorkshopWindow,
title: '4 · Workshop', subtitle: 'Industrial slate + amber. Шильдики, лево-акцентный border.' },
];
const presets = [
{ id: 'idle', label: 'idle' },
{ id: 'checking', label: 'checking…' },
{ id: 'passed', label: 'all passed' },
{ id: 'auth-passed', label: 'with auth · passed' },
{ id: 'failed', label: 'UDP failed' },
{ id: 'active', label: 'active proxy' },
{ id: 'active-warn', label: 'active · UDP warn' },
];
return (
<DesignCanvas>
<DCSection id="intro" title="About" subtitle="Round 2 — dark only">
<DCArtboard id="intro-card" label="reasoning" width={540} height={150}>
<ReasoningCard/>
</DCArtboard>
</DCSection>
{variants.map(v => (
<DCSection key={v.id} id={v.id} title={v.title} subtitle={v.subtitle}>
{presets.map(p => (
<DCArtboard key={p.id} id={`${v.id}-${p.id}`} label={p.label} width={W} height={H}>
<PresetHost Component={v.Component} preset={p.id}/>
</DCArtboard>
))}
</DCSection>
))}
</DesignCanvas>
);
}
function tryMount() {
if (window.CompactWindow && window.CliWindow && window.StudioWindow && window.WorkshopWindow && window.DesignCanvas) {
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
} else {
setTimeout(tryMount, 50);
}
}
tryMount();
</script>
</body>
</html>
+622
View File
@@ -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:
// <DesignCanvas>
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
// </DCSection>
// </DesignCanvas>
const DC = {
bg: '#f0eee9',
grid: 'rgba(0,0,0,0.06)',
label: 'rgba(60,50,40,0.7)',
title: 'rgba(40,30,20,0.85)',
subtitle: 'rgba(60,50,40,0.6)',
postitBg: '#fef4a8',
postitText: '#5a4a2a',
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
};
// One-time CSS injection (classes are dc-prefixed so they don't collide with
// the hosted design's own styles).
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
const s = document.createElement('style');
s.id = 'dc-styles';
s.textContent = [
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
'.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 (
<DCCtx.Provider value={api}>
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
{state.focus && registry[state.focus] && (
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
)}
</DCCtx.Provider>
);
}
// ─────────────────────────────────────────────────────────────
// DCViewport — transform-based pan/zoom (internal)
//
// Input mapping (Figma-style):
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
// • trackpad scroll → pan (two-finger)
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
// • middle-drag / primary-drag-on-bg → pan
//
// Transform state lives in a ref and is written straight to the DOM
// (translate3d + will-change) so wheel ticks don't go through React —
// keeps pans at 60fps on dense canvases.
// ─────────────────────────────────────────────────────────────
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
const vpRef = React.useRef(null);
const worldRef = React.useRef(null);
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
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 (
<div
ref={vpRef}
className="design-canvas"
style={{
height: '100vh', width: '100vw',
background: DC.bg,
overflow: 'hidden',
overscrollBehavior: 'none',
touchAction: 'none',
position: 'relative',
fontFamily: DC.font,
boxSizing: 'border-box',
...style,
}}
>
<div
ref={worldRef}
style={{
position: 'absolute', top: 0, left: 0,
transformOrigin: '0 0',
willChange: 'transform',
width: 'max-content', minWidth: '100%',
minHeight: '100%',
padding: '60px 0 80px',
}}
>
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
{children}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// DCSection — editable title + h-row of artboards in persisted order
// ─────────────────────────────────────────────────────────────
function DCSection({ id, title, subtitle, children, gap = 48 }) {
const ctx = React.useContext(DCCtx);
const sid = id ?? title;
const all = React.Children.toArray(children);
const artboards = all.filter((c) => c && c.type === DCArtboard);
const rest = all.filter((c) => !(c && c.type === DCArtboard));
const 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 (
<div data-dc-section={sid} style={{ marginBottom: 80, position: 'relative' }}>
<div style={{ padding: '0 60px 56px' }}>
<DCEditable tag="div" value={sec.title ?? title}
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
</div>
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
{order.map((k) => (
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
label={(sec.labels || {})[k] ?? byId[k].props.label}
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
))}
</div>
{rest}
</div>
);
}
// 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 (
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
<div className="dc-labelrow" style={{ position: 'absolute', bottom: '100%', left: -4, marginBottom: 4, color: DC.label }}>
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
</div>
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
</div>
</div>
<button className="dc-expand" onClick={onFocus} onPointerDown={(e) => e.stopPropagation()} title="Focus">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
</button>
<div className="dc-card"
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
</div>
</div>
);
}
// Inline rename — commits on blur or Enter.
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
const T = tag;
return (
<T className="dc-editable" contentEditable suppressContentEditableWarning
onClick={onClick}
onPointerDown={(e) => e.stopPropagation()}
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
style={style}>{value}</T>
);
}
// ─────────────────────────────────────────────────────────────
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
// sections, Esc or backdrop click to exit.
// ─────────────────────────────────────────────────────────────
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
const ctx = React.useContext(DCCtx);
const { sectionId, artboard } = entry;
const sec = ctx.section(sectionId);
const meta = sectionMeta[sectionId];
const peers = meta.slotIds;
const aid = artboard.props.id ?? artboard.props.label;
const idx = peers.indexOf(aid);
const secIdx = sectionOrder.indexOf(sectionId);
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
const goSection = (d) => {
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 }) => (
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
</button>
);
// Portal to body so position:fixed is the real viewport regardless of any
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
return ReactDOM.createPortal(
<div onClick={() => ctx.setFocus(null)}
onWheel={(e) => e.preventDefault()}
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
fontFamily: DC.font, color: '#fff' }}>
{/* top bar: section dropdown (left) · close (right) */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
<div style={{ position: 'relative' }}>
<button onClick={() => setDd((o) => !o)}
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
</span>
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
</button>
{ddOpen && (
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
{sectionOrder.map((sid) => (
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
{sectionMeta[sid].title}
</button>
))}
</div>
)}
</div>
<div style={{ flex: 1 }} />
<button onClick={() => ctx.setFocus(null)}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
</div>
{/* card centered, label + index below — only the card itself stops
propagation so any backdrop click (including the margins around
the card) exits focus */}
<div
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
</div>
</div>
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
{(sec.labels || {})[aid] ?? artboard.props.label}
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
</div>
</div>
<Arrow dir="left" onClick={() => go(-1)} />
<Arrow dir="right" onClick={() => go(1)} />
{/* dots */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
{peers.map((p, i) => (
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
))}
</div>
</div>,
document.body,
);
}
// ─────────────────────────────────────────────────────────────
// Post-it — absolute-positioned sticky note
// ─────────────────────────────────────────────────────────────
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
return (
<div style={{
position: 'absolute', top, left, right, bottom, width,
background: DC.postitBg, padding: '14px 16px',
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
transform: `rotate(${rotate}deg)`,
zIndex: 5,
}}>{children}</div>
);
}
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
+335
View File
@@ -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 (
<div style={{
width: 480, height: 640, background: t.bg, color: t.text, display: 'flex', flexDirection: 'column',
fontFamily: fontMono, fontSize: 12, overflow: 'hidden',
border: `2px solid ${t.border}`, boxSizing:'border-box',
}}>
<BrutTitle t={t}/>
<div style={{ flex: 1, overflow:'auto' }}>
{/* form */}
<div style={{ padding: 14, borderBottom: `2px solid ${t.border}` }}>
<BrutLabel t={t}>// SOCKS5 PROXY</BrutLabel>
<div style={{ display:'flex', gap: 8 }}>
<BField t={t} label="HOST" style={{ flex: 1 }}>
<input value={D.form.host} onChange={e => D.update({ host: e.target.value })}
placeholder="95.165.72.59 / example.com"
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={brutInput(t, fontMono)}/>
</BField>
<BField t={t} label="PORT" style={{ width: 92 }}>
<input value={D.form.port} onChange={e => D.update({ port: e.target.value.replace(/\D/g,'') })}
placeholder="12334" inputMode="numeric"
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={brutInput(t, fontMono)}/>
</BField>
</div>
<div style={{ height: 10 }}/>
<BrutCheckbox t={t} checked={D.form.auth}
onChange={(v) => { D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('br-login')?.focus(),30); }}>
AUTHENTICATION
</BrutCheckbox>
<div style={{ display:'flex', gap: 8, marginTop: 8, opacity: D.form.auth ? 1 : 0.4,
pointerEvents: D.form.auth ? 'auto':'none' }}>
<BField t={t} label="LOGIN" style={{ flex: 1 }}>
<input id="br-login" disabled={!D.form.auth} value={D.form.login}
onChange={e => D.update({ login: e.target.value })} placeholder="user"
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={brutInput(t, fontMono, !D.form.auth)}/>
</BField>
<BField t={t} label="PASSWORD" style={{ flex: 1 }}>
<input disabled={!D.form.auth} type="password" value={D.form.password}
onChange={e => D.update({ password: e.target.value })} placeholder="******"
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={brutInput(t, fontMono, !D.form.auth)}/>
</BField>
</div>
<div style={{ height: 12 }}/>
<BrutPrimary t={t} onClick={D.runCheck} disabled={D.phase === 'checking' || isActive}>
{D.phase === 'checking' ? '>> CHECKING…' : '>> CHECK CONNECTION'}
</BrutPrimary>
</div>
{/* status */}
<div style={{ padding: 14, borderBottom: `2px solid ${t.border}` }}>
<BrutLabel t={t}>// STATUS</BrutLabel>
<BrutStatus t={t} D={D} palette={palette}/>
</div>
{/* actions */}
<div style={{ padding: 14 }}>
<div style={{ display:'flex', gap: 8 }}>
<BrutStartBtn t={t} D={D}/>
<BrutStopBtn t={t} D={D}/>
</div>
{isActive && <BrutLiveStats t={t} stats={D.stats}/>}
</div>
</div>
<BrutLogs t={t} D={D}/>
</div>
);
}
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 (
<div style={{
height: 30, background: t.alt, display:'flex', alignItems:'center',
borderBottom: `2px solid ${t.border}`, userSelect:'none',
}}>
<div style={{ display:'flex', alignItems:'center', gap: 8, padding:'0 12px', flex:1 }}>
<span style={{
width: 16, height: 16, background: t.accent, color: '#0c0c0c',
display:'flex', alignItems:'center', justifyContent:'center', fontWeight: 700, fontSize: 11,
}}>D</span>
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: 1.5 }}>DROVER-GO</span>
<span style={{ fontSize: 10, color: t.dim }}>[v0.4.2]</span>
</div>
<div style={{ display:'flex' }}>
<div style={cell} title="Settings"><window.IconGear color={t.text}/></div>
<div style={cell} title="Minimize"><window.IconMin color={t.text}/></div>
<div style={{ ...cell, background: t.danger, color: '#fff' }} title="Close"><window.IconClose color="#fff"/></div>
</div>
</div>
);
}
function BrutLabel({ t, children }) {
return <div style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: 1.5, marginBottom: 10, color: t.dim }}>{children}</div>;
}
function BField({ t, label, children, style }) {
return <label style={{ display:'flex', flexDirection:'column', gap: 4, ...style }}>
<span style={{ fontSize: 10, color: t.dim, fontWeight: 700, letterSpacing: 1 }}>{label}</span>{children}
</label>;
}
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 (
<label style={{ display:'inline-flex', alignItems:'center', gap: 8, cursor:'pointer', userSelect:'none' }}>
<span style={{
width: 16, height: 16, border: `2px solid ${t.border}`,
background: checked ? t.accent : 'transparent',
display:'flex', alignItems:'center', justifyContent:'center',
}}>
{checked && <span style={{ fontWeight: 900, color: '#0c0c0c', fontSize: 12, lineHeight: 1 }}>×</span>}
</span>
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display:'none' }}/>
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: 1 }}>{children}</span>
</label>
);
}
function BrutPrimary({ t, onClick, disabled, children, style }) {
return (
<button onClick={onClick} disabled={disabled} style={{
width:'100%', padding: '10px 12px', border: `2px solid ${t.border}`, borderRadius: 0,
background: disabled ? t.alt : t.accent, color: disabled ? t.dimmer : t.primaryFg,
fontWeight: 700, fontSize: 12, letterSpacing: 1.2, cursor: disabled?'not-allowed':'pointer',
fontFamily: 'inherit', textAlign:'left',
boxShadow: disabled ? 'none' : `4px 4px 0 ${t.border}`,
transition: 'transform .08s, box-shadow .08s',
...style,
}}
onMouseDown={e => { 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}</button>
);
}
function BrutStatus({ t, D, palette }) {
if (D.phase === 'idle') {
return (
<div style={{ display:'flex', alignItems:'center', gap: 9, padding: '4px 0' }}>
<span style={{ width: 10, height: 10, border: `2px solid ${t.dim}`, background: 'transparent' }}/>
<span style={{ color: t.dim, fontSize: 11.5, letterSpacing: 0.5 }}>READY TO CHECK_</span>
</div>
);
}
return (
<div>
{D.phase === 'checking'
? <div style={{ display:'flex', alignItems:'center', gap: 9, marginBottom: 10 }}>
<window.StatusDot state="running" palette={palette} size={14}/>
<span style={{ fontWeight: 700, fontSize: 11.5, letterSpacing: 0.7 }}>RUNNING DIAGNOSTICS</span>
</div>
: <div style={{
padding: '7px 10px', marginBottom: 10,
background: D.lastSummary?.failed === 0 ? t.accent : t.warn,
color: '#0c0c0c', fontWeight: 700, fontSize: 11.5, letterSpacing: 0.5,
border: `2px solid ${t.border}`,
}}>
{D.lastSummary?.failed === 0
? '▮ ALL CHECKS PASSED. READY TO START.'
: `${D.lastSummary?.failed}/${D.tests.length} FAILED · SOME FEATURES BROKEN`}
</div>}
<div style={{ border: `2px solid ${t.border}` }}>
{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 (
<div key={test.id} style={{ borderBottom: !last ? `1px solid ${t.borderDim}` : 'none' }}>
<div style={{ display:'flex', alignItems:'center', gap: 9, height: 26, padding: '0 9px' }}>
<window.StatusDot state={state} palette={palette} size={12}/>
<span style={{ color: state==='pending'?t.dim:t.text, fontSize: 11.5 }} title={test.desc}>
{test.label}
</span>
<span style={{ marginLeft:'auto', fontSize: 10.5,
color: state==='failed'?t.danger:state==='skipped'?t.skip:state==='passed'?t.pass:t.dim }}>
{r?.metric || (state==='running'?'…':'')}
</span>
{r?.result === 'failed' && (
<button onClick={() => D.toggleExpand(test.id)} style={{
background:'transparent', border:'none', cursor:'pointer', padding: 4, color: t.text,
}}><window.IconChevron color={t.text} dir={r.expanded?'up':'down'}/></button>
)}
</div>
{r?.result === 'failed' && r.expanded && (
<div className="drv-fadein" style={{
borderTop: `1px solid ${t.borderDim}`,
background: t.alt, padding: 10, fontSize: 11,
}}>
<div style={{ color: t.danger, fontWeight: 700, marginBottom: 4, letterSpacing: 0.5 }}>! {r.error}</div>
<div style={{ color: t.dim, lineHeight: 1.5 }}>{r.hint}</div>
<button onClick={() => 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 ]</button>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
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 (
<div style={{
flex: 1, padding: '10px 12px', border: `2px solid ${t.border}`,
background: warning ? t.warn : t.accent, color: '#0c0c0c',
fontWeight: 700, fontSize: 12, letterSpacing: 1.2, fontFamily: 'inherit',
display:'flex', alignItems:'center', justifyContent:'center', gap: 8,
boxShadow: `4px 4px 0 ${t.border}`,
}}>
<span className="drv-pulsedot" style={{ width: 9, height: 9, background: '#0c0c0c' }}/>
{warning ? ' ACTIVE / UDP-WARN' : ' ACTIVE'}
</div>
);
}
return <BrutPrimary t={t} disabled={!ok} onClick={D.startProxy} style={{ flex: 1 }}>{'>> START PROXYING'}</BrutPrimary>;
}
function BrutStopBtn({ t, D }) {
const enabled = D.phase === 'active';
return (
<button onClick={D.stopProxy} disabled={!enabled} style={{
flex: 1, padding: '10px 12px', border: `2px solid ${enabled?t.border:t.borderDim}`,
background: t.bg, color: enabled ? t.text : t.dimmer,
fontWeight: 700, fontSize: 12, letterSpacing: 1.2, fontFamily: 'inherit',
cursor: enabled ? 'pointer':'not-allowed', textAlign:'left',
boxShadow: enabled ? `4px 4px 0 ${t.border}` : 'none',
}}>{'■ STOP'}</button>
);
}
function BrutLiveStats({ t, stats }) {
const C = ({ icon, val, lbl }) => (
<div style={{ display:'flex', alignItems:'center', gap: 4 }}>
{icon}<span style={{ fontSize: 11, fontWeight: 700 }}>{val}</span>
{lbl && <span style={{ fontSize: 9, color: t.dim, fontWeight: 700 }}>{lbl}</span>}
</div>
);
return (
<div style={{
marginTop: 10, padding: '7px 10px', border: `2px solid ${t.border}`,
display:'flex', justifyContent:'space-between', alignItems:'center',
}}>
<C icon={<window.IconArrowUp color={t.text}/>} val={window.fmtBytes(stats.up)}/>
<C icon={<window.IconArrowDown color={t.text}/>} val={window.fmtBytes(stats.down)}/>
<C val={stats.tcp} lbl="TCP"/>
<C val={stats.udp} lbl="UDP"/>
<C val={window.fmtUptime(stats.uptimeS)} lbl="UP"/>
</div>
);
}
function BrutLogs({ t, D }) {
return (
<div style={{ borderTop: `2px solid ${t.border}`, background: t.alt, flexShrink: 0 }}>
<button onClick={() => 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,
}}>
<window.IconChevron color={t.text} dir={D.logsOpen ? 'down' : 'right'}/>
<span>// LOGS</span>
<span style={{ marginLeft: 'auto', color: t.dim }}>{D.logs.length} LINES</span>
</button>
{D.logsOpen && (
<>
<div style={{ display:'flex', gap: 6, padding: '0 14px 7px' }}>
{[['COPY ALL', () => navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n'))],
['CLEAR', D.clearLogs], ['OPEN FILE', null]].map(([l, fn]) => (
<button key={l} onClick={fn || undefined} style={{
background:'transparent', border:`1.5px solid ${t.border}`, color: t.text,
padding: '3px 8px', fontSize: 9.5, fontWeight: 700, letterSpacing: 1, cursor:'pointer',
fontFamily:'inherit',
}}>[ {l} ]</button>
))}
</div>
<div className="drv-log" ref={el => 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) => (
<div key={i}>
<span style={{ color: t.dim }}>{window.fmtTime(l.t)}</span>{' '}
<span style={{
color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.accent, fontWeight: 700,
}}>[{l.level}]</span>{' '}
{l.msg}
</div>
))}
</div>
</>
)}
</div>
);
}
window.BrutWindow = BrutWindow;
+440
View File
@@ -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 (
<div style={{
width: 480, height: 640, background: t.bg, color: t.text, display: 'flex', flexDirection: 'column',
fontFamily: fontUI, fontSize: 13, lineHeight: 1.4, overflow: 'hidden',
border: mode === 'dark' ? '1px solid #000' : '1px solid #c0c3c9',
}}>
{/* ─── title bar ─── */}
<ClassicTitleBar t={t} />
{/* ─── content ─── */}
<div style={{ flex: 1, overflow: 'auto', padding: '14px 16px 0' }}>
{/* Form */}
<SectionLabel t={t}>SOCKS5 Proxy</SectionLabel>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<Field t={t} label="Host" style={{ flex: 1 }}>
<input value={D.form.host} onChange={e => D.update({ host: e.target.value })}
placeholder="95.165.72.59 или example.com"
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
style={inputStyle(t, fontMono)} />
</Field>
<Field t={t} label="Port" style={{ width: 92 }}>
<input value={D.form.port} onChange={e => D.update({ port: e.target.value.replace(/\D/g,'') })}
placeholder="12334" inputMode="numeric"
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
style={inputStyle(t, fontMono)} />
</Field>
</div>
<Checkbox t={t} checked={D.form.auth}
onChange={(v) => { D.update({ auth: v }); if (v) setTimeout(() => document.getElementById('cls-login')?.focus(), 30); }}>
Authentication
</Checkbox>
<div style={{ display: 'flex', gap: 8, marginTop: 8, marginBottom: 12, opacity: D.form.auth ? 1 : 0.45 }}>
<Field t={t} label="Login" style={{ flex: 1 }}>
<input id="cls-login" disabled={!D.form.auth} value={D.form.login}
onChange={e => D.update({ login: e.target.value })} placeholder="user"
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
style={inputStyle(t, fontMono, !D.form.auth)} />
</Field>
<Field t={t} label="Password" style={{ flex: 1 }}>
<input disabled={!D.form.auth} type="password" value={D.form.password}
onChange={e => D.update({ password: e.target.value })} placeholder="••••••"
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
style={inputStyle(t, fontMono, !D.form.auth)} />
</Field>
</div>
<PrimaryBtn t={t} onClick={D.runCheck} disabled={D.phase === 'checking' || isActive}>
{D.phase === 'checking' ? 'Checking…' : 'Check connection'}
</PrimaryBtn>
{/* Status */}
<div style={{ height: 18 }} />
<SectionLabel t={t}>Status</SectionLabel>
<ClassicStatus t={t} D={D} palette={palette} fontMono={fontMono} />
{/* Action buttons */}
<div style={{ height: 14 }} />
<div style={{ display: 'flex', gap: 8 }}>
<ClassicStartBtn t={t} D={D} fontMono={fontMono} />
<ClassicStopBtn t={t} D={D} />
</div>
{isActive && <ClassicLiveStats t={t} stats={D.stats} fontMono={fontMono} />}
<div style={{ height: 14 }} />
</div>
{/* Logs collapsible */}
<ClassicLogs t={t} D={D} fontMono={fontMono} />
</div>
);
}
// ─── pieces ─────────────────────────────────────────────────────────────────
function ClassicTitleBar({ t }) {
const cellStyle = {
width: 38, height: 28, display:'flex', alignItems:'center', justifyContent:'center',
color: t.dim, cursor:'pointer',
};
return (
<div style={{
height: 32, background: t.chrome, borderBottom: `1px solid ${t.borderSoft}`,
display:'flex', alignItems:'center', WebkitAppRegion:'drag', userSelect:'none',
}}>
<div style={{ display:'flex', alignItems:'center', gap:8, padding:'0 12px', flex:1 }}>
<window.BrandMark size={14} color={t.accent}/>
<span style={{ fontSize: 12, fontWeight: 600, letterSpacing: 0.1 }}>Drover-Go</span>
<span style={{ fontSize: 11, color: t.dimmer, fontFamily:'ui-monospace,monospace' }}>v0.4.2</span>
</div>
<div style={{ display:'flex', WebkitAppRegion:'no-drag' }}>
<div style={cellStyle} title="Settings"><window.IconGear color={t.dim}/></div>
<div style={cellStyle} title="Minimize"><window.IconMin color={t.dim}/></div>
<div style={{...cellStyle}} title="Close"
onMouseEnter={e => e.currentTarget.style.background = '#c0463f'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<window.IconClose color={t.dim}/>
</div>
</div>
</div>
);
}
function SectionLabel({ children, t }) {
return <div style={{
fontSize: 10.5, fontWeight: 600, letterSpacing: 1.2, textTransform: 'uppercase',
color: t.dim, marginBottom: 8,
}}>{children}</div>;
}
function Field({ children, label, t, style }) {
return (
<label style={{ display:'flex', flexDirection:'column', gap: 4, ...style }}>
<span style={{ fontSize: 10.5, color: t.dim, fontWeight: 500 }}>{label}</span>
{children}
</label>
);
}
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 (
<label style={{ display:'inline-flex', alignItems:'center', gap: 7, cursor:'pointer', userSelect:'none', fontSize: 12 }}>
<span style={{
width: 14, height: 14, borderRadius: 2, border: `1px solid ${checked ? t.accent : t.border}`,
background: checked ? t.accent : 'transparent', display:'flex', alignItems:'center', justifyContent:'center',
transition: 'background .12s, border-color .12s',
}}>
{checked && <svg width="9" height="9" viewBox="0 0 9 9"><path d="M1.5 4.5l2 2 4-4" stroke={t.primaryFg} strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</span>
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display:'none' }}/>
{children}
</label>
);
}
function PrimaryBtn({ t, onClick, disabled, children, style }) {
return (
<button onClick={onClick} disabled={disabled}
style={{
width:'100%', padding:'9px 12px', border:'none', borderRadius: 3,
background: disabled ? t.btnBg : t.primaryBg, color: disabled ? t.dimmer : t.primaryFg,
fontWeight: 600, fontSize: 12.5, letterSpacing: 0.1, cursor: disabled ? 'not-allowed' : 'pointer',
boxShadow: disabled ? 'none' : `inset 0 -1px 0 rgba(0,0,0,.18)`,
transition: 'background .12s', ...style,
}}>
{children}
</button>
);
}
// ─── status panel ──────────────────────────────────────────────────────────
function ClassicStatus({ t, D, palette, fontMono }) {
const idle = D.phase === 'idle';
if (idle) {
return (
<div style={{
background: t.panel, border: `1px solid ${t.borderSoft}`, borderRadius: 4,
padding: '14px 14px', display:'flex', alignItems:'center', gap: 10,
}}>
<span style={{ width: 8, height: 8, borderRadius: 4, background: t.dimmer }}/>
<span style={{ color: t.dim, fontSize: 12.5 }}>Ready to check</span>
</div>
);
}
return (
<div style={{ background: t.panel, border: `1px solid ${t.borderSoft}`, borderRadius: 4, overflow:'hidden' }}>
{/* header */}
<div style={{
padding: '8px 12px', display:'flex', alignItems:'center', gap: 8,
borderBottom: `1px solid ${t.borderSoft}`, background: t.panelAlt, fontSize: 12,
}}>
{D.phase === 'checking'
? <>
<window.StatusDot state="running" palette={palette} size={12}/>
<span>Running diagnostics</span>
<span style={{ marginLeft:'auto', color: t.dim, fontFamily: fontMono, fontSize: 11 }}>
{Object.keys(D.results).length}/{D.tests.length}
</span>
</>
: D.lastSummary?.failed === 0
? <span style={{ color: t.pass, fontWeight: 600 }}>All checks passed. Ready to start.</span>
: <span style={{ color: t.warn, fontWeight: 600 }}>{D.lastSummary?.failed} of {D.tests.length} checks failed. Some features won't work.</span>}
</div>
{/* tests */}
<div>
{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 (
<div key={test.id} style={{
borderBottom: !isLast ? `1px solid ${t.borderSoft}` : 'none',
padding: '6px 12px',
}}>
<div style={{ display:'flex', alignItems:'center', gap: 9, height: 22 }}>
<window.StatusDot state={state} palette={palette} size={12}/>
<span style={{ fontSize: 12, color: state === 'pending' ? t.dim : t.text }} title={test.desc}>
{test.label}
</span>
<span style={{ marginLeft:'auto', fontFamily: fontMono, fontSize: 11,
color: state === 'failed' ? t.danger : state === 'skipped' ? t.skip : t.dim }}>
{r?.metric || (state === 'running' ? '...' : '')}
</span>
{r?.result === 'failed' && (
<button onClick={() => D.toggleExpand(test.id)} style={iconBtnStyle(t)} title="Подробнее">
<window.IconChevron color={t.dim} dir={r.expanded ? 'up' : 'down'}/>
</button>
)}
</div>
{r?.result === 'failed' && r.expanded && (
<div className="drv-fadein" style={{
margin: '4px 0 6px 21px', padding: '8px 10px', borderRadius: 3,
background: mode_mix(t.danger, t.panel, 0.9), border: `1px solid ${mode_mix(t.danger, t.panel, 0.78)}`,
fontSize: 11.5, color: t.text,
}}>
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 2 }}>{r.error}</div>
<div style={{ color: t.dim }}>{r.hint}</div>
<div style={{ display:'flex', gap: 6, marginTop: 6 }}>
<button onClick={() => navigator.clipboard?.writeText(`[${test.label}] ${r.error} — ${r.metric}`)}
style={smallBtn(t, fontMono)}>
<window.IconCopy color={t.dim}/> copy
</button>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
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 (
<div style={{
flex:1, padding:'9px 12px', borderRadius: 3, display:'flex', alignItems:'center', justifyContent:'center', gap: 8,
background: warning ? mode_mix(t.warn, t.panel, 0.85) : mode_mix(t.pass, t.panel, 0.85),
border: `1px solid ${warning ? t.warn : t.pass}`,
color: warning ? t.warn : t.pass, fontWeight: 600, fontSize: 12.5, fontFamily: fontMono,
}}>
<span className="drv-pulsedot" style={{
width: 8, height: 8, borderRadius: 4, background: warning ? t.warn : t.pass,
}}/>
Active{warning ? ' · UDP fallback' : ''}
</div>
);
}
return (
<PrimaryBtn t={t} disabled={!checkedOk} onClick={D.startProxy} style={{ flex: 1 }}>
Start proxying
</PrimaryBtn>
);
}
function ClassicStopBtn({ t, D }) {
const enabled = D.phase === 'active';
return (
<button onClick={D.stopProxy} disabled={!enabled}
style={{
flex:1, padding:'9px 12px', borderRadius: 3, fontWeight: 600, fontSize: 12.5,
background: t.btnBg, color: enabled ? t.text : t.dimmer,
border: `1px solid ${t.border}`, cursor: enabled ? 'pointer':'not-allowed',
}}>
Stop
</button>
);
}
function ClassicLiveStats({ t, stats, fontMono }) {
const cell = (icon, val) => (
<div style={{ display:'flex', alignItems:'center', gap: 4, color: t.dim, fontFamily: fontMono, fontSize: 11 }}>
{icon}<span>{val}</span>
</div>
);
return (
<div style={{
marginTop: 8, padding: '6px 10px', borderRadius: 3,
background: t.panel, border: `1px solid ${t.borderSoft}`,
display:'flex', justifyContent:'space-between', alignItems:'center',
}}>
{cell(<window.IconArrowUp color={t.dim}/>, window.fmtBytes(stats.up))}
{cell(<window.IconArrowDown color={t.dim}/>, window.fmtBytes(stats.down))}
{cell(<span style={{fontSize:9, color:t.dimmer}}>TCP</span>, stats.tcp)}
{cell(<span style={{fontSize:9, color:t.dimmer}}>UDP</span>, stats.udp)}
{cell(<span style={{fontSize:9, color:t.dimmer}}>↑t</span>, window.fmtUptime(stats.uptimeS))}
</div>
);
}
// ─── logs ──────────────────────────────────────────────────────────────────
function ClassicLogs({ t, D, fontMono }) {
return (
<div style={{ borderTop: `1px solid ${t.borderSoft}`, background: t.chrome, flexShrink: 0 }}>
<button onClick={() => 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,
}}>
<window.IconChevron color={t.dim} dir={D.logsOpen ? 'down' : 'right'}/>
<span style={{ textTransform:'uppercase' }}>Logs</span>
<span style={{ marginLeft: 'auto', color: t.dimmer }}>{D.logs.length} lines</span>
</button>
{D.logsOpen && (
<div style={{ borderTop: `1px solid ${t.borderSoft}` }}>
<div style={{ display:'flex', gap: 6, padding: '6px 12px', borderBottom: `1px solid ${t.borderSoft}` }}>
<button style={smallBtn(t, fontMono)}
onClick={() => navigator.clipboard?.writeText(D.logs.map(l => `[${l.level}] ${l.msg}`).join('\n'))}>copy all</button>
<button style={smallBtn(t, fontMono)} onClick={D.clearLogs}>clear</button>
<button style={smallBtn(t, fontMono)}>open log file</button>
</div>
<div className="drv-log" style={{
maxHeight: 130, overflowY: 'auto', padding: '6px 12px',
fontFamily: fontMono, fontSize: 10.5, lineHeight: 1.55, color: t.dim,
background: t.panelAlt,
}} ref={el => el && (el.scrollTop = el.scrollHeight)}>
{D.logs.map((l, i) => (
<div key={i}>
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>
{' '}
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.pass, fontWeight: 600 }}>[{l.level}]</span>
{' '}
<span>{l.msg}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
window.ClassicWindow = ClassicWindow;
+263
View File
@@ -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 (
<div style={{
width: 480, height: 640, background: t.bg, color: t.text, display:'flex', flexDirection:'column',
fontFamily: fontMono, fontSize: 12, overflow:'hidden', position:'relative',
border:'1px solid #000', textShadow: `0 0 1px ${t.accent}30`,
}}>
{/* CRT scanlines */}
<div style={{
position:'absolute', inset:0, pointerEvents:'none', zIndex: 5, opacity: 0.18,
background: 'repeating-linear-gradient(to bottom, transparent 0, transparent 2px, rgba(0,0,0,.55) 3px, transparent 4px)',
}}/>
{/* vignette */}
<div style={{
position:'absolute', inset:0, pointerEvents:'none', zIndex: 4,
background: 'radial-gradient(ellipse at center, transparent 50%, rgba(0,0,0,.45) 100%)',
}}/>
{/* title bar — like a terminal tab */}
<div style={{
height: 28, background: t.chrome, borderBottom:`1px solid ${t.border}`,
display:'flex', alignItems:'center', position:'relative', zIndex: 6,
}}>
<div style={{ display:'flex', alignItems:'center', gap: 7, padding:'0 11px', flex:1 }}>
<span style={{ color: t.accent }}>$</span>
<span style={{ fontSize: 11.5, fontWeight: 600, letterSpacing: 0.6 }}>drover-go</span>
<span style={{ fontSize: 10.5, color: t.dim }}> ~ 80×24</span>
</div>
<div style={{ display:'flex' }}>
{['settings','min','close'].map(k => (
<span key={k} style={{
width: 32, height: 28, display:'flex', alignItems:'center', justifyContent:'center',
cursor:'pointer', color: t.dim, fontSize: 11,
}}>{k==='close'?'×':k==='min'?'':'⚙'}</span>
))}
</div>
</div>
<div style={{ flex: 1, overflow:'auto', padding: '10px 14px', position:'relative', zIndex: 6 }}>
{/* prompt-style form */}
<div style={cliHead(t)}> socks5 proxy </div>
<div style={{ marginLeft: 4, paddingLeft: 14, borderLeft:`1px solid ${t.borderSoft}`, paddingTop: 4, paddingBottom: 6 }}>
<CliRow t={t}>
<span style={{ color: t.dim }}>host:</span>
<CliInput t={t} value={D.form.host} onChange={v => D.update({ host: v })}
placeholder="95.165.72.59 / example.com" onSubmit={D.runCheck} style={{ flex: 1 }}/>
<span style={{ color: t.dim }}>port:</span>
<CliInput t={t} value={D.form.port} onChange={v => D.update({ port: v.replace(/\D/g,'') })}
placeholder="12334" onSubmit={D.runCheck} style={{ width: 70 }}/>
</CliRow>
<div style={{ marginTop: 6 }}>
<label style={{ cursor:'pointer', userSelect:'none' }}>
<span style={{ color: t.dim }}>auth:</span>{' '}
<span onClick={() => {
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 ]'}</span>
</label>
</div>
{D.form.auth && (
<CliRow t={t} style={{ marginTop: 6 }}>
<span style={{ color: t.dim }}>user:</span>
<CliInput id="cli-login" t={t} value={D.form.login} onChange={v => D.update({ login: v })}
placeholder="login" onSubmit={D.runCheck} style={{ flex: 1 }}/>
<span style={{ color: t.dim }}>pass:</span>
<CliInput t={t} value={D.form.password} onChange={v => D.update({ password: v })} type="password"
placeholder="••••••" onSubmit={D.runCheck} style={{ flex: 1 }}/>
</CliRow>
)}
</div>
<button onClick={D.runCheck} disabled={D.phase==='checking'||isActive} style={{
marginTop: 8, width:'100%', padding:'7px 12px', background:'transparent',
border:`1px solid ${(D.phase==='checking'||isActive)?t.dim:t.accent}`,
color: (D.phase==='checking'||isActive)?t.dim:t.accent, fontFamily: fontMono,
fontWeight: 600, fontSize: 11.5, letterSpacing: 1, textAlign:'left',
cursor: D.phase==='checking'?'not-allowed':'pointer', borderRadius: 0,
textShadow: (D.phase==='checking'||isActive)?'none':`0 0 6px ${t.accent}80`,
}}>
{D.phase==='checking'
? <>{'>'} running<span style={{ animation:'drv-blink 1s steps(2) infinite' }}>_</span></>
: <>{'> check_connection --strict'}</>}
</button>
{/* status */}
<div style={{ height: 12 }}/>
<div style={cliHead(t)}> status </div>
{D.phase === 'idle'
? <div style={{ marginLeft: 4, paddingLeft: 14, borderLeft:`1px solid ${t.borderSoft}`, color: t.dim }}>
{'> '} ready to check<span style={{ animation: 'drv-blink 1s steps(2) infinite' }}>_</span>
</div>
: <div style={{ marginLeft: 4, paddingLeft: 14, borderLeft:`1px solid ${t.borderSoft}` }}>
{D.phase === 'checking'
? <div style={{ color: t.dim }}>{'> '} running diagnostics<span style={{ animation:'drv-blink 1s steps(2) infinite' }}>...</span></div>
: (D.lastSummary?.failed === 0
? <div style={{ color: t.accent, fontWeight: 600 }}>[ ] all checks passed. ready to start.</div>
: <div style={{ color: t.warn, fontWeight: 600 }}>[ ! ] {D.lastSummary?.failed}/{D.tests.length} failed. some features won't work.</div>
)}
<div style={{ marginTop: 6 }}>
{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 (
<div key={test.id}>
<div style={{ display:'flex', gap: 9, alignItems:'baseline', padding:'1px 0' }}>
<span style={{ color: c, width: 10 }}>{sym}</span>
<span style={{ color: state==='pending'?t.dimmer:t.text, flex: 1 }} title={test.desc}>{test.label}</span>
<span style={{ color: c, fontSize: 10.5 }}>{r?.metric || (state==='running'?'':'')}</span>
{r?.result === 'failed' && (
<button onClick={() => D.toggleExpand(test.id)} style={{
background:'transparent', border:'none', color: t.dim, cursor:'pointer', padding: 0,
}}>{r.expanded?'[]':'[+]'}</button>
)}
</div>
{r?.result === 'failed' && r.expanded && (
<div className="drv-fadein" style={{
margin:'2px 0 6px 19px', padding:'4px 8px', borderLeft:`2px solid ${t.danger}`,
background: 'rgba(255,107,107,0.06)', fontSize: 10.5,
}}>
<div style={{ color: t.danger, fontWeight: 600 }}>! {r.error}</div>
<div style={{ color: t.dim }}>{r.hint}</div>
</div>
)}
</div>
);
})}
</div>
</div>
}
{/* actions */}
<div style={{ height: 12 }}/>
<div style={{ display:'flex', gap: 6 }}>
{(() => {
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 (
<div style={{
flex: 1, padding:'7px 12px', border:`1px solid ${c}`, color: c, fontWeight: 700,
background: `${c}14`, textShadow: `0 0 6px ${c}80`,
display:'flex', alignItems:'center', gap: 7,
}}>
<span className="drv-pulsedot" style={{ width: 7, height: 7, borderRadius: 3, background: c, boxShadow:`0 0 6px ${c}` }}/>
[ ACTIVE{warning ? ' / UDP-WARN' : ''} ]
</div>
);
}
return (
<button onClick={D.startProxy} disabled={!ok} style={{
flex: 1, padding:'7px 12px', border:`1px solid ${ok?t.accent:t.dim}`,
background: ok ? `${t.accent}14` : 'transparent',
color: ok ? t.accent : t.dim, fontFamily: fontMono, fontWeight: 600, fontSize: 11.5,
letterSpacing: 1, textAlign:'left', cursor: ok?'pointer':'not-allowed',
textShadow: ok ? `0 0 6px ${t.accent}80` : 'none', borderRadius: 0,
}}>{'>'} start_proxying</button>
);
})()}
<button onClick={D.stopProxy} disabled={!isActive} style={{
flex: 1, padding:'7px 12px', border:`1px solid ${isActive?t.dim:t.borderSoft}`,
background:'transparent', color: isActive ? t.text : t.dimmer, fontFamily: fontMono,
fontWeight: 600, fontSize: 11.5, letterSpacing: 1, textAlign:'left',
cursor: isActive?'pointer':'not-allowed', borderRadius: 0,
}}>{''} stop</button>
</div>
{isActive && (
<div style={{
marginTop: 8, padding:'5px 9px', border:`1px dashed ${t.borderSoft}`,
color: t.dim, fontSize: 10.5, display:'flex', justifyContent:'space-between',
}}>
<span>↑ {window.fmtBytes(D.stats.up).padEnd(10)}</span>
<span>↓ {window.fmtBytes(D.stats.down).padEnd(10)}</span>
<span>tcp={D.stats.tcp}</span>
<span>udp={D.stats.udp}</span>
<span>up={window.fmtUptime(D.stats.uptimeS)}</span>
</div>
)}
</div>
{/* logs */}
<div style={{ borderTop:`1px solid ${t.border}`, background: t.chrome, flexShrink: 0, position:'relative', zIndex: 6 }}>
<button onClick={() => 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,
}}>
<span>{D.logsOpen?'':''}</span>
<span style={{ fontWeight: 600 }}>tail -f drover.log</span>
<span style={{ marginLeft:'auto', color: t.dimmer }}>{D.logs.length} lines</span>
</button>
{D.logsOpen && (
<>
<div style={{ display:'flex', gap: 6, padding:'0 14px 6px' }}>
{[['copy', () => navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n'))],
['clear', D.clearLogs], ['file', null]].map(([l, fn]) => (
<button key={l} onClick={fn||undefined} style={{
background:'transparent', border:`1px solid ${t.borderSoft}`, color: t.dim,
padding:'2px 7px', fontSize: 10, fontFamily: fontMono, cursor:'pointer', borderRadius: 0,
}}>{`[${l}]`}</button>
))}
</div>
<div className="drv-log" ref={el => 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) => (
<div key={i}>
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>{' '}
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.accent }}>{l.level}</span>{' '}
<span style={{ color: t.text }}>{l.msg}</span>
</div>
))}
</div>
</>
)}
</div>
</div>
);
}
function cliHead(t) { return { color: t.dim, fontSize: 10.5, marginBottom: 2, letterSpacing: 0.3 }; }
function CliRow({ children, style }) {
return <div style={{ display:'flex', alignItems:'center', gap: 7, ...style }}>{children}</div>;
}
function CliInput({ value, onChange, placeholder, type, onSubmit, style, t, id }) {
return <input id={id} value={value} type={type||'text'}
onChange={e => 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;
+293
View File
@@ -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 (
<div style={{
width: 480, height: 640, background: t.bg, color: t.text, display:'flex', flexDirection:'column',
fontFamily: fontUI, fontSize: 12, overflow: 'hidden',
border: '1px solid #000',
}}>
{/* title bar */}
<div style={{
height: 28, background: t.chrome, borderBottom:`1px solid ${t.border}`,
display:'flex', alignItems:'center', userSelect:'none',
}}>
<div style={{ display:'flex', alignItems:'center', gap: 7, padding:'0 10px', flex:1 }}>
<window.BrandMark size={12} color={t.accent}/>
<span style={{ fontSize: 11.5, fontWeight: 600 }}>drover-go</span>
<span style={{ fontSize: 10, color: t.dimmer, fontFamily: fontMono }}>0.4.2</span>
<span style={{ fontSize: 10, color: t.dim, marginLeft: 8, fontFamily: fontMono }}>
· {D.form.host}:{D.form.port}{D.form.auth ? ' · auth' : ''}
</span>
</div>
<div style={{ display:'flex' }}>
<CompactCell t={t}><window.IconGear color={t.dim}/></CompactCell>
<CompactCell t={t}><window.IconMin color={t.dim}/></CompactCell>
<CompactCell t={t} hover="#c0463f"><window.IconClose color={t.dim}/></CompactCell>
</div>
</div>
<div style={{ flex: 1, overflow:'auto' }}>
{/* Row 1: form, single tight line */}
<div style={{ padding: '10px 12px', borderBottom:`1px solid ${t.borderSoft}` }}>
<div style={cptHead(t)}>SOCKS5</div>
<div style={{ display:'flex', gap: 6, alignItems:'center' }}>
<CptInput t={t} fontMono={fontMono} value={D.form.host}
onChange={v => D.update({ host: v })} placeholder="host" style={{ flex: 1 }}
onSubmit={D.runCheck}/>
<span style={{ color: t.dimmer, fontFamily: fontMono }}>:</span>
<CptInput t={t} fontMono={fontMono} value={D.form.port}
onChange={v => D.update({ port: v.replace(/\D/g,'') })}
placeholder="port" style={{ width: 64 }} onSubmit={D.runCheck}/>
<CptCheck t={t} checked={D.form.auth} onChange={(v) => {
D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('cpt-login')?.focus(),30);
}}>auth</CptCheck>
</div>
{D.form.auth && (
<div style={{ display:'flex', gap: 6, marginTop: 6 }}>
<CptInput id="cpt-login" t={t} fontMono={fontMono} value={D.form.login}
onChange={v => D.update({ login: v })} placeholder="login"
style={{ flex: 1 }} onSubmit={D.runCheck}/>
<CptInput t={t} fontMono={fontMono} value={D.form.password} type="password"
onChange={v => D.update({ password: v })} placeholder="password"
style={{ flex: 1 }} onSubmit={D.runCheck}/>
</div>
)}
<button onClick={D.runCheck} disabled={D.phase === 'checking' || isActive} style={{
marginTop: 8, width:'100%', padding:'6px 10px', borderRadius: 2, fontFamily: fontMono,
background: (D.phase==='checking'||isActive)?t.panel2:t.accent,
color: (D.phase==='checking'||isActive)?t.dimmer:t.primaryFg,
border:`1px solid ${t.border}`, fontWeight: 700, fontSize: 11.5, letterSpacing: 0.5,
cursor: D.phase==='checking'?'not-allowed':'pointer', textAlign: 'left',
}}>
{'>>'} {D.phase==='checking'?'CHECKING…':'check connection'}
</button>
</div>
{/* Row 2: status table, full width */}
<div style={{ padding: '8px 12px', borderBottom:`1px solid ${t.borderSoft}` }}>
<div style={cptHead(t)}>
<span>STATUS</span>
<span style={{ marginLeft:'auto', color: t.dim, fontFamily: fontMono }}>
{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`)}
</span>
</div>
{D.phase === 'idle'
? <div style={{ color: t.dim, fontFamily: fontMono, fontSize: 11 }}>
<span style={{ color: t.dimmer }}></span> ready to check_
</div>
: <CompactStatusTable t={t} D={D} palette={palette} fontMono={fontMono}/>}
</div>
{/* Row 3: actions */}
<div style={{ padding: '8px 12px' }}>
<div style={{ display:'flex', gap: 6 }}>
<CompactStartBtn t={t} D={D} fontMono={fontMono}/>
<CompactStopBtn t={t} D={D} fontMono={fontMono}/>
</div>
{isActive && (
<div style={{
marginTop: 8, padding: '5px 8px', background: t.panel,
border:`1px solid ${t.borderSoft}`, borderRadius: 2,
fontFamily: fontMono, fontSize: 10.5, color: t.dim,
display:'flex', justifyContent:'space-between',
}}>
<span><window.IconArrowUp color={t.pass}/> {window.fmtBytes(D.stats.up)}</span>
<span><window.IconArrowDown color={t.accent}/> {window.fmtBytes(D.stats.down)}</span>
<span>tcp:{D.stats.tcp}</span>
<span>udp:{D.stats.udp}</span>
<span>up:{window.fmtUptime(D.stats.uptimeS)}</span>
</div>
)}
</div>
</div>
{/* Logs */}
<CompactLogs t={t} D={D} fontMono={fontMono}/>
</div>
);
}
function CompactCell({ children, t, hover }) {
const [h, setH] = React.useState(false);
return <div onMouseEnter={()=>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}</div>;
}
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 <input id={id} value={value} type={type||'text'}
onChange={e => 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 (
<label style={{
display:'inline-flex', alignItems:'center', gap: 5, cursor:'pointer', userSelect:'none',
padding:'4px 7px', border:`1px solid ${checked?t.accent:t.border}`, borderRadius: 2,
fontFamily: '"JetBrains Mono",monospace', fontSize: 11,
background: checked ? `${t.accent}22` : 'transparent', color: checked ? t.accent : t.dim,
}}>
<span>[{checked ? 'x' : ' '}]</span>
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{display:'none'}}/>
<span>{children}</span>
</label>
);
}
function CompactStatusTable({ t, D, palette, fontMono }) {
return (
<div style={{ fontFamily: fontMono, fontSize: 11 }}>
{D.tests.map((test) => {
const r = D.results[test.id];
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
return (
<div key={test.id}>
<div style={{
display:'flex', alignItems:'center', gap: 7, padding:'2px 0', height: 20,
color: state === 'pending' ? t.dimmer : t.text,
}}>
<span style={{ width: 10, color: t.dimmer }}>{
state==='passed'?'✓':state==='failed'?'✗':state==='skipped'?'':state==='running'?'':'·'
}</span>
<window.StatusDot state={state} palette={palette} size={10}/>
<span title={test.desc}>{test.label}</span>
<span style={{
marginLeft:'auto',
color: state==='failed'?t.danger:state==='skipped'?t.skip:state==='passed'?t.pass:t.dim,
}}>{r?.metric || (state==='running'?'…':'')}</span>
{r?.result === 'failed' && (
<button onClick={() => D.toggleExpand(test.id)} style={{
background:'transparent', border:'none', cursor:'pointer', padding: 2, color: t.dim,
}}><window.IconChevron color={t.dim} dir={r.expanded?'up':'down'}/></button>
)}
</div>
{r?.result === 'failed' && r.expanded && (
<div className="drv-fadein" style={{
margin: '2px 0 6px 17px', padding:'5px 8px',
background: t.panel2, borderLeft:`2px solid ${t.danger}`,
fontSize: 10.5, color: t.dim,
}}>
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 2 }}>{r.error}</div>
<div>{r.hint}</div>
</div>
)}
</div>
);
})}
</div>
);
}
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 (
<div style={{
flex:1, padding:'6px 10px', border:`1px solid ${c}`, borderRadius: 2,
background: `${c}1a`, color: c, fontFamily: fontMono, fontWeight: 700, fontSize: 11.5,
display:'flex', alignItems:'center', gap: 6,
}}>
<span className="drv-pulsedot" style={{ width: 6, height: 6, borderRadius: 3, background: c }}/>
ACTIVE{warning ? ' · UDP-FALLBACK' : ''}
</div>
);
}
return (
<button onClick={D.startProxy} disabled={!ok} style={{
flex: 1, padding:'6px 10px', borderRadius: 2,
background: ok ? t.accent : t.panel, color: ok ? t.primaryFg : t.dimmer,
border:`1px solid ${ok ? t.accent : t.border}`, fontFamily: fontMono,
fontWeight: 700, fontSize: 11.5, letterSpacing: 0.5, textAlign:'left',
cursor: ok ? 'pointer' : 'not-allowed',
}}>{'>'} start proxying</button>
);
}
function CompactStopBtn({ t, D, fontMono }) {
const enabled = D.phase === 'active';
return (
<button onClick={D.stopProxy} disabled={!enabled} style={{
flex: 1, padding:'6px 10px', borderRadius: 2,
background: t.panel, color: enabled ? t.text : t.dimmer,
border:`1px solid ${t.border}`, fontFamily: fontMono,
fontWeight: 700, fontSize: 11.5, letterSpacing: 0.5, textAlign:'left',
cursor: enabled ? 'pointer' : 'not-allowed',
}}>{'■'} stop</button>
);
}
function CompactLogs({ t, D, fontMono }) {
return (
<div style={{ borderTop:`1px solid ${t.border}`, background: t.chrome, flexShrink: 0 }}>
<button onClick={() => 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,
}}>
<window.IconChevron color={t.dim} dir={D.logsOpen?'down':'right'}/>
<span style={{ fontWeight: 700 }}>LOGS</span>
<span style={{ marginLeft:'auto', color: t.dimmer }}>{D.logs.length} lines</span>
</button>
{D.logsOpen && (
<>
<div style={{ display:'flex', gap: 4, padding:'0 12px 6px' }}>
{[['copy', () => navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n'))],
['clear', D.clearLogs], ['file', null]].map(([l, fn]) => (
<button key={l} onClick={fn||undefined} style={{
background:'transparent', border:`1px solid ${t.border}`, color: t.dim,
padding:'2px 7px', fontSize: 10, fontFamily: fontMono, cursor:'pointer', borderRadius: 2,
}}>{l}</button>
))}
</div>
<div className="drv-log" ref={el => 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) => (
<div key={i}>
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>{' '}
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.pass, fontWeight: 700 }}>{l.level.padEnd(5)}</span>{' '}
{l.msg}
</div>
))}
</div>
</>
)}
</div>
);
}
window.CompactWindow = CompactWindow;
+416
View File
@@ -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 (
<div style={{
width: 480, height: 640, background: t.bg, color: t.text,
display: 'flex', flexDirection: 'column', overflow: 'hidden',
fontFamily: fontUI, fontSize: 13.5, lineHeight: 1.4,
borderRadius: 0,
}}>
<FluentTitleBar t={t} mode={mode} />
<div style={{ flex: 1, overflow: 'auto', padding: '12px 12px 0', display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* SOCKS5 form card */}
<FluentCard t={t}>
<FluentCardTitle t={t}>SOCKS5 Proxy</FluentCardTitle>
<div style={{ display: 'flex', gap: 8 }}>
<FluentField t={t} label="Host" style={{ flex: 1 }}>
<input value={D.form.host}
onChange={e => D.update({ host: e.target.value })}
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
placeholder="95.165.72.59 или example.com"
style={fluentInputStyle(t, fontMono)} />
</FluentField>
<FluentField t={t} label="Port" style={{ width: 92 }}>
<input value={D.form.port}
onChange={e => D.update({ port: e.target.value.replace(/\D/g,'') })}
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
placeholder="12334" inputMode="numeric"
style={fluentInputStyle(t, fontMono)} />
</FluentField>
</div>
<FluentCheckbox t={t} checked={D.form.auth}
onChange={(v) => { D.update({ auth: v }); if (v) setTimeout(() => document.getElementById('flu-login')?.focus(), 30); }}>
Authentication
</FluentCheckbox>
<div style={{ display: 'flex', gap: 8, marginTop: 8, opacity: D.form.auth ? 1 : 0.45, pointerEvents: D.form.auth ? 'auto' : 'none' }}>
<FluentField t={t} label="Login" style={{ flex: 1 }}>
<input id="flu-login" disabled={!D.form.auth} value={D.form.login}
onChange={e => D.update({ login: e.target.value })}
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
placeholder="user" style={fluentInputStyle(t, fontMono, !D.form.auth)} />
</FluentField>
<FluentField t={t} label="Password" style={{ flex: 1 }}>
<input disabled={!D.form.auth} type="password" value={D.form.password}
onChange={e => D.update({ password: e.target.value })}
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
placeholder="••••••" style={fluentInputStyle(t, fontMono, !D.form.auth)} />
</FluentField>
</div>
<FluentPrimaryBtn t={t} onClick={D.runCheck} disabled={D.phase === 'checking' || isActive}>
{D.phase === 'checking' ? 'Checking…' : 'Check connection'}
</FluentPrimaryBtn>
</FluentCard>
{/* Status card */}
<FluentCard t={t}>
<FluentCardTitle t={t}>Status</FluentCardTitle>
<FluentStatus t={t} D={D} palette={palette} fontMono={fontMono} themeKey={themeKey} />
</FluentCard>
{/* Action buttons */}
<div style={{ display: 'flex', gap: 8 }}>
<FluentStartBtn t={t} D={D} fontMono={fontMono} />
<FluentStopBtn t={t} D={D} />
</div>
{isActive && <FluentLiveStats t={t} stats={D.stats} fontMono={fontMono} />}
<div style={{ height: 8 }} />
</div>
<FluentLogs t={t} D={D} fontMono={fontMono} />
</div>
);
}
function FluentTitleBar({ t, mode }) {
return (
<div style={{
height: 32, background: t.chrome, borderBottom: `1px solid ${t.border}`,
display: 'flex', alignItems: 'center',
fontFamily: "'Segoe UI Variable','Segoe UI',system-ui,sans-serif",
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '0 12px', flex: 1 }}>
<window.BrandMark size={14} color={t.accent} />
<span style={{ fontSize: 12.5, fontWeight: 600 }}>Drover-Go</span>
<span style={{ fontSize: 11, color: t.dimmer }}>0.4.2</span>
</div>
<div style={{ display: 'flex' }}>
<FluentTitleBtn t={t}><window.IconGear color={t.dim} /></FluentTitleBtn>
<FluentTitleBtn t={t}><window.IconMin color={t.dim} /></FluentTitleBtn>
<FluentTitleBtn t={t} hoverBg="#c42b1c" hoverFg="#fff"><window.IconClose color={t.dim} /></FluentTitleBtn>
</div>
</div>
);
}
function FluentTitleBtn({ children, t, hoverBg, hoverFg }) {
const [hover, setHover] = React.useState(false);
return (
<div onMouseEnter={() => 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}</div>
);
}
function FluentCard({ t, children, style }) {
return (
<div style={{
background: t.panel, border: `1px solid ${t.border}`, borderRadius: 6,
padding: 12, boxShadow: '0 1px 2px rgba(0,0,0,0.04)',
display: 'flex', flexDirection: 'column', gap: 8, ...style,
}}>{children}</div>
);
}
function FluentCardTitle({ t, children }) {
return <div style={{ fontSize: 11.5, fontWeight: 600, color: t.dim, letterSpacing: 0.2 }}>{children}</div>;
}
function FluentField({ t, label, children, style }) {
return (
<label style={{ display: 'flex', flexDirection: 'column', gap: 2, ...style }}>
<span style={{ fontSize: 11, color: t.dimmer }}>{label}</span>
{children}
</label>
);
}
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 (
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none', fontSize: 12.5, marginTop: 2 }}>
<span style={{
width: 18, height: 18, borderRadius: 4,
border: `1.5px solid ${checked ? t.accent : t.borderHard}`,
background: checked ? t.accent : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'background .12s, border-color .12s',
}}>
{checked && <svg width="11" height="11" viewBox="0 0 11 11"><path d="M2 5.5l2.5 2.5 4.5-5" stroke="#fff" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</span>
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display: 'none' }} />
<span>{children}</span>
</label>
);
}
function FluentPrimaryBtn({ t, onClick, disabled, children, style }) {
const [hover, setHover] = React.useState(false);
return (
<button onClick={onClick} disabled={disabled}
onMouseEnter={() => 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}</button>
);
}
function FluentStatus({ t, D, palette, fontMono, themeKey }) {
if (D.phase === 'idle') {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0', color: t.dim, fontSize: 12.5 }}>
<span style={{ width: 8, height: 8, borderRadius: 4, background: t.dimmer }} />
<span>Ready to check</span>
</div>
);
}
return (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5,
color: D.phase === 'checking' ? t.accent
: D.lastSummary?.failed === 0 ? t.pass : t.warn,
fontWeight: 600,
}}>
{D.phase === 'checking'
? <>
<window.StatusDot state="running" palette={palette} size={12} />
<span>Running diagnostics</span>
<span style={{ marginLeft: 'auto', color: t.dim, fontFamily: fontMono, fontSize: 11, fontWeight: 400 }}>
{Object.keys(D.results).length}/{D.tests.length}
</span>
</>
: D.lastSummary?.failed === 0
? <span>All checks passed. Ready to start.</span>
: <span>{D.lastSummary?.failed} of {D.tests.length} checks failed. Some features won't work.</span>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 1, marginTop: 4 }}>
{D.tests.map((test) => {
const r = D.results[test.id];
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
return (
<div key={test.id}>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '4px 2px', minHeight: 22 }}>
<window.StatusDot state={state} palette={palette} size={12} />
<span style={{ fontSize: 12.5, color: state === 'pending' ? t.dimmer : t.text }} title={test.desc}>
{test.label}
</span>
<span style={{ marginLeft: 'auto', fontFamily: fontMono, fontSize: 11,
color: state === 'failed' ? t.danger : state === 'skipped' ? t.skip : t.dim }}>
{r?.metric || (state === 'running' ? '' : '')}
</span>
{r?.result === 'failed' && (
<button onClick={() => D.toggleExpand(test.id)}
style={{ width: 20, height: 20, padding: 0, border: 'none', background: 'transparent', cursor: 'pointer' }}>
<window.IconChevron color={t.dim} dir={r.expanded ? 'up' : 'down'} />
</button>
)}
</div>
{r?.result === 'failed' && r.expanded && (
<div className="drv-fadein" style={{
margin: '2px 0 4px 21px', padding: '8px 10px', borderRadius: 4,
background: themeKey === 'l' ? '#fdf2f2' : '#3a1f1f',
border: `1px solid ${t.danger}`,
fontSize: 11.5,
}}>
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 2 }}>{r.error}</div>
<div style={{ color: t.dim }}>{r.hint}</div>
<button onClick={() => 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
</button>
</div>
)}
</div>
);
})}
</div>
</>
);
}
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 (
<div style={{
flex: 1, height: 34, borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
background: warning ? t.warnBg : t.activeBg,
border: `1px solid ${warning ? t.warnBorder : t.activeBorder}`,
color: warning ? t.warn : t.pass, fontWeight: 600, fontSize: 12.5,
}}>
<span className="drv-pulsedot" style={{ width: 8, height: 8, borderRadius: 4, background: warning ? t.warn : t.pass }} />
Active{warning ? ' · UDP fallback' : ''}
</div>
);
}
return (
<FluentPrimaryBtn t={t} disabled={!checkedOk} onClick={D.startProxy} style={{ flex: 1, marginTop: 0 }}>
Start proxying
</FluentPrimaryBtn>
);
}
function FluentStopBtn({ t, D }) {
const enabled = D.phase === 'active';
const [hover, setHover] = React.useState(false);
return (
<button onClick={D.stopProxy} disabled={!enabled}
onMouseEnter={() => 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</button>
);
}
function FluentLiveStats({ t, stats, fontMono }) {
const cell = (label, val) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: t.dim, fontSize: 11.5, fontFamily: fontMono }}>
{label}<span style={{ color: t.text }}>{val}</span>
</div>
);
return (
<div style={{
padding: '8px 12px', borderRadius: 4,
background: t.panel, border: `1px solid ${t.border}`,
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}>
{cell(<window.IconArrowUp color={t.dim} />, window.fmtBytes(stats.up))}
{cell(<window.IconArrowDown color={t.dim} />, window.fmtBytes(stats.down))}
{cell(<span style={{ fontSize: 9, color: t.dimmer }}>TCP</span>, stats.tcp)}
{cell(<span style={{ fontSize: 9, color: t.dimmer }}>UDP</span>, stats.udp)}
{cell(<span style={{ fontSize: 9, color: t.dimmer }}>↑t</span>, window.fmtUptime(stats.uptimeS))}
</div>
);
}
function FluentLogs({ t, D, fontMono }) {
return (
<div style={{ borderTop: `1px solid ${t.border}`, background: t.chrome, flexShrink: 0 }}>
<button onClick={() => 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,
}}>
<window.IconChevron color={t.dim} dir={D.logsOpen ? 'down' : 'right'} />
<span>Logs</span>
<span style={{ marginLeft: 'auto', color: t.dimmer }}>{D.logs.length} lines</span>
</button>
{D.logsOpen && (
<div style={{ borderTop: `1px solid ${t.border}` }}>
<div style={{ display: 'flex', gap: 6, padding: '6px 12px', borderBottom: `1px solid ${t.border}` }}>
{[
['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) => (
<button key={i} onClick={fn} style={{
padding: '3px 8px', fontSize: 11, fontFamily: fontMono,
background: t.panel, color: t.dim, border: `1px solid ${t.borderHard}`,
borderRadius: 3, cursor: 'pointer',
}}>{l}</button>
))}
</div>
<div className="drv-log" style={{
maxHeight: 130, overflowY: 'auto', padding: '6px 12px',
fontFamily: fontMono, fontSize: 10.5, lineHeight: 1.55, color: t.dim,
background: t.panelAlt,
}} ref={el => el && (el.scrollTop = el.scrollHeight)}>
{D.logs.map((l, i) => (
<div key={i}>
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>
{' '}
<span style={{ color: l.level === 'ERROR' ? t.danger : l.level === 'WARN' ? t.warn : t.pass, fontWeight: 600 }}>[{l.level}]</span>
{' '}
<span>{l.msg}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
window.FluentWindow = FluentWindow;
+348
View File
@@ -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 (
<div style={{
width: 480, height: 640, color: t.text, display: 'flex', flexDirection: 'column',
fontFamily: fontUI, fontSize: 13, position: 'relative',
background: t.bgGrad, overflow: 'hidden', borderRadius: 0,
}}>
{/* gradient orbs */}
<div style={{ position:'absolute', width: 280, height: 280, borderRadius:'50%',
background: 'radial-gradient(closest-side, rgba(155,139,255,0.55), transparent)',
top: -80, right: -80, filter: 'blur(20px)', pointerEvents:'none' }}/>
<div style={{ position:'absolute', width: 240, height: 240, borderRadius:'50%',
background: mode==='dark' ? 'radial-gradient(closest-side, rgba(126,224,179,0.35), transparent)'
: 'radial-gradient(closest-side, rgba(255,180,210,0.55), transparent)',
bottom: -90, left: -60, filter: 'blur(30px)', pointerEvents:'none' }}/>
{/* title */}
<GlassTitle t={t}/>
<div style={{ flex: 1, overflow:'auto', padding: '10px 14px 0', position:'relative', zIndex: 1 }}>
<GlassPanel t={t}>
<GlassHeader t={t}>SOCKS5 Proxy</GlassHeader>
<div style={{ display:'flex', gap: 10 }}>
<GField t={t} label="Host" style={{ flex: 1 }}>
<input value={D.form.host} onChange={e => D.update({ host: e.target.value })}
placeholder="95.165.72.59 или example.com"
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={glassInput(t, fontUI)}/>
</GField>
<GField t={t} label="Port" style={{ width: 96 }}>
<input value={D.form.port} onChange={e => D.update({ port: e.target.value.replace(/\D/g,'') })}
placeholder="12334" inputMode="numeric"
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={glassInput(t, fontUI)}/>
</GField>
</div>
<div style={{ height: 12 }}/>
<GlassToggle t={t} checked={D.form.auth}
onChange={(v) => { D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('gl-login')?.focus(),30); }}>
Authentication
</GlassToggle>
<div style={{ display:'flex', gap: 10, marginTop: 10, opacity: D.form.auth ? 1 : 0.4,
pointerEvents: D.form.auth ? 'auto':'none' }}>
<GField t={t} label="Login" style={{ flex: 1 }}>
<input id="gl-login" disabled={!D.form.auth} value={D.form.login}
onChange={e => D.update({ login: e.target.value })} placeholder="user"
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={glassInput(t, fontUI, !D.form.auth)}/>
</GField>
<GField t={t} label="Password" style={{ flex: 1 }}>
<input disabled={!D.form.auth} type="password" value={D.form.password}
onChange={e => D.update({ password: e.target.value })} placeholder="••••••"
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={glassInput(t, fontUI, !D.form.auth)}/>
</GField>
</div>
<div style={{ height: 14 }}/>
<GlassPrimary t={t} onClick={D.runCheck} disabled={D.phase === 'checking' || isActive}>
{D.phase === 'checking' ? 'Checking…' : 'Check connection'}
</GlassPrimary>
</GlassPanel>
<div style={{ height: 12 }}/>
<GlassPanel t={t}>
<GlassHeader t={t}>Status</GlassHeader>
<GlassStatus t={t} D={D} palette={palette} fontMono={fontMono}/>
</GlassPanel>
<div style={{ height: 12 }}/>
<GlassPanel t={t}>
<div style={{ display:'flex', gap: 10 }}>
<GlassStartBtn t={t} D={D}/>
<GlassStopBtn t={t} D={D}/>
</div>
{isActive && <GlassLiveStats t={t} stats={D.stats} fontMono={fontMono}/>}
</GlassPanel>
<div style={{ height: 12 }}/>
</div>
<GlassLogs t={t} D={D} fontMono={fontMono}/>
</div>
);
}
function GlassTitle({ t }) {
const cell = { width: 44, height: 32, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', color: t.dim };
return (
<div style={{
height: 36, background: t.glassBg, backdropFilter: 'blur(20px)',
borderBottom: `1px solid ${t.borderSoft}`, display:'flex', alignItems:'center', position:'relative', zIndex: 2,
}}>
<div style={{ display:'flex', alignItems:'center', gap: 9, padding:'0 14px', flex:1 }}>
<window.BrandMark size={15} color={t.accent}/>
<span style={{ fontSize: 13, fontWeight: 600 }}>Drover-Go</span>
<span style={{ fontSize: 11, color: t.dimmer }}>0.4.2</span>
</div>
<div style={{ display:'flex' }}>
<div style={cell}><window.IconGear color={t.dim}/></div>
<div style={cell}><window.IconMin color={t.dim}/></div>
<div style={cell}><window.IconClose color={t.dim}/></div>
</div>
</div>
);
}
function GlassPanel({ t, children, style }) {
return <div style={{
background: t.glassBg, backdropFilter: 'blur(24px) saturate(140%)',
WebkitBackdropFilter: 'blur(24px) saturate(140%)',
border: `1px solid ${t.border}`, borderRadius: 14, padding: 14,
boxShadow: `inset 0 1px 0 ${t.borderSoft}`,
...style,
}}>{children}</div>;
}
function GlassHeader({ t, children }) {
return <div style={{ fontSize: 11.5, fontWeight: 600, color: t.dim, marginBottom: 10, letterSpacing: 1.5, textTransform:'uppercase' }}>{children}</div>;
}
function GField({ t, label, children, style }) {
return <label style={{ display:'flex', flexDirection:'column', gap: 5, ...style }}>
<span style={{ fontSize: 11, color: t.dim }}>{label}</span>{children}
</label>;
}
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 (
<label style={{ display:'inline-flex', alignItems:'center', gap: 10, cursor:'pointer' }}>
<span style={{
width: 36, height: 20, borderRadius: 10, padding: 2, boxSizing:'border-box',
background: checked ? t.accent : 'rgba(255,255,255,0.18)',
boxShadow: checked ? `0 0 12px ${t.accentGlow}80` : 'none',
transition: 'background .14s, box-shadow .14s', display:'flex', alignItems:'center',
}}>
<span style={{
width: 14, height: 14, borderRadius: 7, background: 'white',
transform: checked ? 'translateX(16px)' : 'translateX(0)', transition: 'transform .14s',
}}/>
</span>
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display:'none' }}/>
<span style={{ fontSize: 13 }}>{children}</span>
</label>
);
}
function GlassPrimary({ t, onClick, disabled, children, style }) {
return (
<button onClick={onClick} disabled={disabled} style={{
width:'100%', padding: '10px 14px', border:'none', borderRadius: 10,
background: disabled ? 'rgba(255,255,255,0.08)' : `linear-gradient(135deg, ${t.accent}, ${t.accentGlow})`,
color: disabled ? t.dimmer : t.primaryFg,
fontWeight: 600, fontSize: 13, cursor: disabled?'not-allowed':'pointer',
boxShadow: disabled ? 'none' : `0 6px 24px ${t.accentGlow}55, inset 0 1px 0 rgba(255,255,255,0.4)`,
...style,
}}>{children}</button>
);
}
function GlassStatus({ t, D, palette, fontMono }) {
if (D.phase === 'idle') {
return (
<div style={{ display:'flex', alignItems:'center', gap: 10 }}>
<span style={{ width: 8, height: 8, borderRadius: 4, background: t.dimmer }}/>
<span style={{ color: t.dim }}>Ready to check</span>
</div>
);
}
const allOk = D.lastSummary?.failed === 0;
return (
<div>
{D.phase === 'checking'
? <div style={{ display:'flex', alignItems:'center', gap: 9, marginBottom: 8 }}>
<window.StatusDot state="running" palette={palette} size={14}/>
<span style={{ fontWeight: 500 }}>Running diagnostics</span>
</div>
: <div style={{
padding: '7px 11px', borderRadius: 8, marginBottom: 10,
background: allOk ? 'rgba(126,224,179,0.16)' : 'rgba(255,196,107,0.18)',
border: `1px solid ${allOk ? t.pass : t.warn}55`,
color: allOk ? t.pass : t.warn, fontWeight: 600, fontSize: 12.5,
}}>{allOk ? 'All checks passed. Ready to start.' : `${D.lastSummary?.failed} of ${D.tests.length} checks failed. Some features won't work.`}</div>}
<div>
{D.tests.map(test => {
const r = D.results[test.id];
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
return (
<div key={test.id} style={{ padding:'4px 0' }}>
<div style={{ display:'flex', alignItems:'center', gap: 9, height: 26 }}>
<window.StatusDot state={state} palette={palette} size={14}/>
<span style={{ color: state==='pending'?t.dim:t.text, fontSize: 13 }} title={test.desc}>{test.label}</span>
<span style={{ marginLeft:'auto', fontFamily: fontMono, fontSize: 11.5,
color: state==='failed'?t.danger:state==='skipped'?t.skip:t.dim }}>
{r?.metric}
</span>
{r?.result === 'failed' && (
<button onClick={() => D.toggleExpand(test.id)} style={{
background:'transparent', border:'none', cursor:'pointer', padding: 4, color: t.dim,
}}><window.IconChevron color={t.dim} dir={r.expanded?'up':'down'}/></button>
)}
</div>
{r?.result === 'failed' && r.expanded && (
<div className="drv-fadein" style={{
margin: '4px 0 6px 23px', padding: 10, borderRadius: 8,
background: 'rgba(255,138,152,0.10)', border: `1px solid ${t.danger}40`,
fontSize: 12,
}}>
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 3 }}>{r.error}</div>
<div style={{ color: t.dim, lineHeight: 1.5 }}>{r.hint}</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
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 (
<div style={{
flex: 1, padding: '10px 14px', borderRadius: 10, fontWeight: 600, fontSize: 13,
background: warning ? 'rgba(255,196,107,0.16)' : 'rgba(126,224,179,0.16)',
border: `1px solid ${c}66`, color: c,
boxShadow: `0 0 24px ${c}40, inset 0 1px 0 rgba(255,255,255,0.18)`,
display:'flex', alignItems:'center', justifyContent:'center', gap: 9,
}}>
<span className="drv-pulsedot" style={{ width: 8, height: 8, borderRadius: 4, background: c, boxShadow: `0 0 8px ${c}` }}/>
Active{warning ? ' · UDP fallback' : ''}
</div>
);
}
return <GlassPrimary t={t} onClick={D.startProxy} disabled={!checkedOk} style={{ flex: 1 }}>Start proxying</GlassPrimary>;
}
function GlassStopBtn({ t, D }) {
const enabled = D.phase === 'active';
return (
<button onClick={D.stopProxy} disabled={!enabled} style={{
flex: 1, padding: '10px 14px', borderRadius: 10,
background: 'rgba(255,255,255,0.08)', color: enabled ? t.text : t.dimmer,
border: `1px solid ${t.borderSoft}`, fontWeight: 600, fontSize: 13,
cursor: enabled ? 'pointer':'not-allowed', backdropFilter: 'blur(8px)',
}}>Stop</button>
);
}
function GlassLiveStats({ t, stats, fontMono }) {
const C = ({ icon, val, lbl }) => (
<div style={{ display:'flex', alignItems:'center', gap: 4 }}>
{icon}<span style={{ fontFamily: fontMono, fontSize: 11.5 }}>{val}</span>
{lbl && <span style={{ fontSize: 9.5, color: t.dimmer, textTransform:'uppercase', letterSpacing:0.5 }}>{lbl}</span>}
</div>
);
return (
<div style={{
marginTop: 11, paddingTop: 11, borderTop: `1px solid ${t.borderSoft}`,
display:'flex', justifyContent:'space-between', color: t.dim,
}}>
<C icon={<window.IconArrowUp color={t.pass}/>} val={window.fmtBytes(stats.up)}/>
<C icon={<window.IconArrowDown color={t.accent}/>} val={window.fmtBytes(stats.down)}/>
<C val={stats.tcp} lbl="tcp"/>
<C val={stats.udp} lbl="udp"/>
<C val={window.fmtUptime(stats.uptimeS)} lbl="up"/>
</div>
);
}
function GlassLogs({ t, D, fontMono }) {
return (
<div style={{
borderTop: `1px solid ${t.borderSoft}`,
background: t.glassBg, backdropFilter: 'blur(20px)',
flexShrink: 0, position:'relative', zIndex: 2,
}}>
<button onClick={() => 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,
}}>
<window.IconChevron color={t.dim} dir={D.logsOpen ? 'down' : 'right'}/>
<span style={{ fontWeight: 600, letterSpacing: 0.5 }}>Logs</span>
<span style={{ marginLeft: 'auto', fontFamily: fontMono, fontSize: 11, color: t.dimmer }}>{D.logs.length}</span>
</button>
{D.logsOpen && (
<>
<div style={{ display:'flex', gap: 6, padding: '0 16px 8px' }}>
{['Copy all','Clear','Open log file'].map((l,i) => (
<button key={l} onClick={i===0 ? () => 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}</button>
))}
</div>
<div className="drv-log" ref={el => 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) => (
<div key={i}>
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>{' '}
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.accent, fontWeight: 600 }}>[{l.level}]</span>{' '}
{l.msg}
</div>
))}
</div>
</>
)}
</div>
);
}
window.GlassWindow = GlassWindow;
+424
View File
@@ -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 (
<div style={{
width: 480, height: 640, background: t.bg, color: t.text,
display: 'flex', flexDirection: 'column', overflow: 'hidden',
fontFamily: fontUI, fontSize: 13.5, lineHeight: 1.4,
}}>
{/* Title bar */}
<div style={{ height: 36, display: 'flex', alignItems: 'center', padding: '0 14px', fontSize: 12.5 }}>
<window.BrandMark size={14} color={t.accent} />
<span style={{ marginLeft: 10, fontWeight: 600, color: t.text }}>Drover-Go</span>
<span style={{ marginLeft: 8, fontSize: 11, color: t.dim }}>0.4.2</span>
<span style={{ marginLeft: 'auto', fontSize: 11, color: ringColor, display: 'flex', alignItems: 'center', gap: 6 }}>
{phase === 'active' && <span className="drv-pulsedot" style={{ width: 6, height: 6, borderRadius: 3, background: ringColor }} />}
{phase === 'idle' && 'idle'}
{phase === 'checking' && 'checking…'}
{phase === 'checked' && (allFailed ? 'failed' : 'ready')}
{phase === 'active' && (warning ? 'connected · warn' : 'connected')}
</span>
<div style={{ display: 'flex', marginLeft: 8 }}>
<HeroTitleBtn t={t}><window.IconGear color={t.dim} /></HeroTitleBtn>
<HeroTitleBtn t={t}><window.IconMin color={t.dim} /></HeroTitleBtn>
<HeroTitleBtn t={t} hoverBg="#c42b1c"><window.IconClose color={t.dim} /></HeroTitleBtn>
</div>
</div>
{/* Big button + headlines */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '8px 18px 0', gap: 14, minHeight: 0 }}>
<BigToggle t={t} state={buttonState} onClick={handleBigClick} progress={
phase === 'checking' ? Object.keys(D.results).length / D.tests.length : 0
} />
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 19, fontWeight: 600 }}>{headline}</div>
<div style={{ fontSize: 12.5, color: t.dim, marginTop: 4, fontFamily: fontMono }}>{subline}</div>
</div>
{/* Stats — only when active */}
{isActive && (
<div style={{
display: 'flex', width: '100%', background: t.panel,
border: `1px solid ${t.border}`, borderRadius: 12, padding: 12, justifyContent: 'space-around',
}}>
<HeroStat icon="↑" v={fmtCompact(D.stats.up)} u={fmtUnit(D.stats.up)} c={t.accent} mono={fontMono} t={t} />
<HeroStat icon="↓" v={fmtCompact(D.stats.down)} u={fmtUnit(D.stats.down)} c={t.accent} mono={fontMono} t={t} />
<HeroStat icon="◇" v={D.stats.tcp} u="tcp" c={t.text} mono={fontMono} t={t} />
<HeroStat icon="◈" v={D.stats.udp} u="udp" c={warning ? t.warn : t.text} mono={fontMono} t={t} />
</div>
)}
{/* Diagnostic mini-list during check or after */}
{(phase === 'checking' || (phase === 'checked' && !isActive)) && (
<div style={{ width: '100%', flex: 1, minHeight: 0, overflow: 'auto' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{D.tests.map((test) => {
const r = D.results[test.id];
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
return (
<div key={test.id}>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '4px 8px', minHeight: 24, fontSize: 12.5 }}>
<window.StatusDot state={state} palette={palette} size={11} />
<span style={{ color: state === 'pending' ? t.dimmer : t.textDim }} title={test.desc}>{test.label}</span>
<span style={{ marginLeft: 'auto', fontFamily: fontMono, fontSize: 11,
color: state === 'failed' ? t.danger : state === 'skipped' ? t.skip : t.dim }}>
{r?.metric || (state === 'running' ? '…' : '')}
</span>
{r?.result === 'failed' && (
<button onClick={() => D.toggleExpand(test.id)}
style={{ width: 18, height: 18, padding: 0, border: 'none', background: 'transparent', cursor: 'pointer' }}>
<window.IconChevron color={t.dim} dir={r.expanded ? 'up' : 'down'} />
</button>
)}
</div>
{r?.result === 'failed' && r.expanded && (
<div className="drv-fadein" style={{
margin: '2px 8px 4px 28px', padding: '8px 10px', borderRadius: 6,
background: themeKey === 'd' ? 'rgba(255,107,107,.08)' : '#fdf2f2',
border: `1px solid ${t.danger}`, fontSize: 12,
}}>
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 2 }}>{r.error}</div>
<div style={{ color: t.dim }}>{r.hint}</div>
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
{/* Bottom: form (idle) or footer chips (active) */}
<div style={{ padding: '0 18px 14px' }}>
<div style={{
background: t.panel, border: `1px solid ${t.border}`, borderRadius: 12,
overflow: 'hidden',
}}>
<button onClick={() => 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',
}}>
<span style={{
width: 26, height: 26, borderRadius: 13, background: t.panelAlt,
border: `1px solid ${t.border}`, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontFamily: fontMono, fontSize: 10, color: t.accent,
}}>S5</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12.5, fontWeight: 600 }}>Proxy</div>
<div style={{ fontSize: 11, color: t.dim, fontFamily: fontMono }}>{D.form.host}:{D.form.port}{D.form.auth ? ' · auth' : ''}</div>
</div>
<window.IconChevron color={t.dim} dir={showForm ? 'up' : 'down'} />
</button>
{showForm && (
<div className="drv-fadein" style={{ borderTop: `1px solid ${t.border}`, padding: 14, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', gap: 8 }}>
<HeroInput t={t} fontMono={fontMono} value={D.form.host}
onChange={v => D.update({ host: v })}
onEnter={D.runCheck} placeholder="95.165.72.59 или example.com" label="Host" style={{ flex: 1 }} />
<HeroInput t={t} fontMono={fontMono} value={D.form.port}
onChange={v => D.update({ port: v.replace(/\D/g,'') })}
onEnter={D.runCheck} placeholder="12334" label="Port" style={{ width: 90 }} />
</div>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none', fontSize: 12.5 }}>
<span style={{
width: 16, height: 16, borderRadius: 4,
border: `1.5px solid ${D.form.auth ? t.accent : t.borderHard}`,
background: D.form.auth ? t.accent : 'transparent',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
transition: 'background .12s',
}}>
{D.form.auth && <svg width="10" height="10" viewBox="0 0 11 11"><path d="M2 5.5l2.5 2.5 4.5-5" stroke={themeKey === 'd' ? '#0c1a18' : '#fff'} strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</span>
<input type="checkbox" checked={D.form.auth}
onChange={e => { D.update({ auth: e.target.checked }); if (e.target.checked) setTimeout(() => document.getElementById('hero-login')?.focus(), 30); }}
style={{ display: 'none' }} />
<span style={{ color: t.textDim }}>Authentication</span>
</label>
<div style={{ display: 'flex', gap: 8, opacity: D.form.auth ? 1 : 0.4, pointerEvents: D.form.auth ? 'auto' : 'none' }}>
<HeroInput t={t} fontMono={fontMono} id="hero-login" value={D.form.login}
onChange={v => D.update({ login: v })} onEnter={D.runCheck}
placeholder="user" label="Login" style={{ flex: 1 }} disabled={!D.form.auth} />
<HeroInput t={t} fontMono={fontMono} value={D.form.password} type="password"
onChange={v => D.update({ password: v })} onEnter={D.runCheck}
placeholder="••••••" label="Password" style={{ flex: 1 }} disabled={!D.form.auth} />
</div>
</div>
)}
</div>
{/* Logs strip */}
<button onClick={() => 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',
}}>
<window.IconChevron color={t.dim} dir={showDetails ? 'down' : 'right'} />
<span>Logs</span>
<span style={{ marginLeft: 'auto', color: t.dimmer }}>{D.logs.length} lines</span>
</button>
{showDetails && (
<div className="drv-fadein" style={{
background: t.panelAlt, border: `1px solid ${t.border}`, borderRadius: 8,
maxHeight: 110, overflow: 'auto', padding: '8px 12px',
fontFamily: fontMono, fontSize: 10.5, color: t.textDim,
}} ref={el => el && (el.scrollTop = el.scrollHeight)}>
{D.logs.map((l, i) => (
<div key={i}>
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>{' '}
<span style={{ color: l.level === 'ERROR' ? t.danger : l.level === 'WARN' ? t.warn : t.pass, fontWeight: 600 }}>[{l.level}]</span>{' '}
<span>{l.msg}</span>
</div>
))}
</div>
)}
</div>
</div>
);
}
// ─── 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 (
<button onClick={onClick} disabled={isChecking}
style={{
position: 'relative', width: ring, height: ring, marginTop: 8,
background: isActive
? `radial-gradient(circle at 35% 30%, ${accent} 0%, ${accentDeep} 60%, ${accentDarker} 100%)`
: isFailed
? `radial-gradient(circle at 35% 30%, ${t.danger} 0%, #8c2c2c 100%)`
: `radial-gradient(circle at 35% 30%, ${t.panel} 0%, ${t.panelAlt} 100%)`,
boxShadow: isActive
? `0 0 60px ${accent}66, 0 0 0 6px ${accent}22, 0 0 0 12px ${accent}10`
: `0 0 0 2px ${accent}22, 0 6px 24px rgba(0,0,0,.15)`,
border: 'none', borderRadius: '50%', cursor: isChecking ? 'wait' : 'pointer',
color: isActive || isFailed ? '#fff' : accent,
transition: 'background .35s, box-shadow .35s, color .2s',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column',
}}>
{/* pulsing ring on active */}
{isActive && (
<span style={{
position: 'absolute', inset: -16, borderRadius: '50%',
border: `1px solid ${accent}66`, animation: 'drv-pulse-ring 2s ease-out infinite',
}} />
)}
{/* progress ring while checking */}
{isChecking && (
<svg width={ring} height={ring} style={{ position: 'absolute', inset: 0, transform: 'rotate(-90deg)' }}>
<circle cx={ring/2} cy={ring/2} r={r} fill="none" stroke={`${accent}30`} strokeWidth={stroke} />
<circle cx={ring/2} cy={ring/2} r={r} fill="none" stroke={accent} strokeWidth={stroke} strokeLinecap="round"
strokeDasharray={c} strokeDashoffset={c * (1 - progress)}
style={{ transition: 'stroke-dashoffset .35s' }} />
{/* spinner segment */}
<circle cx={ring/2} cy={ring/2} r={r-12} fill="none" stroke={accent} strokeWidth="2" strokeLinecap="round"
strokeDasharray={`${(c-24)*0.18} ${(c-24)*0.82}`}
style={{ animation: 'drv-spin 1.2s linear infinite', transformOrigin: '50% 50%' }} />
</svg>
)}
<span style={{ fontSize: 42, lineHeight: 1, fontWeight: 100 }}></span>
<span style={{ fontSize: 11, letterSpacing: '.25em', marginTop: 6, opacity: 0.95, fontWeight: 600 }}>
{isActive ? 'ACTIVE' : isChecking ? 'TESTING' : isFailed ? 'RETRY' : 'OFF'}
</span>
<style>{`
@keyframes drv-pulse-ring { 0%{ transform: scale(1); opacity:.5; } 70%{ transform: scale(1.18); opacity:0;} 100%{transform:scale(1.18); opacity:0;}}
`}</style>
</button>
);
}
function HeroStat({ icon, v, u, c, mono, t }) {
return (
<div style={{ textAlign: 'center', minWidth: 56 }}>
<div style={{ fontSize: 11, color: t.accent }}>{icon}</div>
<div style={{ fontSize: 18, fontWeight: 600, fontFamily: mono, marginTop: 2, color: c }}>{v}</div>
<div style={{ fontSize: 9.5, color: t.dim, letterSpacing: '.06em', textTransform: 'uppercase' }}>{u}</div>
</div>
);
}
function HeroTitleBtn({ children, t, hoverBg }) {
const [hover, setHover] = React.useState(false);
return (
<div onMouseEnter={() => 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}</div>
);
}
function HeroInput({ t, fontMono, value, onChange, onEnter, placeholder, label, style, type, disabled, id }) {
const [focus, setFocus] = React.useState(false);
return (
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, ...style }}>
<span style={{ fontSize: 10.5, color: t.dim, fontWeight: 500, letterSpacing: 0.4, textTransform: 'uppercase' }}>{label}</span>
<input id={id} type={type} value={value} disabled={disabled}
onChange={e => 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',
}} />
</label>
);
}
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;
+358
View File
@@ -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 (
<div style={{
width: 480, height: 640, background: t.bg, color: t.text, display: 'flex', flexDirection: 'column',
fontFamily: fontUI, fontSize: 13.5, overflow: 'hidden',
boxShadow: mode === 'dark' ? '0 0 0 1px rgba(255,255,255,0.06)' : '0 0 0 1px rgba(0,0,0,0.08)',
}}>
{/* title */}
<MinTitle t={t}/>
<div style={{ flex: 1, overflow:'auto', padding: '8px 16px 0' }}>
{/* Form card */}
<Card t={t} style={{ padding: 16 }}>
<CardHeader t={t}>SOCKS5 Proxy</CardHeader>
<div style={{ display:'flex', gap: 10 }}>
<MinField t={t} label="Host" style={{ flex: 1 }}>
<input value={D.form.host} onChange={e => D.update({ host: e.target.value })}
placeholder="95.165.72.59 или example.com"
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={minInput(t, fontUI)}/>
</MinField>
<MinField t={t} label="Port" style={{ width: 96 }}>
<input value={D.form.port} onChange={e => D.update({ port: e.target.value.replace(/\D/g,'') })}
placeholder="12334" inputMode="numeric"
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={minInput(t, fontUI)}/>
</MinField>
</div>
<div style={{ height: 12 }}/>
<MinToggle t={t} checked={D.form.auth}
onChange={(v) => { D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('min-login')?.focus(),30); }}>
Authentication
</MinToggle>
<div style={{ display:'flex', gap: 10, marginTop: 10, opacity: D.form.auth ? 1 : 0.45,
pointerEvents: D.form.auth ? 'auto':'none' }}>
<MinField t={t} label="Login" style={{ flex: 1 }}>
<input id="min-login" disabled={!D.form.auth} value={D.form.login}
onChange={e => D.update({ login: e.target.value })} placeholder="user"
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={minInput(t, fontUI, !D.form.auth)}/>
</MinField>
<MinField t={t} label="Password" style={{ flex: 1 }}>
<input disabled={!D.form.auth} type="password" value={D.form.password}
onChange={e => D.update({ password: e.target.value })} placeholder="••••••"
onKeyDown={e => e.key === 'Enter' && D.runCheck()} style={minInput(t, fontUI, !D.form.auth)}/>
</MinField>
</div>
<div style={{ height: 14 }}/>
<button onClick={D.runCheck} disabled={D.phase === 'checking' || isActive} style={{
width:'100%', padding: '10px 14px', borderRadius: 6, border: 'none',
background: (D.phase === 'checking' || isActive) ? t.panel2 : t.primaryBg,
color: (D.phase === 'checking' || isActive) ? t.dimmer : t.primaryFg,
fontWeight: 600, fontSize: 13.5, cursor: D.phase==='checking'?'not-allowed':'pointer',
fontFamily: fontUI,
}}>
{D.phase === 'checking' ? 'Checking…' : 'Check connection'}
</button>
</Card>
<div style={{ height: 12 }}/>
{/* Status card */}
<Card t={t} style={{ padding: 14 }}>
<CardHeader t={t}>Status</CardHeader>
<MinStatus t={t} D={D} palette={palette} fontMono={fontMono}/>
</Card>
<div style={{ height: 12 }}/>
{/* Action card */}
<Card t={t} style={{ padding: 14 }}>
<div style={{ display:'flex', gap: 10 }}>
<MinStartBtn t={t} D={D} fontUI={fontUI}/>
<MinStopBtn t={t} D={D} fontUI={fontUI}/>
</div>
{isActive && <MinLiveStats t={t} stats={D.stats} fontMono={fontMono}/>}
</Card>
<div style={{ height: 12 }}/>
</div>
{/* Logs */}
<MinLogs t={t} D={D} fontMono={fontMono}/>
</div>
);
}
function MinTitle({ t }) {
const cell = { width: 46, height: 32, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', color: t.dim };
return (
<div style={{
height: 36, background: t.chrome, display:'flex', alignItems:'center',
borderBottom: `1px solid ${t.border}`, userSelect:'none',
}}>
<div style={{ display:'flex', alignItems:'center', gap: 10, padding:'0 14px', flex:1 }}>
<window.BrandMark size={16} color={t.accent}/>
<span style={{ fontSize: 13, fontWeight: 600 }}>Drover-Go</span>
<span style={{ fontSize: 11, color: t.dimmer }}>0.4.2</span>
</div>
<div style={{ display:'flex' }}>
<div style={cell} title="Settings"><window.IconGear color={t.dim}/></div>
<div style={cell} title="Minimize"><window.IconMin color={t.dim}/></div>
<div style={cell} title="Close"
onMouseEnter={e => { e.currentTarget.style.background = '#c0463f'; e.currentTarget.style.color = 'white'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = t.dim; }}>
<window.IconClose/>
</div>
</div>
</div>
);
}
function Card({ t, children, style }) {
return <div style={{
background: t.panel, border: `1px solid ${t.border}`, borderRadius: 8, ...style,
}}>{children}</div>;
}
function CardHeader({ t, children }) {
return <div style={{ fontSize: 12, fontWeight: 600, color: t.dim, marginBottom: 10, letterSpacing: 0.2 }}>{children}</div>;
}
function MinField({ t, label, children, style }) {
return <label style={{ display:'flex', flexDirection:'column', gap: 5, ...style }}>
<span style={{ fontSize: 11.5, color: t.dim }}>{label}</span>
{children}
</label>;
}
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 (
<label style={{ display:'inline-flex', alignItems:'center', gap: 10, cursor:'pointer', userSelect:'none' }}>
<span style={{
width: 36, height: 20, borderRadius: 10, padding: 2, boxSizing:'border-box',
background: checked ? t.accent : t.borderStrong, transition: 'background .14s',
display:'flex', alignItems:'center',
}}>
<span style={{
width: 14, height: 14, borderRadius: 7, background: 'white',
transform: checked ? 'translateX(16px)' : 'translateX(0)', transition: 'transform .14s',
}}/>
</span>
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display:'none' }}/>
<span style={{ fontSize: 13 }}>{children}</span>
</label>
);
}
function MinStatus({ t, D, palette, fontMono }) {
if (D.phase === 'idle') {
return (
<div style={{ display:'flex', alignItems:'center', gap: 10, padding: '6px 0' }}>
<span style={{ width: 8, height: 8, borderRadius: 4, background: t.dimmer }}/>
<span style={{ color: t.dim }}>Ready to check</span>
</div>
);
}
return (
<div>
<div style={{ display:'flex', alignItems:'center', gap: 8, marginBottom: 8 }}>
{D.phase === 'checking'
? <>
<window.StatusDot state="running" palette={palette} size={14}/>
<span style={{ fontWeight: 500 }}>Running diagnostics</span>
</>
: D.lastSummary?.failed === 0
? <SummaryPill t={t} kind="ok">All checks passed. Ready to start.</SummaryPill>
: <SummaryPill t={t} kind="warn">{D.lastSummary?.failed} of {D.tests.length} checks failed. Some features won't work.</SummaryPill>}
</div>
<div style={{ display:'flex', flexDirection:'column', gap: 2 }}>
{D.tests.map(test => {
const r = D.results[test.id];
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
return (
<div key={test.id} style={{ borderRadius: 4, padding: '4px 4px' }}>
<div style={{ display:'flex', alignItems:'center', gap: 10, height: 26 }}>
<window.StatusDot state={state} palette={palette} size={14}/>
<span style={{ color: state==='pending'?t.dim:t.text, fontSize: 13 }} title={test.desc}>{test.label}</span>
<span style={{ marginLeft:'auto', fontFamily: fontMono, fontSize: 11.5,
color: state==='failed'?t.danger:state==='skipped'?t.skip:t.dim }}>
{r?.metric}
</span>
{r?.result === 'failed' && (
<button onClick={() => D.toggleExpand(test.id)} style={{
background:'transparent', border:'none', cursor:'pointer', padding: 4, color: t.dim,
}} title="Подробнее">
<window.IconChevron color={t.dim} dir={r.expanded ? 'up' : 'down'}/>
</button>
)}
</div>
{r?.result === 'failed' && r.expanded && (
<div className="drv-fadein" style={{
margin: '4px 0 6px 24px', padding: 10, background: t.panel2, borderRadius: 6,
borderLeft: `2px solid ${t.danger}`, fontSize: 12,
}}>
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 3 }}>{r.error}</div>
<div style={{ color: t.dim, lineHeight: 1.5 }}>{r.hint}</div>
<button onClick={() => 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,
}}>
<window.IconCopy color={t.dim}/> copy error
</button>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
function SummaryPill({ t, kind, children }) {
const c = kind === 'ok' ? t.pass : t.warn;
return <div style={{
padding: '7px 11px', borderRadius: 6, fontSize: 12.5, fontWeight: 500,
background: kind==='ok' ? 'rgba(122,210,156,0.12)' : 'rgba(255,184,107,0.12)',
color: c, border: `1px solid ${c}40`, width: '100%',
}}>{children}</div>;
}
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 (
<div style={{
flex: 1, padding: '10px 14px', borderRadius: 6, fontWeight: 600, fontSize: 13.5,
background: warning ? 'rgba(255,184,107,0.14)' : 'rgba(122,210,156,0.14)',
border: `1px solid ${c}55`, color: c,
display:'flex', alignItems:'center', justifyContent:'center', gap: 8, fontFamily: fontUI,
}}>
<span className="drv-pulsedot" style={{ width: 8, height: 8, borderRadius: 4, background: c }}/>
Active{warning ? ' · UDP fallback' : ''}
</div>
);
}
return (
<button onClick={D.startProxy} disabled={!checkedOk} style={{
flex: 1, padding: '10px 14px', borderRadius: 6, border:'none',
background: checkedOk ? t.primaryBg : t.panel2, color: checkedOk ? t.primaryFg : t.dimmer,
fontWeight: 600, fontSize: 13.5, cursor: checkedOk ? 'pointer':'not-allowed', fontFamily: fontUI,
}}>Start proxying</button>
);
}
function MinStopBtn({ t, D, fontUI }) {
const enabled = D.phase === 'active';
return (
<button onClick={D.stopProxy} disabled={!enabled} style={{
flex: 1, padding: '10px 14px', borderRadius: 6,
background: 'transparent', color: enabled ? t.text : t.dimmer,
border: `1px solid ${enabled ? t.borderStrong : t.border}`, fontWeight: 600, fontSize: 13.5,
cursor: enabled ? 'pointer' : 'not-allowed', fontFamily: fontUI,
}}>Stop</button>
);
}
function MinLiveStats({ t, stats, fontMono }) {
const Cell = ({ icon, val, lbl }) => (
<div style={{ display:'flex', alignItems:'center', gap: 4 }}>
{icon}
<span style={{ fontFamily: fontMono, fontSize: 11.5, color: t.text }}>{val}</span>
{lbl && <span style={{ fontSize: 10, color: t.dimmer, textTransform:'uppercase', letterSpacing:0.5 }}>{lbl}</span>}
</div>
);
return (
<div style={{
marginTop: 10, paddingTop: 10, borderTop: `1px solid ${t.border}`,
display:'flex', justifyContent:'space-between', alignItems:'center', color: t.dim,
}}>
<Cell icon={<window.IconArrowUp color={t.pass}/>} val={window.fmtBytes(stats.up)}/>
<Cell icon={<window.IconArrowDown color={t.accent}/>} val={window.fmtBytes(stats.down)}/>
<Cell val={stats.tcp} lbl="tcp"/>
<Cell val={stats.udp} lbl="udp"/>
<Cell val={window.fmtUptime(stats.uptimeS)} lbl="up"/>
</div>
);
}
function MinLogs({ t, D, fontMono }) {
return (
<div style={{ borderTop: `1px solid ${t.border}`, background: t.chrome, flexShrink: 0 }}>
<button onClick={() => 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,
}}>
<window.IconChevron color={t.dim} dir={D.logsOpen ? 'down' : 'right'}/>
<span style={{ fontWeight: 600 }}>Logs</span>
<span style={{ marginLeft: 'auto', fontFamily: fontMono, fontSize: 11, color: t.dimmer }}>{D.logs.length}</span>
</button>
{D.logsOpen && (
<>
<div style={{ display:'flex', gap: 6, padding: '0 16px 8px' }}>
{['Copy all','Clear','Open log file'].map((l,i) => (
<button key={l} onClick={i===0 ? () => 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}</button>
))}
</div>
<div className="drv-log" ref={el => 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) => (
<div key={i}>
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>{' '}
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.accent, fontWeight: 600 }}>[{l.level}]</span>{' '}
{l.msg}
</div>
))}
</div>
</>
)}
</div>
);
}
window.MinimalWindow = MinimalWindow;
+308
View File
@@ -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 (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke={color} strokeWidth={strokeWidth}/>
<path d="M7 9 L12 14 L17 9" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round"/>
<path d="M12 14 L12 19" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round"/>
</svg>
);
}
function IconGear({ size=14, color='currentColor' }) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="2.2" stroke={color} strokeWidth="1.2"/>
<path d="M8 1.5v2M8 12.5v2M14.5 8h-2M3.5 8h-2M12.6 3.4l-1.4 1.4M4.8 11.2l-1.4 1.4M12.6 12.6l-1.4-1.4M4.8 4.8L3.4 3.4"
stroke={color} strokeWidth="1.2" strokeLinecap="round"/>
</svg>
);
}
function IconMin({ size=14, color='currentColor' }) {
return <svg width={size} height={size} viewBox="0 0 16 16"><path d="M3 8h10" stroke={color} strokeWidth="1.2" strokeLinecap="round"/></svg>;
}
function IconClose({ size=14, color='currentColor' }) {
return <svg width={size} height={size} viewBox="0 0 16 16"><path d="M4 4l8 8M12 4l-8 8" stroke={color} strokeWidth="1.2" strokeLinecap="round"/></svg>;
}
function IconChevron({ size=12, color='currentColor', dir='down' }) {
const r = { down: 0, up: 180, left: 90, right: -90 }[dir];
return <svg width={size} height={size} viewBox="0 0 12 12" style={{ transform: `rotate(${r}deg)` }}>
<path d="M3 4.5 L6 7.5 L9 4.5" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
</svg>;
}
function IconCopy({ size=12, color='currentColor' }) {
return <svg width={size} height={size} viewBox="0 0 12 12" fill="none">
<rect x="3" y="3" width="7" height="7" rx="1" stroke={color} strokeWidth="1.2"/>
<path d="M2 8.5V2.5C2 1.95 2.45 1.5 3 1.5h6" stroke={color} strokeWidth="1.2"/>
</svg>;
}
function IconArrowUp({ size=10, color='currentColor' }) {
return <svg width={size} height={size} viewBox="0 0 10 10" fill="none">
<path d="M5 8.5V1.5M5 1.5L2 4.5M5 1.5L8 4.5" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
</svg>;
}
function IconArrowDown({ size=10, color='currentColor' }) {
return <svg width={size} height={size} viewBox="0 0 10 10" fill="none">
<path d="M5 1.5V8.5M5 8.5L2 5.5M5 8.5L8 5.5" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
</svg>;
}
// ─── 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 (
<span style={{ display:'inline-block', width:size, height:size, position:'relative' }}>
<svg width={size} height={size} viewBox="0 0 16 16" style={{ animation: 'drv-spin 0.8s linear infinite' }}>
<circle cx="8" cy="8" r="6" stroke={c} strokeOpacity="0.25" strokeWidth="2" fill="none"/>
<path d="M8 2 a6 6 0 0 1 6 6" stroke={c} strokeWidth="2" strokeLinecap="round" fill="none"/>
</svg>
</span>
);
}
if (state === 'passed') {
return <svg width={size} height={size} viewBox="0 0 16 16">
<circle cx="8" cy="8" r="7" fill={c}/>
<path d="M5 8.2l2 2 4-4.4" stroke="white" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
</svg>;
}
if (state === 'failed') {
return <svg width={size} height={size} viewBox="0 0 16 16">
<circle cx="8" cy="8" r="7" fill={c}/>
<path d="M5.5 5.5l5 5M10.5 5.5l-5 5" stroke="white" strokeWidth="1.6" strokeLinecap="round"/>
</svg>;
}
if (state === 'skipped') {
return <svg width={size} height={size} viewBox="0 0 16 16">
<circle cx="8" cy="8" r="7" fill="none" stroke={c} strokeWidth="1.4" strokeDasharray="2 2"/>
<path d="M5 8h6" stroke={c} strokeWidth="1.4" strokeLinecap="round"/>
</svg>;
}
// pending
return <svg width={size} height={size} viewBox="0 0 16 16">
<circle cx="8" cy="8" r="3" fill="none" stroke={c} strokeWidth="1.4"/>
</svg>;
}
// 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,
});
+903
View File
@@ -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)' }) => (
<div style={{
width: W, height: H, background: bg, borderRadius: radius,
boxShadow: `0 0 0 1px ${ring}, 0 24px 60px rgba(0,0,0,.18)`,
overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column'
}}>{children}</div>
);
const Mark = ({ size = 14, color = '#0c8c7a' }) => (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6.5" stroke={color} strokeWidth="1.5"/>
<path d="M5.5 5.5 8.5 8 5.5 10.5" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M9 5.5 12 8 9 10.5" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" opacity=".5"/>
</svg>
);
const TitleButtons = ({ color = '#666' }) => (
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4, color }}>
<span style={{ width: 22, height: 22, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}></span>
<span style={{ width: 22, height: 22, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}></span>
<span style={{ width: 22, height: 22, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>×</span>
</div>
);
// ═════════════════════════════════════════════════════════════════════════
// 1 · Fluent / Win11 native — the safe baseline
// ═════════════════════════════════════════════════════════════════════════
const SketchFluent = () => {
const f = "'Segoe UI Variable','Segoe UI',system-ui,sans-serif";
return (
<Win bg="#f3f3f3" radius={10}>
<div style={{ height: 36, display: 'flex', alignItems: 'center', padding: '0 14px', fontFamily: f, fontSize: 12.5, color: '#1a1a1a' }}>
<Mark color="#0067c0" />
<span style={{ marginLeft: 10, fontWeight: 600 }}>Drover-Go</span>
<span style={{ marginLeft: 8, color: '#888', fontSize: 11 }}>0.4.2</span>
<TitleButtons />
</div>
<div style={{ padding: 14, fontFamily: f, fontSize: 13.5, flex: 1, display: 'flex', flexDirection: 'column', gap: 10, overflow: 'hidden' }}>
<div style={{ background: '#fff', borderRadius: 8, padding: 12, boxShadow: '0 0 0 1px rgba(0,0,0,.05), 0 1px 3px rgba(0,0,0,.04)' }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#444', marginBottom: 10 }}>SOCKS5 Proxy</div>
<div style={{ display: 'flex', gap: 8 }}>
<FluentField flex={1} label="Host" value="95.165.72.59" />
<FluentField width={88} label="Port" value="12334" />
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 10, fontSize: 12.5 }}>
<span style={{ width: 16, height: 16, borderRadius: 4, border: '1px solid #ccc', background: '#fff' }} />
<span style={{ color: '#888' }}>Authentication</span>
</label>
<button style={{ marginTop: 12, width: '100%', height: 34, background: '#0067c0', color: '#fff', border: 'none', borderRadius: 4, fontFamily: f, fontWeight: 600, fontSize: 13, boxShadow: '0 1px 0 rgba(0,0,0,.1) inset, 0 1px 2px rgba(0,103,192,.3)' }}>Check connection</button>
</div>
<div style={{ background: '#fff', borderRadius: 8, padding: 12, boxShadow: '0 0 0 1px rgba(0,0,0,.05), 0 1px 3px rgba(0,0,0,.04)', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#444', marginBottom: 10 }}>Status</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#0067c0', fontSize: 12.5, marginBottom: 8 }}>
<span style={{ width: 14, height: 14, borderRadius: '50%', border: '2px solid #0067c0', borderRightColor: 'transparent', animation: 'spin 1s linear infinite' }} />
Running diagnostics
</div>
{[
['✓', '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) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', padding: '4px 0', fontSize: 12.5 }}>
<span style={{ width: 18, color: c, fontWeight: 600 }}>{ic}</span>
<span style={{ flex: 1 }}>{name}</span>
<span style={{ color: '#888', fontFamily: "'Cascadia Mono', monospace", fontSize: 11.5 }}>{val}</span>
</div>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button style={{ flex: 1, height: 36, background: '#0067c0', color: '#fff', border: 'none', borderRadius: 4, fontWeight: 600, opacity: 0.4 }}>Start proxying</button>
<button style={{ flex: 1, height: 36, background: '#fff', border: '1px solid #ddd', borderRadius: 4, color: '#aaa' }}>Stop</button>
</div>
</div>
<style>{`@keyframes spin{to{transform:rotate(360deg)}}`}</style>
</Win>
);
};
const FluentField = ({ label, value, flex, width }) => (
<label style={{ flex, width, display: 'block' }}>
<div style={{ fontSize: 11, color: '#888', marginBottom: 3 }}>{label}</div>
<div style={{ height: 30, background: '#fafafa', borderRadius: 4, padding: '0 10px', display: 'flex', alignItems: 'center', fontSize: 13, color: '#1a1a1a', borderBottom: '1.5px solid #888', boxShadow: '0 0 0 1px rgba(0,0,0,.06)' }}>{value}</div>
</label>
);
// ═════════════════════════════════════════════════════════════════════════
// 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 (
<Win bg="#1e1f22" radius={8} ring="rgba(255,255,255,.06)">
<div style={{ height: 30, display: 'flex', alignItems: 'center', padding: '0 12px', borderBottom: '1px solid #2a2c30', fontFamily: f, fontSize: 12, color: '#cbd1d9' }}>
<Mark color="#4f8eff" />
<span style={{ marginLeft: 8, fontWeight: 500 }}>Drover-Go</span>
<span style={{ marginLeft: 8, color: '#666' }}> proxy: 95.165.72.59:12334</span>
<TitleButtons color="#888" />
</div>
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
{/* sidebar */}
<div style={{ width: 56, background: '#191a1d', borderRight: '1px solid #2a2c30', display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '8px 0', gap: 4, fontFamily: f }}>
{[['◉', 'Proxy', true], ['◇', 'Diagnose'], ['≋', 'Traffic'], ['≣', 'Logs'], ['⚙', 'Settings']].map(([ic, l, on], i) => (
<div key={i} style={{ width: 40, height: 40, borderRadius: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: on ? '#fff' : '#888', background: on ? '#2c3140' : 'transparent', borderLeft: on ? '2px solid #4f8eff' : '2px solid transparent', position: 'relative', fontSize: 14 }}>
<span>{ic}</span>
<span style={{ fontSize: 8, marginTop: 1 }}>{l}</span>
</div>
))}
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, fontFamily: f, fontSize: 13, color: '#cbd1d9' }}>
{/* breadcrumb / tabs */}
<div style={{ height: 30, display: 'flex', alignItems: 'flex-end', padding: '0 0 0 0', borderBottom: '1px solid #2a2c30', background: '#191a1d' }}>
<div style={{ padding: '6px 12px', background: '#1e1f22', borderRight: '1px solid #2a2c30', borderTop: '2px solid #4f8eff', fontSize: 11.5, color: '#fff' }}> Proxy</div>
<div style={{ padding: '6px 12px', borderRight: '1px solid #2a2c30', fontSize: 11.5, color: '#888' }}>diagnostic.run</div>
</div>
<div style={{ flex: 1, padding: 14, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden' }}>
<div>
<div style={{ fontSize: 10.5, letterSpacing: '.1em', color: '#888', textTransform: 'uppercase', marginBottom: 8 }}>Endpoint</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
<div style={{ background: '#2c3140', border: '1px solid #3a4050', borderRight: 'none', padding: '0 10px', fontFamily: m, fontSize: 11, color: '#4f8eff', display: 'flex', alignItems: 'center', borderRadius: '4px 0 0 4px' }}>SOCKS5</div>
<div style={{ flex: 1, background: '#15171b', border: '1px solid #3a4050', padding: '0 10px', display: 'flex', alignItems: 'center', fontFamily: m, fontSize: 12, color: '#fff' }}>95.165.72.59</div>
<div style={{ width: 70, background: '#15171b', border: '1px solid #3a4050', borderLeft: 'none', padding: '0 10px', display: 'flex', alignItems: 'center', fontFamily: m, fontSize: 12, color: '#fff', borderRadius: '0 4px 4px 0' }}>:12334</div>
<button style={{ padding: '0 16px', background: '#4f8eff', color: '#fff', border: 'none', borderRadius: 4, fontWeight: 600, fontSize: 12.5 }}>Run </button>
</div>
</div>
<div style={{ flex: 1, minHeight: 0 }}>
<div style={{ fontSize: 10.5, letterSpacing: '.1em', color: '#888', textTransform: 'uppercase', marginBottom: 8 }}>Diagnostic results</div>
<div style={{ background: '#15171b', border: '1px solid #2a2c30', borderRadius: 5, fontFamily: m, fontSize: 11.5 }}>
{[
['✓', '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) => (
<div key={i} style={{ display: 'flex', padding: '5px 10px', borderBottom: i < 6 ? '1px solid #1f2126' : 'none' }}>
<span style={{ width: 16, color: c }}>{ic}</span>
<span style={{ flex: 1, color: c === '#666' ? '#666' : '#cbd1d9' }}>{n}</span>
<span style={{ color: c }}>{v}</span>
</div>
))}
</div>
<div style={{ marginTop: 10, padding: 8, background: '#3a1f1f', border: '1px solid #6e2a2a', borderRadius: 5, fontSize: 11.5, color: '#ff9e9e' }}>
1 of 7 checks failed. UDP voice/screenshare won't work.
</div>
</div>
</div>
{/* status bar */}
<div style={{ height: 22, display: 'flex', alignItems: 'center', padding: '0 10px', background: '#15171b', borderTop: '1px solid #2a2c30', fontFamily: m, fontSize: 10.5, color: '#888' }}>
<span style={{ color: '#ff9e9e' }}>● 6/7</span>
<span style={{ marginLeft: 12 }}>idle</span>
<span style={{ marginLeft: 'auto' }}>UTF-8 · LF · drover.toml</span>
</div>
</div>
</div>
</Win>
);
};
// ═════════════════════════════════════════════════════════════════════════
// 3 · Step wizard / Tab progression — DigiCert / Tunnelblick connect-flow
// ═════════════════════════════════════════════════════════════════════════
const SketchWizard = () => {
const f = "'Inter', system-ui, sans-serif";
return (
<Win bg="#fafafa" radius={8} ring="rgba(0,0,0,.12)">
<div style={{ height: 32, display: 'flex', alignItems: 'center', padding: '0 12px', borderBottom: '1px solid #ececec', fontFamily: f, fontSize: 12, color: '#1a1a1a' }}>
<Mark color="#1e6fd9" />
<span style={{ marginLeft: 8, fontWeight: 600 }}>Drover-Go · Setup</span>
<TitleButtons />
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', fontFamily: f, color: '#1a1a1a' }}>
{/* stepper */}
<div style={{ display: 'flex', padding: '14px 18px', gap: 0, alignItems: 'center', borderBottom: '1px solid #ececec', background: '#fff' }}>
{[['1', 'Configure', true], ['2', 'Verify', true], ['3', 'Connect', false]].map(([n, l, done], i) => (
<React.Fragment key={i}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 22, height: 22, borderRadius: '50%', background: i === 1 ? '#1e6fd9' : (done ? '#1e6fd9' : '#ddd'), color: '#fff', fontSize: 11, fontWeight: 600, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
{done && i !== 1 ? '' : n}
</span>
<span style={{ fontSize: 12, fontWeight: i === 1 ? 600 : 400, color: i === 1 ? '#1a1a1a' : '#888' }}>{l}</span>
</div>
{i < 2 && <div style={{ flex: 1, height: 1, background: i === 0 ? '#1e6fd9' : '#ddd', margin: '0 10px' }} />}
</React.Fragment>
))}
</div>
<div style={{ flex: 1, padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 14, overflow: 'hidden' }}>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>Verifying your proxy</h2>
<div style={{ fontSize: 12.5, color: '#666', marginTop: 4 }}>We're running 7 checks against <span style={{ fontFamily: "'JetBrains Mono',monospace", color: '#1a1a1a' }}>95.165.72.59:12334</span> to make sure Discord will work end-to-end.</div>
</div>
{/* progress + ring */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16, padding: 14, background: '#fff', border: '1px solid #ececec', borderRadius: 8 }}>
<svg width="64" height="64" viewBox="0 0 64 64">
<circle cx="32" cy="32" r="26" fill="none" stroke="#eee" strokeWidth="6" />
<circle cx="32" cy="32" r="26" fill="none" stroke="#1e6fd9" strokeWidth="6" strokeLinecap="round" strokeDasharray="163.4" strokeDashoffset="70" transform="rotate(-90 32 32)" />
<text x="32" y="36" textAnchor="middle" fontSize="14" fontWeight="600" fill="#1a1a1a">4/7</text>
</svg>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 13.5 }}>Testing UDP relay</div>
<div style={{ fontSize: 12, color: '#666', marginTop: 2 }}>This usually takes 515 seconds.</div>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12.5 }}>
{[
['✓', '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) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', padding: '5px 10px', background: i === 4 ? '#eef4fc' : 'transparent', borderRadius: 4 }}>
<span style={{ width: 18, color: c, fontWeight: 600 }}>{ic}</span>
<span style={{ flex: 1, color: c === '#bbb' ? '#999' : '#1a1a1a' }}>{n}</span>
<span style={{ color: c, fontFamily: "'JetBrains Mono',monospace", fontSize: 11.5 }}>{v}</span>
</div>
))}
</div>
</div>
<div style={{ height: 50, display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8, padding: '0 18px', borderTop: '1px solid #ececec', background: '#fff' }}>
<button style={{ height: 30, padding: '0 14px', background: '#fff', border: '1px solid #ddd', borderRadius: 4, fontSize: 12.5 }}>Back</button>
<button style={{ height: 30, padding: '0 14px', background: '#1e6fd9', color: '#fff', border: 'none', borderRadius: 4, fontSize: 12.5, fontWeight: 600, opacity: 0.5 }}>Continue </button>
</div>
</div>
</Win>
);
};
// ═════════════════════════════════════════════════════════════════════════
// 4 · Network monitor — gauges, sparklines, multi-pane (Wireshark / Activity)
// ═════════════════════════════════════════════════════════════════════════
const SketchMonitor = () => {
const f = "'Inter', system-ui, sans-serif";
const m = "'JetBrains Mono', monospace";
return (
<Win bg="#fcfcfc" radius={6} ring="rgba(0,0,0,.12)">
<div style={{ height: 30, display: 'flex', alignItems: 'center', padding: '0 12px', borderBottom: '1px solid #e6e6e6', fontFamily: f, fontSize: 12, background: '#f5f5f5' }}>
<Mark color="#0c8c7a" />
<span style={{ marginLeft: 8, fontWeight: 600 }}>Drover-Go</span>
<span style={{ marginLeft: 8, fontFamily: m, fontSize: 10, color: '#21a655' }}> ACTIVE 4m 12s</span>
<TitleButtons />
</div>
<div style={{ flex: 1, padding: 12, display: 'flex', flexDirection: 'column', gap: 10, fontFamily: f, fontSize: 12, color: '#222', overflow: 'hidden' }}>
{/* compact endpoint */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px', background: '#fff', border: '1px solid #e6e6e6', borderRadius: 5 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#21a655' }} />
<span style={{ fontFamily: m, fontSize: 11.5 }}>95.165.72.59:12334</span>
<span style={{ marginLeft: 'auto', color: '#888', fontSize: 11 }}>SOCKS5 · auth</span>
<button style={{ background: 'transparent', border: '1px solid #ddd', borderRadius: 4, padding: '2px 8px', fontSize: 11 }}>Edit</button>
</div>
{/* meters */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2,1fr)', gap: 8 }}>
<Gauge label="Upload" v="142" unit="KB/s" pct={28} c="#0c8c7a" />
<Gauge label="Download" v="2.8" unit="MB/s" pct={84} c="#1e6fd9" />
</div>
{/* sparkline panel */}
<div style={{ background: '#fff', border: '1px solid #e6e6e6', borderRadius: 5, padding: 10 }}>
<div style={{ display: 'flex', alignItems: 'baseline', marginBottom: 6 }}>
<span style={{ fontSize: 10.5, letterSpacing: '.06em', color: '#888', textTransform: 'uppercase' }}>Throughput · last 60s</span>
<span style={{ marginLeft: 'auto', fontFamily: m, fontSize: 11, color: '#222' }}>peak 3.4 MB/s</span>
</div>
<svg width="100%" height="60" viewBox="0 0 400 60" preserveAspectRatio="none">
<path d="M0 50 L20 45 L40 30 L60 35 L80 18 L100 25 L120 12 L140 22 L160 8 L180 18 L200 28 L220 15 L240 30 L260 20 L280 38 L300 25 L320 32 L340 18 L360 24 L380 14 L400 20 L400 60 L0 60Z" fill="#1e6fd9" opacity=".15" />
<path d="M0 50 L20 45 L40 30 L60 35 L80 18 L100 25 L120 12 L140 22 L160 8 L180 18 L200 28 L220 15 L240 30 L260 20 L280 38 L300 25 L320 32 L340 18 L360 24 L380 14 L400 20" fill="none" stroke="#1e6fd9" strokeWidth="1.5" />
</svg>
</div>
{/* connections table */}
<div style={{ flex: 1, background: '#fff', border: '1px solid #e6e6e6', borderRadius: 5, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', padding: '6px 10px', background: '#f5f5f5', borderBottom: '1px solid #e6e6e6', fontSize: 10.5, color: '#888', letterSpacing: '.06em', textTransform: 'uppercase' }}>
<span style={{ width: 50 }}>Proto</span>
<span style={{ flex: 1 }}>Endpoint</span>
<span style={{ width: 80, textAlign: 'right' }}>Up</span>
<span style={{ width: 80, textAlign: 'right' }}>Down</span>
</div>
{[
['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) => (
<div key={i} style={{ display: 'flex', padding: '5px 10px', fontFamily: m, fontSize: 11, borderBottom: i < 3 ? '1px solid #f0f0f0' : 'none' }}>
<span style={{ width: 50, color: r[4], fontWeight: 600 }}>{r[0]}</span>
<span style={{ flex: 1 }}>{r[1]}</span>
<span style={{ width: 80, textAlign: 'right', color: '#666' }}>{r[2]}</span>
<span style={{ width: 80, textAlign: 'right', color: '#666' }}>{r[3]}</span>
</div>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button style={{ flex: 1, height: 32, background: '#21a655', color: '#fff', border: 'none', borderRadius: 4, fontWeight: 600, fontSize: 12.5 }}> Active · 4m 12s</button>
<button style={{ flex: 1, height: 32, background: '#fff', border: '1px solid #ddd', borderRadius: 4, fontSize: 12.5 }}>Stop</button>
</div>
</div>
</Win>
);
};
const Gauge = ({ label, v, unit, pct, c }) => (
<div style={{ background: '#fff', border: '1px solid #e6e6e6', borderRadius: 5, padding: 10 }}>
<div style={{ display: 'flex', alignItems: 'baseline' }}>
<span style={{ fontSize: 10.5, letterSpacing: '.06em', color: '#888', textTransform: 'uppercase' }}>{label}</span>
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, marginTop: 4 }}>
<span style={{ fontSize: 22, fontWeight: 600, fontFamily: "'JetBrains Mono',monospace" }}>{v}</span>
<span style={{ fontSize: 11, color: '#888' }}>{unit}</span>
</div>
<div style={{ height: 4, background: '#f0f0f0', borderRadius: 2, marginTop: 6, overflow: 'hidden' }}>
<div style={{ width: pct + '%', height: '100%', background: c }} />
</div>
</div>
);
// ═════════════════════════════════════════════════════════════════════════
// 5 · Inspector / Properties — devtools-style key:value list
// ═════════════════════════════════════════════════════════════════════════
const SketchInspector = () => {
const f = "'Inter', system-ui, sans-serif";
const m = "'JetBrains Mono', monospace";
return (
<Win bg="#fff" radius={6} ring="rgba(0,0,0,.12)">
<div style={{ height: 28, display: 'flex', alignItems: 'center', padding: '0 12px', borderBottom: '1px solid #ececec', fontFamily: f, fontSize: 11.5 }}>
<Mark color="#1a1a1a" size={12} />
<span style={{ marginLeft: 8, fontWeight: 600 }}>Drover-Go</span>
<span style={{ marginLeft: 8, color: '#888', fontSize: 10.5 }}>0.4.2</span>
<TitleButtons />
</div>
{/* tabs */}
<div style={{ height: 28, display: 'flex', borderBottom: '1px solid #ececec', fontFamily: f, fontSize: 11.5, background: '#fafafa' }}>
{['Inspector', 'Network', 'Console', 'Settings'].map((t, i) => (
<div key={t} style={{ padding: '0 14px', display: 'flex', alignItems: 'center', borderBottom: i === 0 ? '2px solid #1a1a1a' : '2px solid transparent', color: i === 0 ? '#1a1a1a' : '#888', fontWeight: i === 0 ? 600 : 400 }}>{t}</div>
))}
</div>
<div style={{ flex: 1, fontFamily: m, fontSize: 11.5, color: '#1a1a1a', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<Group title="proxy" expanded>
<KV k="protocol" v="SOCKS5" t="enum" />
<KV k="host" v={<input style={{ all: 'unset', width: '100%', fontFamily: m, fontSize: 11.5, color: '#1a1a1a' }} defaultValue="95.165.72.59" />} editable />
<KV k="port" v="12334" t="int" editable />
<KV k="auth.enabled" v="true" t="bool" />
<KV k="auth.user" v="alice" t="str" editable />
<KV k="auth.pass" v="••••••••" t="str" editable />
</Group>
<Group title="diagnostic.last_run" expanded>
<KV k="started_at" v="14:32:01" t="time" />
<KV k="duration" v="3.2 s" t="dur" />
<KV k="passed" v="7" t="int" color="#21a655" />
<KV k="failed" v="0" t="int" />
<KV k="checks[0].name" v="tcp_reachability" />
<KV k="checks[0].rtt" v="12 ms" />
<KV k="checks[1].name" v="socks5_greeting" />
<KV k="checks[2].name" v="socks5_authentication" />
</Group>
<Group title="state" expanded>
<KV k="status" v="ready" t="enum" color="#21a655" />
<KV k="active" v="false" t="bool" />
</Group>
</div>
<div style={{ height: 32, display: 'flex', gap: 6, padding: '0 8px', alignItems: 'center', borderTop: '1px solid #ececec', background: '#fafafa' }}>
<button style={{ height: 22, padding: '0 10px', background: '#fff', border: '1px solid #ccc', borderRadius: 3, fontFamily: f, fontSize: 11 }}>Run diagnostic</button>
<button style={{ height: 22, padding: '0 10px', background: '#1a1a1a', color: '#fff', border: 'none', borderRadius: 3, fontFamily: f, fontSize: 11, fontWeight: 600 }}> Start proxying</button>
<span style={{ marginLeft: 'auto', fontFamily: m, fontSize: 10.5, color: '#888' }}>14 props · saved</span>
</div>
</Win>
);
};
const Group = ({ title, expanded, children }) => (
<div>
<div style={{ padding: '4px 10px', background: '#f0f0f0', fontSize: 10.5, fontWeight: 600, color: '#444', display: 'flex', alignItems: 'center' }}>
<span style={{ marginRight: 4, fontSize: 9 }}>{expanded ? '▼' : '▶'}</span>{title}
</div>
<div>{children}</div>
</div>
);
const KV = ({ k, v, t, color, editable }) => (
<div style={{ display: 'flex', padding: '3px 10px', borderBottom: '1px solid #f5f5f5', alignItems: 'center', fontSize: 11 }}>
<span style={{ width: 150, color: '#888' }}>{k}</span>
<span style={{ flex: 1, color: color || '#1a1a1a', background: editable ? '#f9f9f9' : 'transparent', borderRadius: 2, padding: '1px 4px' }}>{v}</span>
{t && <span style={{ marginLeft: 8, fontSize: 9.5, color: '#bbb', textTransform: 'uppercase' }}>{t}</span>}
</div>
);
// ═════════════════════════════════════════════════════════════════════════
// 6 · Command palette + form — Raycast / Linear / cmd+k feel
// ═════════════════════════════════════════════════════════════════════════
const SketchPalette = () => {
const f = "'Inter', system-ui, sans-serif";
const m = "'JetBrains Mono', monospace";
return (
<Win bg="#0f1015" radius={14} ring="rgba(255,255,255,.08)">
<div style={{ height: 32, display: 'flex', alignItems: 'center', padding: '0 14px', fontFamily: f, fontSize: 12, color: '#cbd1d9' }}>
<Mark color="#a5b4ff" />
<span style={{ marginLeft: 8, fontWeight: 500 }}>Drover-Go</span>
<TitleButtons color="#666" />
</div>
<div style={{ flex: 1, padding: 14, fontFamily: f, color: '#e8e8ea', display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden' }}>
{/* command bar */}
<div style={{ background: '#15171c', border: '1px solid #2a2d36', borderRadius: 9, padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 10, boxShadow: '0 0 0 3px rgba(165,180,255,.08)' }}>
<span style={{ color: '#a5b4ff', fontSize: 14 }}></span>
<input style={{ all: 'unset', flex: 1, fontFamily: f, fontSize: 13.5, color: '#fff' }} placeholder="Search commands…" defaultValue="check con" />
<span style={{ fontFamily: m, fontSize: 10, color: '#666', padding: '2px 6px', background: '#1a1c22', border: '1px solid #2a2d36', borderRadius: 4 }}>esc</span>
</div>
{/* suggestion */}
<div style={{ background: '#15171c', borderRadius: 9, padding: 6 }}>
<div style={{ padding: '8px 10px', borderRadius: 6, background: '#23264a', display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ width: 22, height: 22, borderRadius: 5, background: '#a5b4ff', color: '#0f1015', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700, fontSize: 12 }}></span>
<span style={{ flex: 1, fontSize: 13 }}>Check connection</span>
<span style={{ fontFamily: m, fontSize: 10.5, color: '#a5b4ff' }}></span>
</div>
<div style={{ padding: '8px 10px', display: 'flex', alignItems: 'center', gap: 10, color: '#888' }}>
<span style={{ width: 22, height: 22, borderRadius: 5, background: '#1a1c22', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 12 }}></span>
<span style={{ flex: 1, fontSize: 13 }}>Start proxying</span>
<span style={{ fontFamily: m, fontSize: 10.5 }}></span>
</div>
</div>
{/* Form below — proxy quick-edit */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 10, minHeight: 0 }}>
<div style={{ fontSize: 10.5, letterSpacing: '.1em', color: '#666', textTransform: 'uppercase' }}>Proxy</div>
<div style={{ display: 'flex', gap: 8 }}>
<PalField flex={1} label="Host" value="95.165.72.59" />
<PalField width={86} label="Port" value="12334" />
</div>
<div style={{ fontSize: 10.5, letterSpacing: '.1em', color: '#666', textTransform: 'uppercase', marginTop: 4 }}>Last run · 7/7 passed</div>
{[
['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) => (
<div key={i} style={{ display: 'flex', padding: '4px 10px', fontFamily: m, fontSize: 11.5, background: '#15171c', borderRadius: 5 }}>
<span style={{ color: '#21d07a', marginRight: 8 }}></span>
<span style={{ flex: 1, color: '#cbd1d9' }}>{n}</span>
<span style={{ color: '#888' }}>{v}</span>
</div>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 0', fontFamily: m, fontSize: 10.5, color: '#666' }}>
<span><kbd style={{ background: '#1a1c22', border: '1px solid #2a2d36', padding: '1px 5px', borderRadius: 3, color: '#cbd1d9' }}></kbd> run</span>
<span><kbd style={{ background: '#1a1c22', border: '1px solid #2a2d36', padding: '1px 5px', borderRadius: 3, color: '#cbd1d9' }}></kbd> start</span>
<span style={{ marginLeft: 'auto' }}>idle · ready</span>
</div>
</div>
</Win>
);
};
const PalField = ({ label, value, flex, width }) => (
<label style={{ flex, width }}>
<div style={{ fontSize: 10.5, color: '#888', marginBottom: 4 }}>{label}</div>
<div style={{ height: 32, background: '#15171c', border: '1px solid #2a2d36', borderRadius: 7, padding: '0 10px', display: 'flex', alignItems: 'center', fontSize: 13, color: '#fff', fontFamily: "'JetBrains Mono',monospace" }}>{value}</div>
</label>
);
// ═════════════════════════════════════════════════════════════════════════
// 7 · Big toggle / Hero — TunnelBear / NordVPN big-button connect
// ═════════════════════════════════════════════════════════════════════════
const SketchHero = () => {
const f = "'Inter', system-ui, sans-serif";
const m = "'JetBrains Mono', monospace";
return (
<Win bg="#0e1426" radius={14} ring="rgba(255,255,255,.06)">
<div style={{ height: 36, display: 'flex', alignItems: 'center', padding: '0 14px', fontFamily: f, fontSize: 12.5, color: '#cbd1d9' }}>
<Mark color="#5dd4b3" />
<span style={{ marginLeft: 10, fontWeight: 600 }}>Drover-Go</span>
<span style={{ marginLeft: 'auto', fontSize: 11, color: '#5dd4b3' }}> connected</span>
<TitleButtons color="#666" />
</div>
<div style={{ flex: 1, padding: '20px 18px', fontFamily: f, color: '#fff', display: 'flex', flexDirection: 'column', gap: 16, alignItems: 'center', overflow: 'hidden' }}>
{/* Big circular button */}
<div style={{ width: 180, height: 180, borderRadius: '50%', background: 'radial-gradient(circle at 35% 30%, #5dd4b3 0%, #2da085 60%, #1a6e5b 100%)', boxShadow: '0 0 60px rgba(93,212,179,.4), 0 0 0 6px rgba(93,212,179,.1), 0 0 0 12px rgba(93,212,179,.05)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', position: 'relative', marginTop: 12 }}>
<div style={{ position: 'absolute', inset: -20, borderRadius: '50%', border: '1px solid rgba(93,212,179,.2)', animation: 'pulse 2s infinite' }} />
<span style={{ fontSize: 36 }}></span>
<span style={{ fontSize: 11, letterSpacing: '.2em', marginTop: 4, opacity: .9 }}>ACTIVE</span>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 22, fontWeight: 600 }}>Discord is protected</div>
<div style={{ fontSize: 12.5, color: '#7a8499', marginTop: 4, fontFamily: m }}>via 95.165.72.59 · 4m 12s</div>
</div>
{/* Stats row */}
<div style={{ display: 'flex', gap: 0, width: '100%', background: '#15192a', borderRadius: 10, padding: 12, justifyContent: 'space-around' }}>
{[
['↑', '142', 'KB/s'],
['↓', '2.8', 'MB/s'],
['◇', '14', 'tcp'],
['◈', '3', 'udp'],
].map(([k, v, u], i) => (
<div key={i} style={{ textAlign: 'center' }}>
<div style={{ fontSize: 11, color: '#5dd4b3' }}>{k}</div>
<div style={{ fontSize: 18, fontWeight: 600, fontFamily: m, marginTop: 2 }}>{v}</div>
<div style={{ fontSize: 9.5, color: '#7a8499', letterSpacing: '.06em', textTransform: 'uppercase' }}>{u}</div>
</div>
))}
</div>
<button style={{ width: '100%', height: 38, background: 'transparent', color: '#cbd1d9', border: '1px solid #2a3042', borderRadius: 9, fontWeight: 500, fontSize: 13 }}>Disconnect</button>
<div style={{ width: '100%', fontSize: 11, color: '#5a6178', display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10 }}></span><span>Logs (3 new)</span>
</div>
</div>
<style>{`@keyframes pulse{0%,100%{transform:scale(1);opacity:.4}50%{transform:scale(1.08);opacity:0}}`}</style>
</Win>
);
};
// ═════════════════════════════════════════════════════════════════════════
// 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 (
<Win bg="#ebebeb" radius={4} ring="rgba(0,0,0,.18)">
<div style={{ height: 26, display: 'flex', alignItems: 'center', padding: '0 8px', background: 'linear-gradient(180deg,#f5f5f5,#dcdcdc)', borderBottom: '1px solid #b8b8b8', fontFamily: f, fontSize: 11 }}>
<Mark color="#1a1a1a" size={12} />
<span style={{ marginLeft: 6, fontWeight: 600 }}>Drover-Go</span>
<span style={{ marginLeft: 8, color: '#666' }}> [Connected]</span>
<TitleButtons />
</div>
{/* menu bar */}
<div style={{ height: 22, display: 'flex', padding: '0 6px', background: '#ebebeb', borderBottom: '1px solid #c8c8c8', fontFamily: f, fontSize: 11, alignItems: 'center', gap: 2 }}>
{['File', 'Edit', 'Run', 'View', 'Tools', 'Help'].map(m => (
<span key={m} style={{ padding: '2px 8px', borderRadius: 2 }}>{m}</span>
))}
</div>
{/* ribbon */}
<div style={{ display: 'flex', padding: '6px 8px', background: 'linear-gradient(180deg,#f5f5f5,#e8e8e8)', borderBottom: '1px solid #c8c8c8', gap: 4 }}>
<Tool icon="▶" label="Run" primary />
<Tool icon="●" label="Start" />
<Tool icon="■" label="Stop" disabled />
<div style={{ width: 1, background: '#c8c8c8', margin: '4px 6px' }} />
<Tool icon="⚙" label="Config" />
<Tool icon="⎘" label="Copy" />
<div style={{ width: 1, background: '#c8c8c8', margin: '4px 6px' }} />
<Tool icon="≣" label="Logs" />
</div>
<div style={{ flex: 1, padding: 10, display: 'flex', flexDirection: 'column', gap: 8, fontFamily: f, fontSize: 11.5, color: '#1a1a1a', overflow: 'hidden' }}>
<Fieldset label="SOCKS5 Proxy">
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ width: 40 }}>Host:</span>
<div style={{ flex: 1, height: 22, background: '#fff', border: '1px solid #888', boxShadow: '1px 1px 0 #ccc inset', padding: '0 6px', display: 'flex', alignItems: 'center', fontFamily: m, fontSize: 11.5 }}>95.165.72.59</div>
<span>Port:</span>
<div style={{ width: 70, height: 22, background: '#fff', border: '1px solid #888', boxShadow: '1px 1px 0 #ccc inset', padding: '0 6px', display: 'flex', alignItems: 'center', fontFamily: m, fontSize: 11.5 }}>12334</div>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 6 }}>
<span style={{ width: 14, height: 14, border: '1px solid #888', background: '#fff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 10 }}></span>
<span>Authentication</span>
<span style={{ marginLeft: 12 }}>User:</span>
<div style={{ flex: 1, height: 22, background: '#fff', border: '1px solid #888', boxShadow: '1px 1px 0 #ccc inset', padding: '0 6px', display: 'flex', alignItems: 'center' }}>alice</div>
</div>
</Fieldset>
<Fieldset label="Diagnostics" flex>
<div style={{ background: '#fff', border: '1px solid #888', boxShadow: '1px 1px 0 #ccc inset', flex: 1, fontFamily: m, fontSize: 11, overflow: 'auto' }}>
{[
['✓', '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) => (
<div key={i} style={{ display: 'flex', padding: '3px 8px', borderBottom: i < 6 ? '1px dotted #ddd' : 'none' }}>
<span style={{ width: 14, color: '#107c10' }}>{r[0]}</span>
<span style={{ flex: 1 }}>{r[1]}</span>
<span style={{ color: '#666' }}>{r[2]}</span>
</div>
))}
</div>
</Fieldset>
</div>
{/* status bar */}
<div style={{ height: 22, display: 'flex', padding: '0 8px', background: '#dcdcdc', borderTop: '1px solid #b8b8b8', alignItems: 'center', fontSize: 10.5, fontFamily: f, gap: 12 }}>
<span>Ready</span>
<span> 7/7</span>
<span> 142 KB/s 2.8 MB/s</span>
<span style={{ marginLeft: 'auto', fontFamily: m }}>uptime 4m 12s</span>
</div>
</Win>
);
};
const Tool = ({ icon, label, primary, disabled }) => (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '2px 8px', borderRadius: 2, background: primary ? 'linear-gradient(180deg,#fff,#e0e0e0)' : 'transparent', border: primary ? '1px solid #888' : '1px solid transparent', minWidth: 44, opacity: disabled ? 0.4 : 1 }}>
<span style={{ fontSize: 16, color: primary ? '#107c10' : '#1a1a1a' }}>{icon}</span>
<span style={{ fontSize: 10, marginTop: 1 }}>{label}</span>
</div>
);
const Fieldset = ({ label, children, flex }) => (
<div style={{ border: '1px solid #b8b8b8', borderRadius: 2, padding: '10px 8px 8px', position: 'relative', background: '#f5f5f5', flex: flex ? 1 : undefined, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<span style={{ position: 'absolute', top: -8, left: 8, background: '#ebebeb', padding: '0 4px', fontSize: 10.5, fontWeight: 600 }}>{label}</span>
{children}
</div>
);
// ═════════════════════════════════════════════════════════════════════════
// 9 · Modern dev tool — Studio (Linear-ish, restrained dark)
// ═════════════════════════════════════════════════════════════════════════
const SketchStudio = () => {
const f = "'Inter', sans-serif";
return (
<Win bg="#0e0f12" radius={12} ring="rgba(255,255,255,.08)">
<div style={{ height: 36, display: 'flex', alignItems: 'center', padding: '0 14px', fontFamily: f, fontSize: 12.5, color: '#e8e8ea' }}>
<Mark color="#7c8aff" />
<span style={{ marginLeft: 10, fontWeight: 600 }}>Drover-Go</span>
<span style={{ marginLeft: 8, color: '#5a5d6a', fontSize: 11 }}>0.4.2</span>
<TitleButtons color="#777a86" />
</div>
<div style={{ padding: 14, fontFamily: f, color: '#e8e8ea', flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden' }}>
<div>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '.04em', color: '#a1a3ad', marginBottom: 8, textTransform: 'uppercase' }}>SOCKS5 Proxy</div>
<div style={{ display: 'flex', gap: 8 }}>
<StField flex={1} label="Host" value="95.165.72.59" />
<StField width={86} label="Port" value="12334" />
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 10, fontSize: 12.5, color: '#a1a3ad' }}>
<span style={{ width: 14, height: 14, borderRadius: 4, background: '#7c8aff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 10 }}></span>
Authentication
</label>
<button style={{ marginTop: 12, width: '100%', height: 34, background: '#7c8aff', color: '#0a0a0c', border: 'none', borderRadius: 7, fontWeight: 600, fontSize: 13 }}>Check connection</button>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '.04em', color: '#a1a3ad', marginBottom: 8, display: 'flex', textTransform: 'uppercase' }}>
Status <span style={{ marginLeft: 'auto', fontSize: 11, color: '#21d07a', fontWeight: 500, textTransform: 'none' }}> All systems</span>
</div>
<div style={{ background: '#15171c', border: '1px solid #1f2228', borderRadius: 9, padding: 4, flex: 1 }}>
{[
['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) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', padding: '6px 8px', borderRadius: 6, fontSize: 12.5 }}>
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#21d07a22', border: '1px solid #21d07a', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: '#21d07a', fontSize: 9, marginRight: 10 }}></span>
<span style={{ flex: 1 }}>{n}</span>
<span style={{ color: '#777a86', fontFamily: "'Geist Mono',monospace", fontSize: 11.5 }}>{v}</span>
</div>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button style={{ flex: 1, height: 36, background: '#7c8aff', color: '#0a0a0c', border: 'none', borderRadius: 7, fontWeight: 600 }}>Start proxying</button>
<button style={{ flex: 1, height: 36, background: '#1a1c22', color: '#e8e8ea', border: '1px solid #2a2d36', borderRadius: 7 }}>Stop</button>
</div>
</div>
</Win>
);
};
const StField = ({ label, value, flex, width }) => (
<label style={{ flex, width }}>
<div style={{ fontSize: 11, color: '#777a86', marginBottom: 4 }}>{label}</div>
<div style={{ height: 32, background: '#15171c', border: '1px solid #1f2228', borderRadius: 7, padding: '0 10px', display: 'flex', alignItems: 'center', fontSize: 13 }}>{value}</div>
</label>
);
// ═════════════════════════════════════════════════════════════════════════
// 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 (
<g transform={`translate(${x},${y})`}>
<rect width="120" height="40" rx="6" fill={bg} stroke={c} strokeWidth="1.5" />
<circle cx="14" cy="20" r="5" fill={c} />
<text x="26" y="17" fontSize="11" fontWeight="600" fill="#1a1a1a" fontFamily={f}>{label}</text>
<text x="26" y="30" fontSize="9.5" fill="#666" fontFamily={m}>{sub}</text>
</g>
);
};
const Arrow = ({ from, to, dashed }) => (
<line x1={from[0]} y1={from[1]} x2={to[0]} y2={to[1]} stroke="#bbb" strokeWidth="1.5" strokeDasharray={dashed ? '3 3' : ''} markerEnd="url(#ar)" />
);
return (
<Win bg="#fafafa" radius={6}>
<div style={{ height: 30, display: 'flex', alignItems: 'center', padding: '0 12px', borderBottom: '1px solid #ececec', fontFamily: f, fontSize: 12 }}>
<Mark color="#1a1a1a" size={12} />
<span style={{ marginLeft: 8, fontWeight: 600 }}>Drover-Go</span>
<span style={{ marginLeft: 8, color: '#888', fontSize: 11 }}>· pipeline view</span>
<TitleButtons />
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* compact endpoint */}
<div style={{ padding: 10, display: 'flex', gap: 6, alignItems: 'center', borderBottom: '1px solid #ececec', background: '#fff' }}>
<span style={{ fontSize: 11, color: '#888', fontFamily: m }}>SOCKS5</span>
<input style={{ flex: 1, height: 26, padding: '0 8px', border: '1px solid #d0d0d0', borderRadius: 4, fontFamily: m, fontSize: 11.5 }} defaultValue="95.165.72.59" />
<input style={{ width: 70, height: 26, padding: '0 8px', border: '1px solid #d0d0d0', borderRadius: 4, fontFamily: m, fontSize: 11.5 }} defaultValue="12334" />
<button style={{ height: 26, padding: '0 12px', background: '#1e6fd9', color: '#fff', border: 'none', borderRadius: 4, fontWeight: 600, fontSize: 11.5 }}>Run </button>
</div>
{/* DAG */}
<div style={{ flex: 1, padding: 12, background: 'radial-gradient(circle at 1px 1px, #ddd 1px, transparent 0) 0 0/16px 16px', overflow: 'auto' }}>
<svg width="456" height="380" viewBox="0 0 456 380">
<defs>
<marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0 0 L10 5 L0 10 z" fill="#bbb"/></marker>
</defs>
<Node x={20} y={20} label="TCP reach" sub="12 ms" status="pass" />
<Arrow from={[140, 40]} to={[160, 40]} />
<Node x={160} y={20} label="SOCKS5 greet" sub="ok" status="pass" />
<Arrow from={[280, 40]} to={[300, 40]} />
<Node x={300} y={20} label="SOCKS5 auth" sub="ok" status="pass" />
<Arrow from={[360, 60]} to={[360, 90]} />
<Node x={300} y={90} label="CONNECT discord" sub="38 ms" status="pass" />
<Arrow from={[300, 110]} to={[180, 160]} />
<Arrow from={[300, 110]} to={[180, 220]} />
<Node x={60} y={140} label="UDP ASSOCIATE" sub="running…" status="run" />
<Arrow from={[180, 160]} to={[60, 240]} dashed />
<Node x={60} y={220} label="UDP STUN RTT" sub="pending" status="pending" />
<Node x={60} y={300} label="Discord API" sub="pending" status="pending" />
<Arrow from={[180, 240]} to={[60, 320]} dashed />
</svg>
</div>
<div style={{ padding: 10, borderTop: '1px solid #ececec', background: '#fff', display: 'flex', gap: 6, alignItems: 'center' }}>
<span style={{ fontSize: 11, color: '#1e6fd9', fontFamily: m }}> 4/7 running</span>
<span style={{ marginLeft: 'auto', display: 'flex', gap: 6 }}>
<button style={{ height: 28, padding: '0 14px', background: '#fff', border: '1px solid #ddd', borderRadius: 4, fontSize: 12 }}>Cancel</button>
<button style={{ height: 28, padding: '0 14px', background: '#1e6fd9', color: '#fff', border: 'none', borderRadius: 4, fontSize: 12, fontWeight: 600, opacity: 0.5 }}>Start proxying</button>
</span>
</div>
</div>
</Win>
);
};
// ═════════════════════════════════════════════════════════════════════════
// 11 · Bauhaus — geometric blocks, primary colors, asymmetric grid
// ═════════════════════════════════════════════════════════════════════════
const SketchBauhaus = () => {
const f = "'Space Grotesk', sans-serif";
return (
<Win bg="#f4ede0" radius={0} ring="#000">
<div style={{ height: 32, display: 'flex', alignItems: 'center', padding: '0 12px', borderBottom: '3px solid #000', fontFamily: f, fontSize: 13, fontWeight: 700 }}>
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#e63946' }} />
<span style={{ width: 14, height: 14, background: '#1d3557', marginLeft: 6 }} />
<span style={{ width: 0, height: 0, borderLeft: '7px solid transparent', borderRight: '7px solid transparent', borderBottom: '14px solid #f1c40f', marginLeft: 6 }} />
<span style={{ marginLeft: 12 }}>DROVER-GO</span>
<span style={{ marginLeft: 'auto', fontWeight: 400, fontSize: 11 }}>v0.4.2 · · · ×</span>
</div>
<div style={{ flex: 1, padding: 12, fontFamily: f, color: '#000', display: 'grid', gridTemplateColumns: '1fr 1fr', gridTemplateRows: 'auto 1fr auto', gap: 8 }}>
<div style={{ gridColumn: '1 / -1', background: '#1d3557', color: '#fff', padding: 14, border: '3px solid #000' }}>
<div style={{ fontSize: 10, letterSpacing: '.15em', opacity: .7 }}>FORM 01</div>
<div style={{ fontSize: 18, fontWeight: 700, marginTop: 2 }}>SOCKS5 PROXY</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<BaField flex={1} label="HOST" value="95.165.72.59" dark />
<BaField width={84} label="PORT" value="12334" dark />
</div>
</div>
<button style={{ background: '#e63946', color: '#fff', border: '3px solid #000', padding: '10px', fontFamily: f, fontSize: 13, fontWeight: 700, letterSpacing: '.06em' }}>CHECK </button>
<button style={{ background: '#f1c40f', color: '#000', border: '3px solid #000', padding: '10px', fontFamily: f, fontSize: 13, fontWeight: 700, letterSpacing: '.06em' }}> START</button>
<div style={{ gridColumn: '1 / -1', border: '3px solid #000', background: '#fff', padding: 14, minHeight: 0, overflow: 'hidden' }}>
<div style={{ fontSize: 10, letterSpacing: '.15em', opacity: .6 }}>STATUS · 7/7</div>
<div style={{ fontSize: 16, fontWeight: 700, marginBottom: 10 }}>ALL CHECKS PASSED</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 14px' }}>
{[
['TCP reach', '12ms'],
['SOCKS5 greet', 'ok'],
['SOCKS5 auth', 'ok'],
['CONNECT', '38ms'],
['UDP assoc', 'ok'],
['UDP STUN', '24ms'],
['API', '200'],
].map(([n, v], i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', fontSize: 12, borderBottom: '1px solid #000' }}>
<span style={{ width: 8, height: 8, background: '#e63946', marginRight: 8 }} />
<span style={{ flex: 1 }}>{n}</span>
<span style={{ fontWeight: 700 }}>{v}</span>
</div>
))}
</div>
</div>
</div>
</Win>
);
};
const BaField = ({ label, value, flex, width, dark }) => (
<label style={{ flex, width }}>
<div style={{ fontSize: 9.5, marginBottom: 3, opacity: .7, letterSpacing: '.1em' }}>{label}</div>
<div style={{ height: 30, background: dark ? '#fff' : '#000', color: dark ? '#000' : '#fff', border: '2px solid #000', padding: '0 8px', display: 'flex', alignItems: 'center', fontSize: 13, fontWeight: 600 }}>{value}</div>
</label>
);
// ═════════════════════════════════════════════════════════════════════════
// 12 · Brutalist — hard borders, mono, acid lime
// ═════════════════════════════════════════════════════════════════════════
const SketchBrutalist = () => {
const m = "'Geist Mono', 'JetBrains Mono', monospace";
return (
<Win bg="#f3f1e8" radius={0} ring="#000">
<div style={{ height: 38, display: 'flex', alignItems: 'center', padding: '0 12px', borderBottom: '2px solid #000', fontFamily: m, fontSize: 12, color: '#000', background: '#e8e4d2' }}>
<span style={{ fontWeight: 700 }}> DROVER-GO</span>
<span style={{ marginLeft: 8, fontSize: 10 }}>v0.4.2</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 6, fontSize: 14, fontWeight: 700 }}>
<span>[]</span><span>[]</span><span>[X]</span>
</div>
</div>
<div style={{ padding: 14, fontFamily: m, fontSize: 12, color: '#000', flex: 1, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ border: '2px solid #000', background: '#fff', padding: 10 }}>
<div style={{ fontSize: 11, fontWeight: 700, marginBottom: 8 }}>// SOCKS5 PROXY</div>
<div style={{ display: 'flex', gap: 6 }}>
<BrField flex={1} label="HOST" value="95.165.72.59" />
<BrField width={92} label="PORT" value="12334" />
</div>
<div style={{ marginTop: 10, fontSize: 12 }}>[X] AUTHENTICATION</div>
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<BrField flex={1} label="LOGIN" value="alice" />
<BrField flex={1} label="PASSWORD" value="********" />
</div>
<button style={{ marginTop: 12, width: '100%', height: 36, background: '#c5f74d', border: '2px solid #000', boxShadow: '4px 4px 0 #000', fontFamily: m, fontWeight: 700, fontSize: 13, cursor: 'pointer' }}> CHECK CONNECTION</button>
</div>
<div style={{ border: '2px solid #000', background: '#fff', padding: 10, flex: 1 }}>
<div style={{ fontSize: 11, fontWeight: 700, marginBottom: 8 }}>// STATUS · 7/7 OK</div>
{[
['[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) => (
<div key={i} style={{ display: 'flex', padding: '3px 0', borderBottom: i < 6 ? '1px dashed #999' : 'none' }}>
<span style={{ width: 38, fontWeight: 700 }}>{s}</span>
<span style={{ flex: 1 }}>{n}</span>
<span>{v}</span>
</div>
))}
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button style={{ flex: 1, height: 38, background: '#c5f74d', border: '2px solid #000', boxShadow: '4px 4px 0 #000', fontFamily: m, fontWeight: 700 }}> START</button>
<button style={{ flex: 1, height: 38, background: '#fff', border: '2px solid #000', boxShadow: '4px 4px 0 #000', fontFamily: m, fontWeight: 700 }}> STOP</button>
</div>
</div>
</Win>
);
};
const BrField = ({ label, value, flex, width }) => (
<label style={{ flex, width }}>
<div style={{ fontSize: 9.5, marginBottom: 3 }}>{label}</div>
<div style={{ height: 30, border: '2px solid #000', padding: '0 8px', display: 'flex', alignItems: 'center', fontSize: 12.5, background: '#fff' }}>{value}</div>
</label>
);
// ═════════════════════════════════════════════════════════════════════════
// SketchesApp
// ═════════════════════════════════════════════════════════════════════════
const SKETCHES = [
{ id: 'fluent', label: '01 · Fluent / Win11 native', el: <SketchFluent /> },
{ id: 'sidebar', label: '02 · IDE sidebar (VS Code-ish)', el: <SketchSidebar /> },
{ id: 'wizard', label: '03 · Wizard / stepper', el: <SketchWizard /> },
{ id: 'monitor', label: '04 · Network monitor (gauges)', el: <SketchMonitor /> },
{ id: 'inspector', label: '05 · Inspector / properties', el: <SketchInspector /> },
{ id: 'palette', label: '06 · Command palette (cmd+k)', el: <SketchPalette /> },
{ id: 'hero', label: '07 · Big toggle (VPN-style)', el: <SketchHero /> },
{ id: 'toolbar', label: '08 · Toolbar / ribbon (classic)', el: <SketchToolbar /> },
{ id: 'studio', label: '09 · Studio (Linear-ish)', el: <SketchStudio /> },
{ id: 'pipeline', label: '10 · Pipeline / DAG view', el: <SketchPipeline /> },
{ id: 'bauhaus', label: '11 · Bauhaus blocks', el: <SketchBauhaus /> },
{ id: 'brutalist', label: '12 · Brutalist (mono+lime)', el: <SketchBrutalist /> },
];
const SketchesApp = () => (
<DesignCanvas>
<DCSection id="about" title="Drover-Go" subtitle="12 directions — every one looks/works like real software, not a marketing site. Different UI paradigms (sidebar, wizard, palette, ribbon, DAG, gauges…), not just different palettes.">
<div data-dc-static style={{ width: 720, padding: '20px 24px', background: '#fff', borderRadius: 10, boxShadow: '0 0 0 1px rgba(0,0,0,.06)', fontSize: 13, lineHeight: 1.55, color: '#3a3530' }}>
<div style={{ fontWeight: 600, marginBottom: 8, fontSize: 14 }}>What changed</div>
<p style={{ margin: '0 0 8px' }}>Removed the "marketing" stylings (ticket, synthwave, soft pastel, editorial serif). Replaced with software-native paradigms each card explores a <em>different way to organize the same UI</em>, not just a different paint job:</p>
<ul style={{ margin: '0 0 8px', paddingLeft: 18 }}>
<li><b>Layout patterns:</b> sidebar IDE · wizard/stepper · pipeline/DAG · ribbon · palette+form · big-toggle hero</li>
<li><b>Element vocab:</b> gauges + sparklines · key/value inspector · fieldsets · tabs+breadcrumbs · status bar · toolbar buttons</li>
<li><b>Aesthetic:</b> Fluent (Win11) · classic Office · Linear/Studio · brutalist · Bauhaus blocks</li>
</ul>
<p style={{ margin: 0, color: '#7a7065' }}>Tell me which paradigm/aesthetic combo to take forward.</p>
</div>
</DCSection>
<DCSection id="sketches" title="12 directions" subtitle="Drag to reorder. Click any artboard's expand icon to focus.">
{SKETCHES.map(s => (
<DCArtboard key={s.id} id={s.id} label={s.label} width={W} height={H}>
{s.el}
</DCArtboard>
))}
</DCSection>
</DesignCanvas>
);
// 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,
});
+309
View File
@@ -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 (
<div style={{
width: 480, height: 640, background: t.bg, color: t.text, display:'flex', flexDirection:'column',
fontFamily: fontUI, fontSize: 13, overflow:'hidden', border:'1px solid #000',
}}>
<StdTitle t={t}/>
<div style={{ flex: 1, overflow:'auto', padding:'14px 16px 4px' }}>
<StdSection t={t} title="SOCKS5 Proxy">
<div style={{ display:'flex', gap: 10 }}>
<StdField t={t} label="Host" style={{ flex: 1 }}>
<StdInput t={t} fontUI={fontUI} value={D.form.host}
onChange={v => D.update({ host: v })} onSubmit={D.runCheck}
placeholder="95.165.72.59 или example.com"/>
</StdField>
<StdField t={t} label="Port" style={{ width: 96 }}>
<StdInput t={t} fontUI={fontUI} value={D.form.port}
onChange={v => D.update({ port: v.replace(/\D/g,'') })} onSubmit={D.runCheck}
placeholder="12334" inputMode="numeric"/>
</StdField>
</div>
<div style={{ height: 12 }}/>
<StdCheck t={t} checked={D.form.auth}
onChange={(v) => { D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('std-login')?.focus(),30); }}>
Authentication
</StdCheck>
<div style={{
display:'flex', gap: 10, marginTop: 10,
opacity: D.form.auth ? 1 : 0.4, pointerEvents: D.form.auth?'auto':'none',
}}>
<StdField t={t} label="Login" style={{ flex: 1 }}>
<StdInput id="std-login" t={t} fontUI={fontUI} value={D.form.login}
onChange={v => D.update({ login: v })} onSubmit={D.runCheck} placeholder="user" disabled={!D.form.auth}/>
</StdField>
<StdField t={t} label="Password" style={{ flex: 1 }}>
<StdInput t={t} fontUI={fontUI} value={D.form.password} type="password"
onChange={v => D.update({ password: v })} onSubmit={D.runCheck} placeholder="••••••"
disabled={!D.form.auth}/>
</StdField>
</div>
<div style={{ height: 14 }}/>
<button onClick={D.runCheck} disabled={D.phase==='checking'||isActive} style={{
width:'100%', padding:'9px 14px', borderRadius: 6, border:'none',
background: (D.phase==='checking'||isActive) ? t.panel2 : t.accent,
color: (D.phase==='checking'||isActive) ? t.dimmer : t.primaryFg,
fontWeight: 600, fontSize: 13, cursor: D.phase==='checking'?'not-allowed':'pointer',
fontFamily: fontUI,
}}>{D.phase==='checking' ? 'Checking…' : 'Check connection'}</button>
</StdSection>
<div style={{ height: 14 }}/>
<StdSection t={t} title="Status" right={
D.phase==='checking' ? `${Object.keys(D.results).length}/${D.tests.length}` :
D.lastSummary ? (D.lastSummary.failed === 0 ? 'all passed' : `${D.lastSummary.failed} failed`) : null
}>
{D.phase === 'idle'
? <div style={{ display:'flex', alignItems:'center', gap: 8, color: t.dim, padding:'2px 0' }}>
<span style={{ width: 6, height: 6, borderRadius: 3, background: t.dimmer }}/>
Ready to check
</div>
: <StdStatus t={t} D={D} palette={palette} fontMono={fontMono}/>}
</StdSection>
<div style={{ height: 14 }}/>
<StdSection t={t}>
<div style={{ display:'flex', gap: 10 }}>
<StdStartBtn t={t} D={D} fontUI={fontUI}/>
<StdStopBtn t={t} D={D} fontUI={fontUI}/>
</div>
{isActive && (
<div style={{
marginTop: 12, paddingTop: 12, borderTop:`1px solid ${t.border}`,
display:'flex', justifyContent:'space-between', color: t.dim,
}}>
<StdStat icon={<window.IconArrowUp color={t.pass}/>} val={window.fmtBytes(D.stats.up)} fontMono={fontMono} t={t}/>
<StdStat icon={<window.IconArrowDown color={t.accent}/>} val={window.fmtBytes(D.stats.down)} fontMono={fontMono} t={t}/>
<StdStat val={D.stats.tcp} lbl="TCP" fontMono={fontMono} t={t}/>
<StdStat val={D.stats.udp} lbl="UDP" fontMono={fontMono} t={t}/>
<StdStat val={window.fmtUptime(D.stats.uptimeS)} lbl="UP" fontMono={fontMono} t={t}/>
</div>
)}
</StdSection>
<div style={{ height: 12 }}/>
</div>
<StdLogs t={t} D={D} fontMono={fontMono}/>
</div>
);
}
function StdTitle({ t }) {
const cell = { width: 44, height: 32, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', color: t.dim };
return (
<div style={{ height: 36, background: t.chrome, borderBottom:`1px solid ${t.border}`, display:'flex', alignItems:'center', userSelect:'none' }}>
<div style={{ display:'flex', alignItems:'center', gap: 9, padding:'0 14px', flex:1 }}>
<window.BrandMark size={15} color={t.accent}/>
<span style={{ fontSize: 13, fontWeight: 600 }}>Drover-Go</span>
<span style={{ fontSize: 11, color: t.dimmer }}>0.4.2</span>
</div>
<div style={{ display:'flex' }}>
<div style={cell}><window.IconGear color={t.dim}/></div>
<div style={cell}><window.IconMin color={t.dim}/></div>
<div style={cell}><window.IconClose color={t.dim}/></div>
</div>
</div>
);
}
function StdSection({ t, title, right, children }) {
return (
<section>
{(title || right) && (
<div style={{ display:'flex', alignItems:'baseline', marginBottom: 8 }}>
{title && <div style={{ fontSize: 11.5, fontWeight: 600, color: t.dim, letterSpacing: 0.5 }}>{title}</div>}
{right && <div style={{ marginLeft:'auto', fontSize: 11, color: t.dimmer, fontFamily:'"JetBrains Mono",monospace' }}>{right}</div>}
</div>
)}
<div style={{ background: t.panel, border:`1px solid ${t.border}`, borderRadius: 8, padding: 14 }}>
{children}
</div>
</section>
);
}
function StdField({ t, label, children, style }) {
return <label style={{ display:'flex', flexDirection:'column', gap: 5, ...style }}>
<span style={{ fontSize: 11.5, color: t.dim }}>{label}</span>{children}
</label>;
}
function StdInput({ t, fontUI, value, onChange, type, placeholder, onSubmit, disabled, id, inputMode }) {
return <input id={id} value={value} type={type||'text'} disabled={disabled}
inputMode={inputMode}
onChange={e => 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 (
<label style={{ display:'inline-flex', alignItems:'center', gap: 8, cursor:'pointer' }}>
<span style={{
width: 16, height: 16, borderRadius: 4, border:`1px solid ${checked?t.accent:t.border}`,
background: checked ? t.accent : 'transparent',
display:'flex', alignItems:'center', justifyContent:'center', transition:'all .12s',
}}>
{checked && <svg width="10" height="10" viewBox="0 0 10 10"><path d="M2 5l2 2 4-4.4" stroke={t.primaryFg} strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" fill="none"/></svg>}
</span>
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{display:'none'}}/>
<span style={{ fontSize: 13 }}>{children}</span>
</label>
);
}
function StdStatus({ t, D, palette, fontMono }) {
const allOk = D.lastSummary?.failed === 0;
return (
<div>
{D.phase === 'checking'
? <div style={{ display:'flex', alignItems:'center', gap: 8, marginBottom: 8 }}>
<window.StatusDot state="running" palette={palette} size={14}/>
<span style={{ fontWeight: 500 }}>Running diagnostics</span>
</div>
: <div style={{
padding:'7px 11px', marginBottom: 10, borderRadius: 6, fontSize: 12.5, fontWeight: 500,
background: allOk ? t.passSoft : t.warnSoft,
border:`1px solid ${allOk ? t.pass : t.warn}40`,
color: allOk ? t.pass : t.warn,
}}>
{allOk ? 'All checks passed. Ready to start.'
: `${D.lastSummary?.failed} of ${D.tests.length} checks failed. Some features won't work.`}
</div>}
<div>
{D.tests.map(test => {
const r = D.results[test.id];
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
return (
<div key={test.id}>
<div style={{ display:'flex', alignItems:'center', gap: 10, height: 26 }}>
<window.StatusDot state={state} palette={palette} size={13}/>
<span style={{ color: state==='pending'?t.dim:t.text, fontSize: 13 }} title={test.desc}>{test.label}</span>
<span style={{
marginLeft:'auto', fontFamily: fontMono, fontSize: 11.5,
color: state==='failed'?t.danger:state==='skipped'?t.skip:t.dim,
}}>{r?.metric}</span>
{r?.result === 'failed' && (
<button onClick={() => D.toggleExpand(test.id)} style={{
background:'transparent', border:'none', cursor:'pointer', padding: 4, color: t.dim,
}}><window.IconChevron color={t.dim} dir={r.expanded?'up':'down'}/></button>
)}
</div>
{r?.result === 'failed' && r.expanded && (
<div className="drv-fadein" style={{
margin: '4px 0 6px 23px', padding: 10, borderRadius: 6,
background: t.danSoft, border: `1px solid ${t.danger}30`, fontSize: 12,
}}>
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 3 }}>{r.error}</div>
<div style={{ color: t.dim, lineHeight: 1.5 }}>{r.hint}</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
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 (
<div style={{
flex: 1, padding:'9px 14px', borderRadius: 6, fontWeight: 600, fontSize: 13,
background: warning ? t.warnSoft : t.passSoft, color: c, border:`1px solid ${c}55`,
display:'flex', alignItems:'center', justifyContent:'center', gap: 8,
}}>
<span className="drv-pulsedot" style={{ width: 7, height: 7, borderRadius: 4, background: c }}/>
Active{warning ? ' · UDP fallback' : ''}
</div>
);
}
return <button onClick={D.startProxy} disabled={!ok} style={{
flex: 1, padding:'9px 14px', borderRadius: 6, border:'none',
background: ok ? t.accent : t.panel2, color: ok ? t.primaryFg : t.dimmer,
fontWeight: 600, fontSize: 13, cursor: ok?'pointer':'not-allowed', fontFamily: fontUI,
}}>Start proxying</button>;
}
function StdStopBtn({ t, D, fontUI }) {
const enabled = D.phase === 'active';
return <button onClick={D.stopProxy} disabled={!enabled} style={{
flex: 1, padding:'9px 14px', borderRadius: 6, background:'transparent',
color: enabled ? t.text : t.dimmer, border:`1px solid ${t.border}`,
fontWeight: 600, fontSize: 13, cursor: enabled?'pointer':'not-allowed', fontFamily: fontUI,
}}>Stop</button>;
}
function StdStat({ icon, val, lbl, fontMono, t }) {
return <div style={{ display:'flex', alignItems:'center', gap: 4 }}>
{icon}<span style={{ fontFamily: fontMono, fontSize: 11.5, color: t.text }}>{val}</span>
{lbl && <span style={{ fontSize: 10, color: t.dimmer, textTransform:'uppercase', letterSpacing: 0.5 }}>{lbl}</span>}
</div>;
}
function StdLogs({ t, D, fontMono }) {
return (
<div style={{ borderTop:`1px solid ${t.border}`, background: t.chrome, flexShrink: 0 }}>
<button onClick={() => 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,
}}>
<window.IconChevron color={t.dim} dir={D.logsOpen?'down':'right'}/>
<span style={{ fontWeight: 600 }}>Logs</span>
<span style={{ marginLeft:'auto', fontFamily: fontMono, fontSize: 11, color: t.dimmer }}>{D.logs.length}</span>
</button>
{D.logsOpen && (
<>
<div style={{ display:'flex', gap: 6, padding:'0 16px 8px' }}>
{[['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]) => (
<button key={l} onClick={fn||undefined} style={{
background:'transparent', border:`1px solid ${t.border}`, borderRadius: 4,
padding:'4px 9px', fontSize: 11, color: t.dim, cursor:'pointer', fontFamily: fontMono,
}}>{l}</button>
))}
</div>
<div className="drv-log" ref={el => 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) => (
<div key={i}>
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>{' '}
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.accent, fontWeight: 600 }}>[{l.level}]</span>{' '}
{l.msg}
</div>
))}
</div>
</>
)}
</div>
);
}
window.StudioWindow = StudioWindow;
+455
View File
@@ -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 (
<div style={{
width: 480, height: 640, background: t.bg, color: t.text,
display: 'flex', flexDirection: 'column', overflow: 'hidden',
fontFamily: fontUI, fontSize: 13.5, lineHeight: 1.45,
borderRadius: 0,
}}>
<WizardTitleBar t={t} />
{/* Stepper */}
<div style={{
display: 'flex', padding: '14px 18px', alignItems: 'center',
borderBottom: `1px solid ${t.border}`, background: t.chrome, gap: 0,
}}>
{[[1, 'Configure'], [2, 'Verify'], [3, 'Connect']].map(([n, label], i) => {
const done = step > n;
const current = step === n;
const dim = step < n;
return (
<React.Fragment key={n}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{
width: 22, height: 22, borderRadius: '50%',
background: dim ? t.panelAlt : t.accent,
border: dim ? `1px solid ${t.borderHard}` : 'none',
color: dim ? t.dimmer : '#fff',
fontSize: 11, fontWeight: 600,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
transition: 'background .2s',
}}>{done ? '✓' : n}</span>
<span style={{ fontSize: 12.5, fontWeight: current ? 600 : 400, color: dim ? t.dimmer : t.text }}>{label}</span>
</div>
{i < 2 && <div style={{
flex: 1, height: 1, background: step > n ? t.accent : t.borderHard,
margin: '0 10px', transition: 'background .2s',
}} />}
</React.Fragment>
);
})}
</div>
{/* Step content */}
<div style={{ flex: 1, overflow: 'auto', padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 14 }}>
{step === 1 && <WizConfigure t={t} D={D} fontMono={fontMono} />}
{step === 2 && <WizVerify t={t} D={D} fontMono={fontMono} palette={palette} themeKey={themeKey} />}
{step === 3 && <WizConnect t={t} D={D} fontMono={fontMono} />}
</div>
{/* Footer */}
<div style={{
height: 54, display: 'flex', alignItems: 'center', justifyContent: 'flex-end',
gap: 8, padding: '0 18px', borderTop: `1px solid ${t.border}`, background: t.chrome,
}}>
<WizardSecondaryBtn t={t} onClick={() => {
if (step === 2) D.stopProxy?.(), D.setPhase('idle');
if (step === 3) D.stopProxy();
}} disabled={step === 1}>
{step === 3 ? 'Disconnect' : 'Back'}
</WizardSecondaryBtn>
<WizardPrimaryBtn t={t}
onClick={() => {
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'}
</WizardPrimaryBtn>
</div>
</div>
);
}
function WizardTitleBar({ t }) {
return (
<div style={{
height: 32, display: 'flex', alignItems: 'center', padding: '0 12px',
borderBottom: `1px solid ${t.border}`, fontSize: 12, color: t.text, background: t.chrome,
}}>
<window.BrandMark size={14} color={t.accent} />
<span style={{ marginLeft: 8, fontWeight: 600 }}>Drover-Go · Setup</span>
<div style={{ marginLeft: 'auto', display: 'flex' }}>
<WizTitleBtn t={t}><window.IconGear color={t.dim} /></WizTitleBtn>
<WizTitleBtn t={t}><window.IconMin color={t.dim} /></WizTitleBtn>
<WizTitleBtn t={t} hoverBg="#c0463f" hoverFg="#fff"><window.IconClose color={t.dim} /></WizTitleBtn>
</div>
</div>
);
}
function WizTitleBtn({ children, t, hoverBg, hoverFg }) {
const [hover, setHover] = React.useState(false);
return (
<div onMouseEnter={() => 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}</div>
);
}
// 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 (
<>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>Configure your proxy</h2>
<div style={{ fontSize: 12.5, color: t.dim, marginTop: 4 }}>
Введите адрес SOCKS5-сервера. На следующем шаге мы проверим, что Discord будет работать через него.
</div>
</div>
<div style={{
background: t.panel, border: `1px solid ${t.border}`, borderRadius: 8,
padding: 16, display: 'flex', flexDirection: 'column', gap: 12,
}}>
<div style={{ display: 'flex', gap: 10 }}>
<label style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 5 }}>
<span style={{ fontSize: 11.5, color: t.dim, fontWeight: 500 }}>Host</span>
<input value={D.form.host}
onChange={e => D.update({ host: e.target.value })}
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
onFocus={onFocus} onBlur={onBlur}
placeholder="95.165.72.59 или example.com"
style={inputStyle(false)} />
</label>
<label style={{ width: 100, display: 'flex', flexDirection: 'column', gap: 5 }}>
<span style={{ fontSize: 11.5, color: t.dim, fontWeight: 500 }}>Port</span>
<input value={D.form.port}
onChange={e => D.update({ port: e.target.value.replace(/\D/g,'') })}
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
onFocus={onFocus} onBlur={onBlur}
placeholder="12334" inputMode="numeric"
style={inputStyle(false)} />
</label>
</div>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 9, cursor: 'pointer', userSelect: 'none', fontSize: 13 }}>
<span style={{
width: 18, height: 18, borderRadius: 4,
border: `1.5px solid ${D.form.auth ? t.accent : t.borderHard}`,
background: D.form.auth ? t.accent : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{D.form.auth && <svg width="11" height="11" viewBox="0 0 11 11"><path d="M2 5.5l2.5 2.5 4.5-5" stroke="#fff" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</span>
<input type="checkbox" checked={D.form.auth}
onChange={e => { D.update({ auth: e.target.checked }); if (e.target.checked) setTimeout(() => document.getElementById('wiz-login')?.focus(), 30); }}
style={{ display: 'none' }} />
<span>Authentication</span>
</label>
<div style={{ display: 'flex', gap: 10, opacity: D.form.auth ? 1 : 0.4, pointerEvents: D.form.auth ? 'auto' : 'none' }}>
<label style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 5 }}>
<span style={{ fontSize: 11.5, color: t.dim, fontWeight: 500 }}>Login</span>
<input id="wiz-login" disabled={!D.form.auth} value={D.form.login}
onChange={e => D.update({ login: e.target.value })}
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
onFocus={onFocus} onBlur={onBlur}
placeholder="user" style={inputStyle(!D.form.auth)} />
</label>
<label style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 5 }}>
<span style={{ fontSize: 11.5, color: t.dim, fontWeight: 500 }}>Password</span>
<input disabled={!D.form.auth} type="password" value={D.form.password}
onChange={e => D.update({ password: e.target.value })}
onKeyDown={e => e.key === 'Enter' && D.runCheck()}
onFocus={onFocus} onBlur={onBlur}
placeholder="••••••" style={inputStyle(!D.form.auth)} />
</label>
</div>
</div>
<div style={{ fontSize: 12, color: t.dim, marginTop: 'auto', display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{
width: 14, height: 14, borderRadius: '50%', border: `1px solid ${t.borderHard}`,
fontSize: 9, color: t.dim, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>i</span>
Нажмите <span style={{ color: t.text, fontWeight: 600 }}>Verify </span>, чтобы продолжить.
</div>
</>
);
}
// 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 (
<>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
{phase === 'checking' ? 'Verifying your proxy' : (failed === 0 ? 'All checks passed' : 'Some checks failed')}
</h2>
<div style={{ fontSize: 12.5, color: t.dim, marginTop: 4 }}>
{phase === 'checking'
? <>Запускаем 7 проверок против <span style={{ fontFamily: fontMono, color: t.text }}>{D.form.host}:{D.form.port}</span></>
: (failed === 0
? <>Прокси работает. Discord голос, чат и демонстрация будет работать через него.</>
: <>{failed} из {total} проверок не прошли. Часть функций работать не будет.</>)}
</div>
</div>
{/* progress + ring */}
<div style={{
display: 'flex', alignItems: 'center', gap: 16, padding: 14,
background: t.panel, border: `1px solid ${t.border}`, borderRadius: 8,
}}>
<svg width="64" height="64" viewBox="0 0 64 64">
<circle cx="32" cy="32" r="26" fill="none" stroke={t.border} strokeWidth="6" />
<circle cx="32" cy="32" r="26" fill="none"
stroke={failed > 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' }} />
<text x="32" y="36" textAnchor="middle" fontSize="13" fontWeight="600" fill={t.text}>
{phase === 'checked' ? `${total - failed}/${total}` : `${completed}/${total}`}
</text>
</svg>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 13.5 }}>
{phase === 'checking' ? <>Testing <span style={{ fontFamily: fontMono }}>{D.tests.find(x => x.id === D.running)?.label || '…'}</span></> : (failed === 0 ? 'Готово к подключению' : 'Завершено с ошибками')}
</div>
<div style={{ fontSize: 12, color: t.dim, marginTop: 2 }}>
{phase === 'checking' ? 'This usually takes 515 seconds.' : 'См. отчёт ниже.'}
</div>
</div>
</div>
{/* test list */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 1, fontSize: 12.5 }}>
{D.tests.map((test) => {
const r = D.results[test.id];
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
return (
<div key={test.id}>
<div style={{
display: 'flex', alignItems: 'center', gap: 9, padding: '5px 10px',
background: D.running === test.id ? t.accentSoft : 'transparent',
borderRadius: 4, minHeight: 26,
}}>
<window.StatusDot state={state} palette={palette} size={12} />
<span style={{ color: state === 'pending' ? t.dimmer : t.text }} title={test.desc}>{test.label}</span>
<span style={{ marginLeft: 'auto', fontFamily: fontMono, fontSize: 11,
color: state === 'failed' ? t.danger : state === 'skipped' ? t.skip : t.dim }}>
{r?.metric || (state === 'running' ? 'running…' : '')}
</span>
{r?.result === 'failed' && (
<button onClick={() => D.toggleExpand(test.id)}
style={{ width: 20, height: 20, padding: 0, border: 'none', background: 'transparent', cursor: 'pointer' }}>
<window.IconChevron color={t.dim} dir={r.expanded ? 'up' : 'down'} />
</button>
)}
</div>
{r?.result === 'failed' && r.expanded && (
<div className="drv-fadein" style={{
margin: '2px 0 4px 22px', padding: '8px 10px', borderRadius: 6,
background: themeKey === 'l' ? '#fdf2f2' : '#3a1f1f',
border: `1px solid ${t.danger}`, fontSize: 12,
}}>
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 2 }}>{r.error}</div>
<div style={{ color: t.dim }}>{r.hint}</div>
<button onClick={() => 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</button>
</div>
)}
</div>
);
})}
</div>
</>
);
}
// Step 3: Connect (active)
function WizConnect({ t, D, fontMono }) {
const stats = D.stats;
const failed = D.lastSummary?.failed ?? 0;
return (
<>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="drv-pulsedot" style={{
width: 10, height: 10, borderRadius: 5, background: failed > 0 ? t.warn : t.pass,
display: 'inline-block',
}} />
{failed > 0 ? 'Connected · UDP fallback' : 'Connected'}
</h2>
<div style={{ fontSize: 12.5, color: t.dim, marginTop: 4 }}>
Discord routes through <span style={{ fontFamily: fontMono, color: t.text }}>{D.form.host}:{D.form.port}</span>
{' · '}<span style={{ fontFamily: fontMono }}>{window.fmtUptime(stats.uptimeS)}</span>
</div>
</div>
{/* stats grid */}
<div style={{
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8,
}}>
<WizStat t={t} fontMono={fontMono} label="Upload" value={window.fmtBytes(stats.up)} accent={t.accent} />
<WizStat t={t} fontMono={fontMono} label="Download" value={window.fmtBytes(stats.down)} accent={t.pass} />
<WizStat t={t} fontMono={fontMono} label="TCP" value={stats.tcp} accent={t.accent} />
<WizStat t={t} fontMono={fontMono} label="UDP" value={stats.udp} accent={failed > 0 ? t.warn : t.pass} />
</div>
{/* logs panel inline */}
<div style={{
background: t.panel, border: `1px solid ${t.border}`, borderRadius: 8,
padding: 12, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column',
}}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: 11.5, color: t.dim, fontWeight: 600, letterSpacing: 0.2 }}>RECENT LOG</span>
<button onClick={() => 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</button>
<button onClick={D.clearLogs}
style={{ marginLeft: 6, padding: '2px 8px', fontSize: 11, fontFamily: fontMono,
background: 'transparent', color: t.dim, border: `1px solid ${t.borderHard}`,
borderRadius: 4, cursor: 'pointer' }}>clear</button>
</div>
<div className="drv-log" style={{
flex: 1, overflowY: 'auto', fontFamily: fontMono, fontSize: 11,
lineHeight: 1.55, color: t.dim, minHeight: 0,
}} ref={el => el && (el.scrollTop = el.scrollHeight)}>
{D.logs.slice(-30).map((l, i) => (
<div key={i}>
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>
{' '}
<span style={{ color: l.level === 'ERROR' ? t.danger : l.level === 'WARN' ? t.warn : t.pass, fontWeight: 600 }}>[{l.level}]</span>
{' '}
<span>{l.msg}</span>
</div>
))}
</div>
</div>
</>
);
}
function WizStat({ t, fontMono, label, value, accent }) {
return (
<div style={{
background: t.panel, border: `1px solid ${t.border}`, borderRadius: 8,
padding: '10px 12px',
}}>
<div style={{ fontSize: 10.5, color: t.dim, letterSpacing: 0.6, textTransform: 'uppercase', fontWeight: 600 }}>{label}</div>
<div style={{ fontSize: 18, fontWeight: 600, fontFamily: fontMono, marginTop: 3, color: accent }}>{value}</div>
</div>
);
}
function WizardPrimaryBtn({ t, onClick, disabled, children }) {
const [hover, setHover] = React.useState(false);
return (
<button onClick={onClick} disabled={disabled}
onMouseEnter={() => 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}</button>
);
}
function WizardSecondaryBtn({ t, onClick, disabled, children }) {
const [hover, setHover] = React.useState(false);
return (
<button onClick={onClick} disabled={disabled}
onMouseEnter={() => 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}</button>
);
}
window.WizardWindow = WizardWindow;
+322
View File
@@ -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 (
<div style={{
width: 480, height: 640, background: t.bg, color: t.text, display:'flex', flexDirection:'column',
fontFamily: fontUI, fontSize: 13, overflow:'hidden',
border:'1px solid #000', position:'relative',
}}>
{/* subtle grain */}
<div style={{
position:'absolute', inset: 0, pointerEvents:'none', opacity: 0.5,
backgroundImage: `radial-gradient(rgba(255,255,255,0.014) 1px, transparent 1px)`,
backgroundSize: '3px 3px',
}}/>
{/* title — workshop label tag */}
<div style={{
height: 36, background: t.chrome, borderBottom:`1px solid ${t.border}`,
display:'flex', alignItems:'center', userSelect:'none', position:'relative', zIndex: 1,
}}>
<div style={{
width: 4, alignSelf:'stretch', background: t.accent, marginRight: 12,
}}/>
<div style={{ display:'flex', alignItems:'center', gap: 9, flex: 1 }}>
<span style={{ fontSize: 13, fontWeight: 700, letterSpacing: 1.5, textTransform:'uppercase' }}>Drover-Go</span>
<span style={{
fontSize: 10, color: t.dim, fontFamily: fontMono, padding:'1px 5px',
border:`1px solid ${t.border}`, borderRadius: 1,
}}>v0.4.2</span>
</div>
<div style={{ display:'flex' }}>
{[<window.IconGear color={t.dim}/>, <window.IconMin color={t.dim}/>, <window.IconClose color={t.dim}/>].map((ic,i) => (
<div key={i} style={{
width: 40, height: 36, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer',
borderLeft:`1px solid ${t.borderSoft}`,
}}>{ic}</div>
))}
</div>
</div>
<div style={{ flex: 1, overflow:'auto', padding:'14px 16px 4px', position:'relative', zIndex: 1 }}>
{/* form */}
<WshHead t={t}>SOCKS5 PROXY</WshHead>
<div style={{ background: t.panel, padding: 13, borderLeft:`3px solid ${t.accent}`, borderRadius: 1 }}>
<div style={{ display:'flex', gap: 10 }}>
<WshField t={t} label="HOST" style={{ flex: 1 }}>
<WshInput t={t} value={D.form.host} onChange={v => D.update({ host: v })}
placeholder="95.165.72.59 / example.com" onSubmit={D.runCheck} fontUI={fontUI}/>
</WshField>
<WshField t={t} label="PORT" style={{ width: 96 }}>
<WshInput t={t} value={D.form.port} onChange={v => D.update({ port: v.replace(/\D/g,'') })}
placeholder="12334" inputMode="numeric" onSubmit={D.runCheck} fontUI={fontUI}/>
</WshField>
</div>
<div style={{ height: 11 }}/>
<WshSwitch t={t} checked={D.form.auth}
onChange={v => { D.update({ auth: v }); if (v) setTimeout(()=>document.getElementById('wsh-login')?.focus(),30); }}>
AUTHENTICATION
</WshSwitch>
<div style={{
display:'flex', gap: 10, marginTop: 10,
opacity: D.form.auth?1:0.4, pointerEvents: D.form.auth?'auto':'none',
}}>
<WshField t={t} label="LOGIN" style={{ flex: 1 }}>
<WshInput id="wsh-login" t={t} value={D.form.login} disabled={!D.form.auth}
onChange={v => D.update({ login: v })} placeholder="user" onSubmit={D.runCheck} fontUI={fontUI}/>
</WshField>
<WshField t={t} label="PASSWORD" style={{ flex: 1 }}>
<WshInput t={t} value={D.form.password} type="password" disabled={!D.form.auth}
onChange={v => D.update({ password: v })} placeholder="••••••" onSubmit={D.runCheck} fontUI={fontUI}/>
</WshField>
</div>
<div style={{ height: 12 }}/>
<button onClick={D.runCheck} disabled={D.phase==='checking'||isActive} style={{
width:'100%', padding:'9px 14px', borderRadius: 1, border:'none',
background: (D.phase==='checking'||isActive) ? t.panel2 : t.accent,
color: (D.phase==='checking'||isActive) ? t.dimmer : t.primaryFg,
fontWeight: 700, fontSize: 12, letterSpacing: 1.5, textTransform:'uppercase',
cursor: D.phase==='checking'?'not-allowed':'pointer', fontFamily: fontUI,
}}>{D.phase==='checking' ? 'Checking…' : 'Check connection'}</button>
</div>
<div style={{ height: 14 }}/>
<WshHead t={t} right={
D.phase==='checking' ? `${Object.keys(D.results).length}/${D.tests.length}` :
D.lastSummary ? (D.lastSummary.failed === 0 ? 'all passed' : `${D.lastSummary.failed} failed`) : null
}>STATUS</WshHead>
<div style={{ background: t.panel, padding: 13, borderRadius: 1 }}>
{D.phase === 'idle'
? <div style={{ display:'flex', alignItems:'center', gap: 8, color: t.dim }}>
<span style={{ width: 7, height: 7, background: t.dimmer }}/>
Ready to check
</div>
: <WshStatus t={t} D={D} palette={palette} fontMono={fontMono}/>}
</div>
<div style={{ height: 14 }}/>
<div style={{ background: t.panel, padding: 13, borderRadius: 1 }}>
<div style={{ display:'flex', gap: 10 }}>
<WshStartBtn t={t} D={D} fontUI={fontUI}/>
<WshStopBtn t={t} D={D} fontUI={fontUI}/>
</div>
{isActive && (
<div style={{
marginTop: 12, paddingTop: 12, borderTop:`1px solid ${t.border}`,
display:'flex', justifyContent:'space-between', color: t.dim,
}}>
<Stat val={window.fmtBytes(D.stats.up)} lbl="↑" t={t} fontMono={fontMono} hl={t.pass}/>
<Stat val={window.fmtBytes(D.stats.down)} lbl="↓" t={t} fontMono={fontMono} hl={t.accent}/>
<Stat val={D.stats.tcp} lbl="tcp" t={t} fontMono={fontMono}/>
<Stat val={D.stats.udp} lbl="udp" t={t} fontMono={fontMono}/>
<Stat val={window.fmtUptime(D.stats.uptimeS)} lbl="up" t={t} fontMono={fontMono}/>
</div>
)}
</div>
<div style={{ height: 12 }}/>
</div>
<WshLogs t={t} D={D} fontMono={fontMono}/>
</div>
);
}
function WshHead({ t, children, right }) {
return (
<div style={{ display:'flex', alignItems:'baseline', marginBottom: 8 }}>
<div style={{
fontSize: 10.5, fontWeight: 700, letterSpacing: 2, color: t.dim,
display:'flex', alignItems:'center', gap: 8,
}}>
<span style={{ width: 8, height: 2, background: t.accent }}/>
{children}
</div>
{right && <div style={{ marginLeft:'auto', fontSize: 10.5, color: t.dimmer, fontFamily:'"IBM Plex Mono",monospace', letterSpacing: 0.5 }}>{right}</div>}
</div>
);
}
function WshField({ t, label, children, style }) {
return <label style={{ display:'flex', flexDirection:'column', gap: 5, ...style }}>
<span style={{ fontSize: 10, color: t.dim, letterSpacing: 1.5, fontWeight: 600 }}>{label}</span>{children}
</label>;
}
function WshInput({ t, value, onChange, type, placeholder, onSubmit, disabled, id, fontUI, inputMode }) {
return <input id={id} value={value} type={type||'text'} disabled={disabled} inputMode={inputMode}
onChange={e => 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 (
<label style={{ display:'inline-flex', alignItems:'center', gap: 9, cursor:'pointer' }}>
<span style={{
width: 32, height: 18, padding: 2, boxSizing:'border-box',
background: checked ? t.accent : t.border,
display:'flex', alignItems:'center', transition:'background .14s', borderRadius: 1,
}}>
<span style={{
width: 12, height: 12, background: checked ? '#1a120a' : '#9a958c',
transform: checked ? 'translateX(14px)' : 'translateX(0)', transition:'transform .14s',
}}/>
</span>
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display:'none' }}/>
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: 1.2 }}>{children}</span>
</label>
);
}
function WshStatus({ t, D, palette, fontMono }) {
return (
<div>
{D.phase === 'checking'
? <div style={{ display:'flex', alignItems:'center', gap: 8, marginBottom: 8 }}>
<window.StatusDot state="running" palette={palette} size={14}/>
<span style={{ fontWeight: 500 }}>Running diagnostics</span>
</div>
: <div style={{
padding:'8px 11px', marginBottom: 10, fontSize: 12, fontWeight: 600, letterSpacing: 0.5,
background: D.lastSummary?.failed === 0 ? `${t.pass}1f` : t.accentSoft,
borderLeft: `3px solid ${D.lastSummary?.failed === 0 ? t.pass : t.accent}`,
color: D.lastSummary?.failed === 0 ? t.pass : t.accent,
}}>
{D.lastSummary?.failed === 0
? 'All checks passed. Ready to start.'
: `${D.lastSummary?.failed} of ${D.tests.length} checks failed. Some features won't work.`}
</div>}
<div>
{D.tests.map(test => {
const r = D.results[test.id];
const state = r?.result || (D.running === test.id ? 'running' : 'pending');
return (
<div key={test.id} style={{ borderBottom:`1px solid ${t.borderSoft}` }}>
<div style={{ display:'flex', alignItems:'center', gap: 10, height: 28 }}>
<window.StatusDot state={state} palette={palette} size={13}/>
<span style={{ color: state==='pending'?t.dim:t.text, fontSize: 12.5 }} title={test.desc}>{test.label}</span>
<span style={{
marginLeft:'auto', fontFamily: fontMono, fontSize: 11,
color: state==='failed'?t.danger:state==='skipped'?t.skip:t.dim,
}}>{r?.metric}</span>
{r?.result === 'failed' && (
<button onClick={() => D.toggleExpand(test.id)} style={{
background:'transparent', border:'none', cursor:'pointer', padding: 4, color: t.dim,
}}><window.IconChevron color={t.dim} dir={r.expanded?'up':'down'}/></button>
)}
</div>
{r?.result === 'failed' && r.expanded && (
<div className="drv-fadein" style={{
margin: '0 0 6px 23px', padding:'7px 10px',
background: t.panel2, borderLeft:`2px solid ${t.danger}`, fontSize: 12,
}}>
<div style={{ color: t.danger, fontWeight: 600, marginBottom: 3 }}>{r.error}</div>
<div style={{ color: t.dim, lineHeight: 1.5 }}>{r.hint}</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
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 (
<div style={{
flex: 1, padding:'9px 14px', borderRadius: 1, fontWeight: 700, fontSize: 12, letterSpacing: 1.5,
background: `${c}1f`, color: c, borderLeft:`3px solid ${c}`,
textTransform:'uppercase', display:'flex', alignItems:'center', justifyContent:'center', gap: 8,
}}>
<span className="drv-pulsedot" style={{ width: 8, height: 8, background: c }}/>
Active{warning ? ' · UDP warn' : ''}
</div>
);
}
return <button onClick={D.startProxy} disabled={!ok} style={{
flex: 1, padding:'9px 14px', borderRadius: 1, border:'none',
background: ok ? t.accent : t.panel2, color: ok ? t.primaryFg : t.dimmer,
fontWeight: 700, fontSize: 12, letterSpacing: 1.5, textTransform:'uppercase',
cursor: ok?'pointer':'not-allowed', fontFamily: fontUI,
}}>Start proxying</button>;
}
function WshStopBtn({ t, D, fontUI }) {
const enabled = D.phase === 'active';
return <button onClick={D.stopProxy} disabled={!enabled} style={{
flex: 1, padding:'9px 14px', borderRadius: 1, background:'transparent',
color: enabled ? t.text : t.dimmer, border:`1px solid ${t.border}`,
fontWeight: 700, fontSize: 12, letterSpacing: 1.5, textTransform:'uppercase',
cursor: enabled?'pointer':'not-allowed', fontFamily: fontUI,
}}>Stop</button>;
}
function Stat({ val, lbl, t, fontMono, hl }) {
return <div style={{ display:'flex', alignItems:'baseline', gap: 4 }}>
<span style={{ fontSize: 10, color: hl||t.dimmer, textTransform:'uppercase', letterSpacing: 0.5, fontWeight: 700 }}>{lbl}</span>
<span style={{ fontFamily: fontMono, fontSize: 11.5, color: t.text }}>{val}</span>
</div>;
}
function WshLogs({ t, D, fontMono }) {
return (
<div style={{ borderTop:`1px solid ${t.border}`, background: t.chrome, flexShrink: 0, position:'relative', zIndex: 1 }}>
<button onClick={() => 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',
}}>
<window.IconChevron color={t.dim} dir={D.logsOpen?'down':'right'}/>
<span style={{ fontWeight: 700 }}>Logs</span>
<span style={{ marginLeft:'auto', fontFamily: fontMono, fontSize: 10.5, color: t.dimmer, letterSpacing: 0 }}>{D.logs.length} lines</span>
</button>
{D.logsOpen && (
<>
<div style={{ display:'flex', gap: 6, padding:'0 16px 8px' }}>
{[['copy', () => navigator.clipboard?.writeText(D.logs.map(x=>`[${x.level}] ${x.msg}`).join('\n'))],
['clear', D.clearLogs], ['file', null]].map(([l, fn]) => (
<button key={l} onClick={fn||undefined} style={{
background:'transparent', border:`1px solid ${t.border}`, borderRadius: 1,
padding:'3px 8px', fontSize: 10.5, color: t.dim, cursor:'pointer',
fontFamily: fontMono, letterSpacing: 1, textTransform:'uppercase',
}}>{l}</button>
))}
</div>
<div className="drv-log" ref={el => 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) => (
<div key={i}>
<span style={{ color: t.dimmer }}>{window.fmtTime(l.t)}</span>{' '}
<span style={{ color: l.level==='ERROR'?t.danger:l.level==='WARN'?t.warn:t.accent, fontWeight: 700 }}>[{l.level}]</span>{' '}
{l.msg}
</div>
))}
</div>
</>
)}
</div>
);
}
window.WorkshopWindow = WorkshopWindow;