diff --git a/AGENTS.md b/AGENTS.md index 259b6f1..878b354 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,23 +1,26 @@ # LeetTutor maintainer and AI guide -LeetTutor has two supported surfaces: +LeetTutor has one product UI with three supported access paths: -- the full local Streamlit application, including code execution, progress, - local/cloud providers, and trusted-LAN host mode; -- the hosted Local App UI in `web-demo`, which calls the repository's real - curriculum, system-design catalog, constrained runner, importer, solution - store, progress store, and local model through `scripts/browser_bridge.py` - on the visitor's own loopback interface. +- the original Streamlit application in `app.py`, including code execution, + progress, local/cloud providers, and the floating JARVIS; +- trusted-LAN host mode for that same application; +- the hosted shell in `web-demo`, which embeds the unchanged original UI from + `127.0.0.1:8501` after the visitor starts `scripts/launch.py --hosted`. + +Do not build or ship a second LeetTutor workspace in `web-demo`. It is only the +bilingual connection shell for the original app. UX changes belong in `app.py` +so local, hosted, and LAN access cannot drift apart again. ## Safety invariants -The public portfolio server must never proxy model traffic. The bridge must -bind only to `127.0.0.1`, allow only reviewed origins, expose only the explicit -model/app endpoint allowlists, accept bounded non-streaming JSON, and forward -model traffic only to another loopback URL. Code execution must continue to use -`leettutor.code_runner`; solution access must continue to use `SolutionStore`. -Do not store prompts, code, API keys, or responses on EC2. Do not add a public -bind flag to the companion. +The public portfolio server must never proxy the Streamlit app or model traffic. +Hosted mode must bind the original app only to `127.0.0.1`; the Caddy boundary +may frame that exact loopback origin but must not expose it publicly. The legacy +browser bridge must retain its loopback bind, reviewed origins, bounded API +allowlists, and loopback-only model upstreams. Code execution must continue to +use `leettutor.code_runner`; solution access must continue to use +`SolutionStore`. Do not store prompts, code, API keys, or responses on EC2. Host mode is for a trusted private LAN and is not an Internet deployment. Preserve its access-code/device-token boundary and the warnings in @@ -29,8 +32,9 @@ Run `python -m pytest`, `python -m compileall -q app.py leettutor scripts tests` and `node --check web-demo/app.js`. Branch from protected `master`, open a PR, and merge after `LeetTutor quality and demo deployment` passes. A merge packages only `web-demo`, then the repository-scoped runner atomically deploys it to -`https://tonytan.me/leetcode/`. The Python companion, full Streamlit app, user -solutions, progress, and local model are never installed on EC2. +`https://tonytan.me/leetcode/`. The shell contains no replacement workspace. +The Python process, original Streamlit UI, user solutions, progress, and local +model are never installed on EC2. Shared Caddy, server accounts, sudo rules, and rollback policy live in the `Personal-Website/ops` repository. Never commit `config.json`, `.env`, diff --git a/README.md b/README.md index 0b749b5..1f7704b 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,17 @@ **LeetTutor · Made by Tony** 是互动式 LeetCode 与系统设计学习助手。内置导师 **JARVIS** 会根据薄弱项和练习进度安排下一题;界面运行在浏览器中,可以直接导入题目、写 Python、跑自定义测试,并把当前代码现场交给 AI。模型既可完全留在本机,也可选择 OpenAI API 或 Gemini API;任意能运行本项目的电脑都可以开启局域网主机模式,让同一 Wi-Fi 内的手机或平板继续刷题。 -## 在线真实应用 + 本机源码 / 模型 +## 在线入口 + 原版应用 / 本机模型 -[tonytan.me/leetcode/](https://tonytan.me/leetcode/) 只从服务器加载仓库内的 UI 资源,不运行假题目或服务端模型。启动本机 companion 后,网页会直接调用这个仓库里的 `curriculum`、`system_design_curriculum`、`code_runner`、`leetcode_client` 与 `SolutionStore`:可以切换真实课程、导入任意 LeetCode 题目、在本机受限进程跑测试、保存到克隆仓库、记录进度,并让 JARVIS 结合真实代码和运行结果辅导。 +[tonytan.me/leetcode/](https://tonytan.me/leetcode/) 不再维护另一套简化 UI。启动本机应用后,网站会在当前标签页加载仓库中原本的 `app.py`:漂浮 JARVIS、左右工作区、Ace 编辑器、学习进度、模型设置和手机导航全部与直接运行原项目共用同一份 Streamlit 源码,不会再出现线上版和原版 UX 分叉。 ```bash ./launch_companion.command ``` -浏览器版本的主入口是 `https://tonytan.me/leetcode/`。用户先自行启动 Ollama(或 LM Studio)和 `python3 scripts/browser_bridge.py`,再回到网页点击连接;之后选题、运行代码、保存进度和 JARVIS 辅导都在浏览器 UI 中完成。后端只监听回环地址,静态资源和 API 都采用固定白名单,题解、测试、进度、Prompt、模型名和回复不经过 EC2。LM Studio 使用 `python3 scripts/browser_bridge.py --upstream http://127.0.0.1:1234/v1`。需要排查浏览器本地网络权限时,可传 `--open-browser` 打开同源备用界面。完整 Streamlit 产品仍按下面方式在本机运行。 +浏览器入口仍是 `https://tonytan.me/leetcode/`。用户先自行启动 Ollama(或 LM Studio),再运行 `python3 scripts/launch.py --hosted`,回到网站点击“打开原版 LeetTutor”。Hosted 模式只把原版 Streamlit 绑定到 `127.0.0.1:8501`,不会另开本机标签页;网站只负责在当前页面显示它。题解、测试、进度、Prompt、模型名和回复都留在本机,不经过 EC2。无法使用浏览器本地网络权限时,可直接打开 `http://127.0.0.1:8501/` 作为备用入口。 + +`scripts/browser_bridge.py` 仍保留为兼容与 API 调试工具,但不再承载主产品 UI。 ## 最快启动 diff --git a/README_EN.md b/README_EN.md index a470f4a..bcb8950 100644 --- a/README_EN.md +++ b/README_EN.md @@ -4,15 +4,17 @@ LeetTutor is a local-first AI learning workspace for LeetCode and system design. Its JARVIS mentor chooses the next exercise from your weak areas, reads the live problem/code/test context, and teaches through short Socratic prompts instead of dumping an answer. You can run Python solutions in the browser, use either local or cloud models, and continue on a phone through a trusted home-network host. -## Hosted real app + source/model on your computer +## Hosted entry + original app/model on your computer -[tonytan.me/leetcode/](https://tonytan.me/leetcode/) downloads only versioned UI assets from the server; it does not use fake missions or a server-side model. Once the local companion is running, the page calls this repository's real curriculum, system-design catalog, constrained code runner, LeetCode importer, SolutionStore, and progress store. It can import a problem, execute tests locally, save into the clone, and give JARVIS the real code and run result. +[tonytan.me/leetcode/](https://tonytan.me/leetcode/) no longer maintains a separate simplified workspace. After the local app starts, the site loads the repository's original `app.py` in the current tab. The floating JARVIS, split workspace, Ace editor, progress, model settings, and mobile navigation therefore share the same Streamlit source as the directly launched product. ```bash ./launch_companion.command ``` -The browser version lives at `https://tonytan.me/leetcode/`. The user starts Ollama (or LM Studio) and `python3 scripts/browser_bridge.py`, returns to the website, and connects; problem selection, code execution, progress, and JARVIS then stay in the browser UI. The backend binds only to loopback and serves fixed static and API allowlists. Code, tests, progress, prompts, model names, and responses never pass through EC2. For LM Studio, pass `--upstream http://127.0.0.1:1234/v1`. Use `--open-browser` only when the same-origin fallback is needed to troubleshoot browser local-network permission. The full Streamlit product remains available through the quick-start flow below. +The browser entry remains `https://tonytan.me/leetcode/`. Start Ollama (or LM Studio), run `python3 scripts/launch.py --hosted`, return to the site, and choose “Open original LeetTutor.” Hosted mode binds the original Streamlit app only to `127.0.0.1:8501` and does not open a second local tab. Code, tests, progress, prompts, model names, and responses never pass through EC2. If Chrome local-network permission is unavailable, open `http://127.0.0.1:8501/` as the direct fallback. + +`scripts/browser_bridge.py` remains available for compatibility and API diagnostics, but it no longer provides the primary product UI. ## Highlights diff --git a/launch_companion.bat b/launch_companion.bat index c2294bc..da86463 100644 --- a/launch_companion.bat +++ b/launch_companion.bat @@ -2,8 +2,8 @@ setlocal cd /d "%~dp0" if exist ".venv\Scripts\python.exe" ( - ".venv\Scripts\python.exe" scripts\browser_bridge.py + ".venv\Scripts\python.exe" scripts\launch.py --hosted ) else ( - py -3 scripts\browser_bridge.py + py -3 scripts\launch.py --hosted ) endlocal diff --git a/launch_companion.command b/launch_companion.command index b051963..5854b55 100755 --- a/launch_companion.command +++ b/launch_companion.command @@ -6,7 +6,7 @@ project_dir="$(cd "$(dirname "$0")" && pwd)" cd "$project_dir" if [[ -x .venv/bin/python ]]; then - exec .venv/bin/python scripts/browser_bridge.py + exec .venv/bin/python scripts/launch.py --hosted fi -exec python3 scripts/browser_bridge.py +exec python3 scripts/launch.py --hosted diff --git a/scripts/launch.py b/scripts/launch.py index 7d41266..b65d6d8 100644 --- a/scripts/launch.py +++ b/scripts/launch.py @@ -104,6 +104,11 @@ def parse_args() -> argparse.Namespace: action="store_true", help="Allow password-protected access from other devices on this LAN.", ) + parser.add_argument( + "--hosted", + action="store_true", + help="Run the original UI on loopback for embedding at tonytan.me/leetcode/.", + ) parser.add_argument( "--port", type=int, default=8501, help="Streamlit port (default: 8501)." ) @@ -112,6 +117,12 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() + if args.lan and args.hosted: + print("[LeetTutor] --lan 与 --hosted 不能同时使用。", file=sys.stderr) + return 2 + if args.hosted and args.port != 8501: + print("[LeetTutor] Hosted 模式固定使用回环端口 8501。", file=sys.stderr) + return 2 if not 1024 <= args.port <= 65535: print("[LeetTutor] 端口必须在 1024 到 65535 之间。", file=sys.stderr) return 2 @@ -137,7 +148,7 @@ def main() -> int: print(f"[LeetTutor] Qwen3.5 9B GPU 服务{owner}:{AMD_METAL_ENDPOINT}") environment = os.environ.copy() - server_address = "localhost" + server_address = "127.0.0.1" if args.hosted else "localhost" if args.lan: server_address = "0.0.0.0" lan_host = find_lan_ipv4() or f"{socket.gethostname()}.local" @@ -159,7 +170,11 @@ def main() -> int: print("[LeetTutor] 首次验证后,可在该浏览器记住此主机 30 天。") print("[LeetTutor] 手机与主机必须在同一 Wi-Fi;请勿把端口映射到公网。") - print("[LeetTutor] 正在启动;浏览器会自动打开。按 Ctrl+C 停止。") + if args.hosted: + print("[LeetTutor] 正在启动原版应用;请回到 https://tonytan.me/leetcode/ 点击连接。") + print("[LeetTutor] 本机备用地址:http://127.0.0.1:8501/;按 Ctrl+C 停止。") + else: + print("[LeetTutor] 正在启动;浏览器会自动打开。按 Ctrl+C 停止。") command = [ str(python), "-m", @@ -168,7 +183,7 @@ def main() -> int: str(PROJECT_ROOT / "app.py"), f"--server.address={server_address}", f"--server.port={args.port}", - "--server.headless=false", + f"--server.headless={'true' if args.hosted else 'false'}", "--client.toolbarMode=viewer", ] if metal_runtime is not None: diff --git a/tests/test_app_smoke.py b/tests/test_app_smoke.py index e978c1e..3623a5b 100644 --- a/tests/test_app_smoke.py +++ b/tests/test_app_smoke.py @@ -68,6 +68,23 @@ def test_lan_launcher_is_explicit_and_password_protected() -> None: assert "请勿把端口映射到公网" in launcher +def test_hosted_launcher_runs_the_original_app_without_opening_another_tab() -> None: + project_root = Path(__file__).resolve().parents[1] + launcher = (project_root / "scripts" / "launch.py").read_text(encoding="utf-8") + companion_macos = (project_root / "launch_companion.command").read_text( + encoding="utf-8" + ) + companion_windows = (project_root / "launch_companion.bat").read_text( + encoding="utf-8" + ) + + assert '"--hosted"' in launcher + assert 'server_address = "127.0.0.1" if args.hosted else "localhost"' in launcher + assert "--server.headless={'true' if args.hosted else 'false'}" in launcher + assert "scripts/launch.py --hosted" in companion_macos + assert "scripts\\launch.py --hosted" in companion_windows + + def test_lan_access_gate_runs_before_the_workspace(monkeypatch) -> None: app_path = Path(__file__).resolve().parents[1] / "app.py" monkeypatch.setenv("LEETTUTOR_ACCESS_CODE", "12345678") diff --git a/tests/test_browser_bridge.py b/tests/test_browser_bridge.py index 07cac43..d71bff2 100644 --- a/tests/test_browser_bridge.py +++ b/tests/test_browser_bridge.py @@ -24,11 +24,13 @@ class BrowserBridgeValidationTests(unittest.TestCase): - def test_hosted_ui_declares_the_companion_as_loopback(self) -> None: + def test_hosted_ui_embeds_the_original_loopback_app(self) -> None: app_source = (ROOT / "web-demo" / "app.js").read_text(encoding="utf-8") - self.assertIn('targetAddressSpace: "loopback"', app_source) - self.assertNotIn('targetAddressSpace: "local"', app_source) - self.assertIn("if (!hostedPortfolio) testConnection();", app_source) + index_source = (ROOT / "web-demo" / "index.html").read_text(encoding="utf-8") + self.assertIn('http://127.0.0.1:8501/?embed=true', app_source) + self.assertIn('id="original-app"', index_source) + self.assertIn('allow="local-network; loopback-network; local-network-access"', index_source) + self.assertNotIn('class="mentor-pane"', index_source) def test_accepts_loopback_model_servers(self) -> None: self.assertEqual(validate_upstream("http://127.0.0.1:11434/"), "http://127.0.0.1:11434") diff --git a/web-demo/app.js b/web-demo/app.js index 3ed7e00..632c3d9 100644 --- a/web-demo/app.js +++ b/web-demo/app.js @@ -1,395 +1,90 @@ "use strict"; +const LOCAL_APP_URL = "http://127.0.0.1:8501/?embed=true"; +const $ = (selector) => document.querySelector(selector); + const copy = { en: { - privacy: "REAL LOCAL APP · NO SERVER RELAY", language: "Language", theme: "Theme", practiceLabel: "SOURCE CURRICULUM", - practiceMode: "Practice mode", mission: "Mission", openOriginal: "Open original problem ↗", objective: "This round", - importProblem: "Import any LeetCode problem from this computer", problemUrl: "LeetCode URL or slug", import: "Import statement & starter", - realRunner: "Real constrained Python runner", methodName: "Solution method", testCases: "JSON test cases", runCode: "Run on this computer", - editorLabel: "LOCAL SOURCE WORKSPACE", localDraft: "DRAFT STAYS LOCAL", editorAria: "Python solution or system design notes", - saveSource: "Save to cloned repository", review: "Ask JARVIS to review", mentorLabel: "LOCAL AI MENTOR", - opening: "Start the local companion, choose a source mission, and I will coach against your real code and test output.", - replyLabel: "Your reasoning", replyPlaceholder: "Explain your next step…", send: "Send to local JARVIS", - connectionEyebrow: "BROWSER APP · LOCAL BACKEND", connectionTitle: "Start the two local services, then do everything here.", - connectionBody: "This website is the complete UI. Python runs the source-backed APIs and code runner; Ollama or LM Studio runs the model. The browser connects to both through the loopback-only backend, and EC2 never receives code, prompts, or responses.", - bridgeUrl: "Python backend", model: "Local model", test: "Connect and enter workspace", notConnected: "Waiting for the Python backend and local model.", - setup: "One-time connection steps", setup1: "Start Ollama, then start the LeetTutor Python backend in the cloned repository.", setup2: "Allow tonytan.me to access the local network when Chrome asks.", setup3: "Return to this tab and choose “Connect and enter workspace.”", openLocal: "Open fallback UI ↗", source: "Get source and backend ↗", - connected: "Connected: {problems} algorithm missions, {systems} system-design missions, {models} local model(s).", - failed: "Backend not reachable. Start Ollama and python3 scripts/browser_bridge.py, allow Chrome local-network access, then retry.", - loading: "Loading from this computer…", imported: "Imported {title} through the local source client.", importFailed: "Could not import this problem locally.", - ready: "READY", running: "RUNNING", passed: "PASSED", failedRun: "FAILED", saved: "Saved as {path} on this computer.", saveFailed: "Could not save; an existing solution is never overwritten automatically.", - thinking: "JARVIS is thinking on your computer…", coldStart: "The local model is still loading on this computer; the first answer can take longer.", requestFailed: "The local model did not answer. Check the companion terminal and selected model.", - empty: "Explain your reasoning or ask a question first.", progress: "Progress: {status} · {attempts} attempt(s)", newProblem: "Progress: new", - algorithmMode: "Algorithms", systemMode: "System design", notesLanguage: "Architecture notes · stored locally", codeLanguage: "Python 3 · local constrained process", + language: "Language", theme: "Theme", eyebrow: "ORIGINAL APP · LOCAL EXECUTION", + title: "The original LeetTutor, in this tab.", + intro: "No replacement dashboard. The hosted page opens the repository's real Streamlit product, including the floating JARVIS, original workspace, progress, editor, and model settings.", + privacy: "The app and model run only on this computer. tonytan.me does not receive code, prompts, progress, or answers.", + step1Title: "Start your model", step1Body: "Open Ollama, LM Studio, or the configured AMD Metal runtime.", + step2Title: "Start the original app", step2Body: "Double-click launch_companion.command, or run:", + step3Title: "Connect here", step3Body: "Allow local-network access when Chrome asks. The original app then fills this tab.", + connect: "Open original LeetTutor", fallback: "Open the original app directly ↗", + waiting: "Waiting for the original app on this computer.", opening: "Opening the original LeetTutor…", + troubleshoot: "Chrome already blocked it?", + permissionHelp: "In the address bar, open Site controls → Site settings → Local network access → Allow, refresh, and connect again.", }, zh: { - privacy: "真实本机应用 · 不经过服务器", language: "语言", theme: "主题", practiceLabel: "源码课程", - practiceMode: "训练模式", mission: "任务", openOriginal: "打开 LeetCode 原题 ↗", objective: "本轮目标", - importProblem: "通过本机源码导入任意 LeetCode 题目", problemUrl: "LeetCode 链接或 slug", import: "导入题面与 Starter", - realRunner: "真实受限 Python 运行器", methodName: "Solution 方法名", testCases: "JSON 测试用例", runCode: "在这台电脑运行", - editorLabel: "本机源码工作区", localDraft: "草稿仅留本机", editorAria: "Python 题解或系统设计笔记", - saveSource: "保存到本机克隆仓库", review: "让 JARVIS Review", mentorLabel: "本机 AI 导师", - opening: "启动本机 companion 并选择源码任务后,我会结合你的真实代码和测试结果进行辅导。", - replyLabel: "你的推理", replyPlaceholder: "解释下一步怎么做…", send: "发送给本机 JARVIS", - connectionEyebrow: "浏览器应用 · 本机后端", connectionTitle: "启动两个本机服务,然后所有操作都在这里完成。", - connectionBody: "这个网站就是完整 UI。Python 提供真实源码 API 和代码运行器,Ollama 或 LM Studio 提供模型;浏览器只通过回环后端连接它们,EC2 收不到代码、Prompt 或回答。", - bridgeUrl: "Python 后端", model: "本机模型", test: "连接并进入工作区", notConnected: "正在等待 Python 后端和本机模型。", - setup: "一次连接三步", setup1: "先启动 Ollama,再在克隆仓库中启动 LeetTutor Python 后端。", setup2: "Chrome 询问时,允许 tonytan.me 访问本地网络。", setup3: "回到此标签页,点击“连接并进入工作区”。", openLocal: "打开备用界面 ↗", source: "获取源码与后端 ↗", - connected: "已连接:{problems} 个算法任务、{systems} 个系统设计任务、{models} 个本机模型。", - failed: "无法连接后端。请启动 Ollama 和 python3 scripts/browser_bridge.py,允许 Chrome 访问本地网络后重试。", - loading: "正在从这台电脑加载…", imported: "已通过本机源码客户端导入 {title}。", importFailed: "无法在本机导入这道题。", - ready: "就绪", running: "运行中", passed: "通过", failedRun: "未通过", saved: "已保存到这台电脑:{path}", saveFailed: "保存失败;现有题解绝不会被自动覆盖。", - thinking: "JARVIS 正在你的电脑上思考…", coldStart: "本机模型仍在加载;第一次回答可能更慢。", requestFailed: "本机模型没有返回;请检查 companion 终端和所选模型。", - empty: "请先解释推理或提出问题。", progress: "进度:{status} · 尝试 {attempts} 次", newProblem: "进度:新题", - algorithmMode: "算法刷题", systemMode: "系统设计", notesLanguage: "架构笔记 · 本机保存", codeLanguage: "Python 3 · 本机受限进程", + language: "语言", theme: "主题", eyebrow: "原版应用 · 本机执行", + title: "原版 LeetTutor,直接在这个标签页使用。", + intro: "不再使用另做的 Dashboard。网页会打开仓库中真正的 Streamlit 产品,保留漂浮 JARVIS、原工作区、学习进度、编辑器和模型设置。", + privacy: "应用与模型只在这台电脑运行;代码、Prompt、进度和回答都不会发送给 tonytan.me。", + step1Title: "启动模型", step1Body: "打开 Ollama、LM Studio,或已经配置的 AMD Metal Runtime。", + step2Title: "启动原版应用", step2Body: "双击 launch_companion.command,或运行:", + step3Title: "在这里连接", step3Body: "Chrome 询问时允许本地网络访问,随后原版应用会铺满这个标签页。", + connect: "打开原版 LeetTutor", fallback: "直接打开原版应用 ↗", + waiting: "正在等待这台电脑上的原版应用。", opening: "正在打开原版 LeetTutor…", + troubleshoot: "Chrome 之前已经拒绝?", + permissionHelp: "点击地址栏左侧的网站控制 → 网站设置 → 本地网络访问 → 允许,然后刷新并重新连接。", }, }; -const state = { - locale: "en", connected: false, problems: [], systems: [], progress: {}, current: null, - imported: false, lastRun: null, -}; -const $ = (selector) => document.querySelector(selector); -const hostedPortfolio = ["tonytan.me", "www.tonytan.me"].includes(window.location.hostname); - -function t(key, values = {}) { - let value = copy[state.locale][key] || copy.en[key] || key; - Object.entries(values).forEach(([name, item]) => { value = value.replace(`{${name}}`, String(item)); }); - return value; -} - function detectedLocale() { return navigator.languages?.some((value) => value.toLowerCase().startsWith("zh")) ? "zh" : "en"; } +function locale() { + const preference = localStorage.getItem("leettutor-language") || "system"; + return preference === "system" ? detectedLocale() : preference; +} + function applyPreferences() { const language = localStorage.getItem("leettutor-language") || "system"; const theme = localStorage.getItem("leettutor-theme") || "system"; - state.locale = language === "system" ? detectedLocale() : language; - const resolvedTheme = theme === "system" ? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") : theme; - document.documentElement.lang = state.locale === "zh" ? "zh-CN" : "en"; + const resolvedTheme = theme === "system" + ? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") + : theme; + document.documentElement.lang = locale() === "zh" ? "zh-CN" : "en"; document.documentElement.dataset.theme = resolvedTheme; $("#language").value = language; $("#theme").value = theme; - document.querySelectorAll("[data-copy]").forEach((node) => { node.textContent = t(node.dataset.copy); }); - document.querySelectorAll("[data-placeholder]").forEach((node) => { node.placeholder = t(node.dataset.placeholder); }); - const algorithmOption = $("#practice-mode option[value='algorithm']"); - const systemOption = $("#practice-mode option[value='system']"); - algorithmOption.textContent = t("algorithmMode"); - systemOption.textContent = t("systemMode"); - if (!state.connected) $("#connection-status").textContent = t("notConnected"); - if (state.current) renderCurrent(false); -} - -function bridgeUrl(path) { - const url = new URL($("#bridge-url").value.trim()); - if (url.protocol !== "http:" || !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)) { - throw new Error("The companion must run on this computer."); - } - url.pathname = `${url.pathname.replace(/\/+$/, "")}${path}`; - url.search = ""; - url.hash = ""; - return url.href; -} - -async function localApi(path, options = {}) { - const { timeoutMs = 15_000, ...requestOptions } = options; - const headers = { Accept: "application/json", ...(requestOptions.headers || {}) }; - if (requestOptions.body) headers["Content-Type"] = "application/json"; - const response = await companionFetch(path, { ...requestOptions, headers, cache: "no-store" }, timeoutMs); - let payload; - try { payload = await response.json(); } catch { payload = { ok: false, error: `HTTP ${response.status}` }; } - if (!response.ok || payload.ok === false) throw new Error(payload.error || `HTTP ${response.status}`); - return payload; -} - -async function companionFetch(path, options = {}, timeoutMs = 15_000) { - const controller = new AbortController(); - const timeout = window.setTimeout(() => controller.abort(), timeoutMs); - try { - return await fetch(bridgeUrl(path), { - ...options, - signal: controller.signal, - targetAddressSpace: "loopback", - }); - } finally { - window.clearTimeout(timeout); - } -} - -function setConnected(connected) { - state.connected = connected; - $("#connection-dot").classList.toggle("is-live", connected); - document.body.classList.toggle("is-connected", connected); -} - -async function testConnection() { - const status = $("#connection-status"); - status.textContent = t("loading"); - try { - const [catalog, progressPayload] = await Promise.all([ - localApi("/api/catalog"), localApi("/api/progress"), - ]); - state.problems = catalog.problems || []; - state.systems = catalog.system_design || []; - state.progress = progressPayload.progress || {}; - let modelPayload = { data: [] }; - try { modelPayload = await localApi("/v1/models"); } catch (_error) { /* Code practice still works without a loaded model. */ } - const models = Array.isArray(modelPayload.data) ? modelPayload.data : Array.isArray(modelPayload.models) ? modelPayload.models : []; - const select = $("#model"); - const previous = localStorage.getItem("leettutor-model") || ""; - select.replaceChildren(); - models.forEach((item) => { - const value = item.id || item.name; - if (!value) return; - const option = document.createElement("option"); - option.value = value; option.textContent = value; select.append(option); - }); - if (previous && [...select.options].some((option) => option.value === previous)) select.value = previous; - setConnected(true); - status.textContent = t("connected", { problems: state.problems.length, systems: state.systems.length, models: models.length }); - populateMissions(); - } catch (_error) { - setConnected(false); - status.textContent = t("failed"); - } -} - -function currentKey() { - if (!state.current) return "none"; - const mode = $("#practice-mode").value; - return `${mode}:${state.current.id || state.current.frontend_id || state.current.slug}`; -} - -function draftKey() { return `leettutor-draft:${currentKey()}`; } - -function defaultAlgorithmSource(problem) { - if (Number(problem.id || problem.frontend_id) === 704 || problem.slug === "binary-search") { - return `class Solution:\n def search(self, nums, target):\n left, right = 0, len(nums) - 1\n while left <= right:\n mid = left + (right - left) // 2\n if nums[mid] == target:\n return mid\n # Preserve the binary-search invariant here.\n return -1`; - } - return problem.starter_code || "class Solution:\n pass\n"; -} - -function defaultTests(problem) { - if (problem.sample_cases) return problem.sample_cases; - if (Number(problem.id || problem.frontend_id) === 704 || problem.slug === "binary-search") { - return '[\n {"args":[[-1,0,3,5,9,12],9],"expected":4},\n {"args":[[-1,0,3,5,9,12],2],"expected":-1}\n]'; - } - return '[\n {"args":[],"expected":null}\n]'; -} - -function safeSlug(value) { - return String(value || "solution").toLowerCase().replace(/[^a-z0-9.-]+/g, "-").replace(/^-+|-+$/g, "") || "solution"; -} - -function renderProgress() { - if (!state.current || $("#practice-mode").value !== "algorithm") { - $("#progress-status").textContent = ""; - return; - } - const id = String(state.current.id || state.current.frontend_id || ""); - const entry = state.progress[id]; - $("#progress-status").textContent = entry - ? t("progress", { status: entry.status, attempts: entry.attempts || 0 }) - : t("newProblem"); -} - -function renderCurrent(resetEditor = true) { - const problem = state.current; - if (!problem) return; - const mode = $("#practice-mode").value; - const algorithm = mode === "algorithm"; - const title = algorithm - ? `${problem.id || problem.frontend_id || ""}. ${state.locale === "zh" ? (problem.title_cn || problem.title) : problem.title}` - : `${problem.id} · ${state.locale === "zh" ? problem.title_cn : problem.title}`; - $("#problem-title").textContent = title; - $("#difficulty").textContent = problem.difficulty || "LOCAL"; - $("#problem-statement").textContent = algorithm - ? (problem.statement || problem.focus || "") - : (state.locale === "zh" ? problem.requirement_cn : problem.requirement); - $("#objective").textContent = algorithm - ? (problem.invariant_prompt || (problem.hints || [])[0] || "") - : (state.locale === "zh" ? problem.first_question_cn : problem.first_question); - $("#problem-link").hidden = !algorithm; - $("#problem-link").href = algorithm ? (problem.url || `https://leetcode.com/problems/${problem.slug}/`) : "#"; - $("#runner-card").hidden = !algorithm; - $(".import-card").hidden = !algorithm; - $("#editor-language").textContent = algorithm ? t("codeLanguage") : t("notesLanguage"); - const filename = algorithm - ? `${problem.id || problem.frontend_id || "0"}.${safeSlug(problem.slug || problem.title)}.py` - : `${problem.id}.architecture-notes.md`; - $("#editor-filename").textContent = filename; - if (algorithm) { - $("#method-name").value = problem.method_name || (problem.slug === "binary-search" ? "search" : ""); - $("#test-cases").value = defaultTests(problem); - } - if (resetEditor) { - const saved = localStorage.getItem(draftKey()); - $("#code").value = saved || (algorithm - ? defaultAlgorithmSource(problem) - : `# ${title}\n\n## Requirements and estimates\n\n## API and data model\n\n## Architecture\n\n## Reliability and trade-offs\n`); - } - state.lastRun = null; - $("#run-result").textContent = ""; - $("#run-status").textContent = t("ready"); - renderProgress(); -} - -function populateMissions() { - const mode = $("#practice-mode").value; - const items = mode === "algorithm" ? state.problems : state.systems; - const select = $("#problem-select"); - const previous = state.current?.id || ""; - select.replaceChildren(); - items.forEach((item) => { - const option = document.createElement("option"); - option.value = String(item.id); - const title = state.locale === "zh" ? (item.title_cn || item.title) : item.title; - option.textContent = `${item.id} · ${title}`; - select.append(option); + document.querySelectorAll("[data-copy]").forEach((node) => { + node.textContent = copy[locale()][node.dataset.copy] || copy.en[node.dataset.copy] || node.dataset.copy; }); - state.current = items.find((item) => String(item.id) === String(previous)) || items[0] || null; - if (state.current) { select.value = String(state.current.id); renderCurrent(); } } -async function importProblem() { - const status = $("#import-status"); - status.textContent = t("loading"); - try { - const payload = await localApi("/api/problems/import", { - method: "POST", - body: JSON.stringify({ reference: $("#problem-reference").value, locale: state.locale }), - timeoutMs: 20_000, - }); - state.current = payload.problem; - state.imported = true; - renderCurrent(); - status.textContent = t("imported", { title: payload.problem.title }); - } catch (error) { - status.textContent = `${t("importFailed")} ${error.message}`; - } +function connectOriginal() { + const button = $("#connect-original"); + button.disabled = true; + $("#connection-status").textContent = copy[locale()].opening; + document.body.classList.add("is-connecting"); + const frame = $("#original-app"); + frame.src = LOCAL_APP_URL; } -async function updateProgress(status) { - if (!state.current?.id || state.imported) return; - try { - const payload = await localApi("/api/progress", { - method: "POST", body: JSON.stringify({ problem_id: state.current.id, status }), - }); - state.progress = payload.progress || state.progress; - renderProgress(); - } catch (_error) { /* Progress never blocks code execution. */ } +function revealOriginal() { + const frame = $("#original-app"); + if (!frame.src.startsWith("http://127.0.0.1:8501/")) return; + $("#connection-view").hidden = true; + $("#app-view").hidden = false; + document.body.classList.remove("is-connecting"); + document.body.classList.add("is-connected"); + document.title = "LeetTutor — JARVIS Learning System"; } -async function runCode() { - if (!state.connected) return testConnection(); - $("#run-status").textContent = t("running"); - $("#run-result").textContent = t("loading"); - await updateProgress("in_progress"); - try { - const payload = await localApi("/api/code/run", { - method: "POST", - body: JSON.stringify({ - source: $("#code").value, method_name: $("#method-name").value, - test_cases: $("#test-cases").value, timeout_seconds: 3, - }), - }); - state.lastRun = payload.result; - const result = payload.result; - $("#run-status").textContent = result.status === "passed" ? t("passed") : t("failedRun"); - $("#run-result").textContent = [ - result.summary, - ...(result.cases || []).map((item) => `#${item.case} ${item.passed ? "✓" : "✗"} actual=${JSON.stringify(item.actual)} expected=${JSON.stringify(item.expected)}`), - result.stdout || "", result.stderr || "", - ].filter(Boolean).join("\n"); - if (result.status === "passed") await updateProgress("mastered"); - } catch (error) { - $("#run-status").textContent = t("failedRun"); - $("#run-result").textContent = error.message; - } -} - -async function saveSource() { - if ($("#practice-mode").value === "system") { - localStorage.setItem(draftKey(), $("#code").value); - $("#progress-status").textContent = t("saved", { path: "browser local storage" }); - return; - } - const filename = $("#editor-filename").textContent; - try { - const payload = await localApi("/api/solutions/save", { - method: "POST", - body: JSON.stringify({ language: "Python", filename, content: $("#code").value, overwrite: false }), - }); - $("#progress-status").textContent = t("saved", { path: payload.path }); - } catch (error) { - $("#progress-status").textContent = `${t("saveFailed")} ${error.message}`; - } -} - -function appendMessage(role, message) { - const article = document.createElement("article"); article.className = `message ${role}`; - const badge = document.createElement("span"); badge.textContent = role === "assistant" ? "J" : "YOU"; - const paragraph = document.createElement("p"); paragraph.textContent = message; - article.append(badge, paragraph); $("#messages").append(article); - article.scrollIntoView({ behavior: "smooth", block: "nearest" }); - return article; -} - -async function askJarvis(message) { - if (!message.trim()) return; - if (!state.connected || !$("#model").value) { - appendMessage("assistant", t("failed")); - return; - } - appendMessage("user", message.trim()); - const pending = appendMessage("assistant", t("thinking")); - const coldStartTimer = window.setTimeout(() => { - pending.querySelector("p").textContent = t("coldStart"); - }, 12_000); - const mode = $("#practice-mode").value; - try { - const response = await companionFetch("/v1/chat/completions", { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: $("#model").value, stream: false, temperature: 0.2, top_p: 0.9, max_tokens: 512, - messages: [ - { role: "system", content: `You are JARVIS, a concise Socratic ${mode === "algorithm" ? "algorithm" : "system-design"} mentor. Reply in ${state.locale === "zh" ? "Chinese" : "English"}. Inspect the supplied work and test result. Give one specific observation, one short hint, and exactly one next question. Do not invent execution results.` }, - { role: "user", content: `Mission: ${$("#problem-title").textContent}\nRequirement: ${$("#problem-statement").textContent}\nObjective: ${$("#objective").textContent}\nCurrent work:\n${$("#code").value}\nLast local run: ${JSON.stringify(state.lastRun)}\n\nStudent: ${message.trim()}` }, - ], - }), - }, 180_000); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const payload = await response.json(); - window.clearTimeout(coldStartTimer); - pending.querySelector("p").textContent = payload.choices?.[0]?.message?.content || t("requestFailed"); - } catch (_error) { - window.clearTimeout(coldStartTimer); - pending.querySelector("p").textContent = t("requestFailed"); - } -} - -$("#language").addEventListener("change", (event) => { localStorage.setItem("leettutor-language", event.target.value); applyPreferences(); populateMissions(); }); -$("#theme").addEventListener("change", (event) => { localStorage.setItem("leettutor-theme", event.target.value); applyPreferences(); }); -matchMedia("(prefers-color-scheme: dark)").addEventListener("change", applyPreferences); -$("#model").addEventListener("change", (event) => localStorage.setItem("leettutor-model", event.target.value)); -$("#test-connection").addEventListener("click", testConnection); -$("#practice-mode").addEventListener("change", () => { state.imported = false; state.current = null; populateMissions(); }); -$("#problem-select").addEventListener("change", (event) => { - const items = $("#practice-mode").value === "algorithm" ? state.problems : state.systems; - state.current = items.find((item) => String(item.id) === event.target.value) || null; - state.imported = false; renderCurrent(); -}); -$("#import-problem").addEventListener("click", importProblem); -$("#run-code").addEventListener("click", runCode); -$("#save-source").addEventListener("click", saveSource); -$("#review-code").addEventListener("click", () => askJarvis(state.locale === "zh" ? "请根据当前真实代码和测试结果 Review,只提示下一步。" : "Review the current real code and test result, then hint only the next step.")); -$("#code").addEventListener("input", () => { if (state.current) localStorage.setItem(draftKey(), $("#code").value); }); -$("#chat-form").addEventListener("submit", (event) => { - event.preventDefault(); const field = $("#message"); - if (!field.value.trim()) { field.setCustomValidity(t("empty")); field.reportValidity(); field.setCustomValidity(""); return; } - askJarvis(field.value); field.value = ""; +document.addEventListener("DOMContentLoaded", () => { + applyPreferences(); + $("#language").addEventListener("change", (event) => { + localStorage.setItem("leettutor-language", event.target.value); + applyPreferences(); + }); + $("#theme").addEventListener("change", (event) => { + localStorage.setItem("leettutor-theme", event.target.value); + applyPreferences(); + }); + $("#connect-original").addEventListener("click", connectOriginal); + $("#original-app").addEventListener("load", revealOriginal); }); - -applyPreferences(); -if (!hostedPortfolio) testConnection(); diff --git a/web-demo/index.html b/web-demo/index.html index b18e085..0a2ef67 100644 --- a/web-demo/index.html +++ b/web-demo/index.html @@ -4,122 +4,56 @@ - - LeetTutor — Real Local JARVIS App + + LeetTutor — Original JARVIS App - -
- - LT - LeetTutorJARVIS Learning System - -
REAL LOCAL APP · NO SERVER RELAY
-
- - -
-
- -
-
-
-
-

SOURCE CURRICULUM

-

LeetTutor

-
- LOCAL -
- -
-
- - -
- -

- Open original problem ↗ - -

This round

-

-

+
+
+ + LT + LeetTutorJARVIS Learning System + +
+ + +
+
+ +
+
+

ORIGINAL APP · LOCAL EXECUTION

+

The original LeetTutor, in this tab.

+

No replacement dashboard. The hosted page opens the repository's real Streamlit product, including the floating JARVIS, original workspace, progress, editor, and model settings.

+
The app and model run only on this computer. tonytan.me does not receive code, prompts, progress, or answers.
+
-
- Import any LeetCode problem from this computer - - -

+
+
    +
  1. 1
    Start your model

    Open Ollama, LM Studio, or the configured AMD Metal runtime.

    ollama serve
  2. +
  3. 2
    Start the original app

    Double-click launch_companion.command, or run:

    python3 scripts/launch.py --hosted
  4. +
  5. 3
    Connect here

    Allow local-network access when Chrome asks. The original app then fills this tab.

  6. +
+ + Open the original app directly ↗ +

Waiting for the original app on this computer.

+
+ Chrome already blocked it? +

In the address bar, open Site controls → Site settings → Local network access → Allow, refresh, and connect again.

- -
-
Real constrained Python runnerREADY
- - - -

-          
- -
-

LOCAL SOURCE WORKSPACE

704.binary-search.py

DRAFT STAYS LOCAL
- - -
- Python 3 · local process -
- - -
-
-
- -
-
-

BROWSER APP · LOCAL BACKEND

Start the two local services, then do everything here.

This website is the complete UI. Python provides source-backed APIs and Ollama or LM Studio provides the model.

-
- - - - Open fallback UI ↗ -

Waiting for the Python backend and local model.

-
-
- One-time connection steps -
    -
  1. Start Ollama, then start the LeetTutor Python backend in the cloned repository.
  2. -
  3. ollama serve
  4. -
  5. python3 scripts/browser_bridge.py
  6. -
  7. Allow tonytan.me to access the local network when Chrome asks.
  8. -
  9. Return to this tab and choose “Connect and enter workspace.”
  10. -
- Get source and backend ↗ -
+ diff --git a/web-demo/styles.css b/web-demo/styles.css index a091bf8..6cc8e5e 100644 --- a/web-demo/styles.css +++ b/web-demo/styles.css @@ -1,159 +1,118 @@ :root { color-scheme: light; - --bg: #f3f5f8; - --panel: #fff; - --panel-2: #f7f8fa; - --text: #171b22; - --muted: #68707e; - --line: #dce1e8; - --accent: #f5ae2e; - --accent-strong: #d78300; - --blue: #2d64da; - --editor: #11161f; + --bg: #f4f7fb; + --panel: rgba(255, 255, 255, .92); + --panel-solid: #fff; + --text: #1c2633; + --muted: #667383; + --line: #dce4ec; + --blue: #45a7d8; + --cyan: #35c5db; + --navy: #0c334c; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } :root[data-theme="dark"] { color-scheme: dark; - --bg: #090d13; - --panel: #111720; - --panel-2: #171e29; - --text: #eef2f7; - --muted: #9ba7b7; - --line: #293342; - --accent: #ffc65c; - --accent-strong: #f3a718; - --blue: #77a0ff; - --editor: #080c12; + --bg: #08131d; + --panel: rgba(13, 29, 42, .94); + --panel-solid: #0d1d2a; + --text: #edf7fb; + --muted: #9cb0bf; + --line: #274154; + --blue: #5fc3ee; + --cyan: #4dd9e7; + --navy: #dff7ff; } * { box-sizing: border-box; } +html, body { min-height: 100%; } body { margin: 0; background: var(--bg); color: var(--text); } -button, input, select, textarea { font: inherit; } -button, select { cursor: pointer; } -.skip-link { position: fixed; top: 8px; left: 8px; z-index: 100; padding: 9px 12px; background: var(--text); color: var(--panel); transform: translateY(-160%); } -.skip-link:focus { transform: none; } -.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } +button, select { font: inherit; } +button, select, summary { cursor: pointer; } +[hidden] { display: none !important; } + +.connection-view { + min-height: 100vh; + background: + radial-gradient(circle at 18% 18%, color-mix(in srgb, var(--cyan) 14%, transparent), transparent 34%), + radial-gradient(circle at 85% 82%, color-mix(in srgb, var(--blue) 12%, transparent), transparent 32%), + var(--bg); +} .topbar { - display: grid; + display: flex; min-height: 72px; - grid-template-columns: 1fr auto 1fr; align-items: center; + justify-content: space-between; gap: 20px; - padding: 10px clamp(16px, 3vw, 42px); + padding: 10px clamp(18px, 4vw, 56px); border-bottom: 1px solid var(--line); - background: color-mix(in srgb, var(--panel) 92%, transparent); + background: color-mix(in srgb, var(--panel-solid) 86%, transparent); + backdrop-filter: blur(18px); } + .brand { display: flex; align-items: center; gap: 11px; color: inherit; text-decoration: none; } -.brand-mark { display: grid; width: 42px; height: 42px; place-items: center; border-radius: 12px; background: var(--accent); color: #18130a; font-weight: 900; } +.brand-mark { display: grid; width: 42px; height: 42px; place-items: center; border-radius: 50%; background: linear-gradient(145deg, var(--blue), var(--cyan)); color: white; font-weight: 900; box-shadow: 0 8px 24px color-mix(in srgb, var(--blue) 24%, transparent); } .brand > span:last-child { display: grid; } -.brand strong { font-size: .95rem; } -.brand small { color: var(--muted); font-size: .67rem; } -.privacy-pill { display: flex; align-items: center; gap: 8px; padding: 7px 12px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: .64rem; font-weight: 800; letter-spacing: .08em; } -.privacy-pill > span:first-child, .connection-dot { width: 8px; height: 8px; border-radius: 50%; background: #56bb72; box-shadow: 0 0 0 4px color-mix(in srgb, #56bb72 17%, transparent); } -.controls { display: flex; justify-content: end; gap: 9px; } +.brand strong { font-size: .96rem; letter-spacing: .01em; } +.brand small { color: var(--muted); font-size: .68rem; } +.controls { display: flex; gap: 10px; } .controls label { display: grid; gap: 3px; color: var(--muted); font-size: .58rem; font-weight: 800; text-transform: uppercase; } -.controls select { min-width: 92px; padding: 7px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel-2); color: var(--text); } +.controls select { min-width: 96px; padding: 7px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel-solid); color: var(--text); } -.workspace { display: grid; min-height: calc(100vh - 72px); grid-template-columns: minmax(260px, .9fr) minmax(380px, 1.3fr) minmax(300px, .92fr); border-bottom: 1px solid var(--line); } -.workspace > * { min-width: 0; border-right: 1px solid var(--line); background: var(--panel); } -.workspace > *:last-child { border-right: 0; } -.pane-title { display: flex; min-height: 78px; align-items: center; gap: 12px; padding: 14px 18px; border-bottom: 1px solid var(--line); } -.pane-title > span:first-child { color: var(--muted); font-weight: 800; } -.pane-title p, .mentor-header p, .connection-panel > div:first-child > p { margin: 0 0 3px; color: var(--accent-strong); font-size: .59rem; font-weight: 900; letter-spacing: .11em; } -.pane-title h1, .pane-title h2, .mentor-header h2 { margin: 0; font-size: 1.12rem; } -.difficulty, .saved { margin-left: auto; color: #358a56; font-size: .59rem; font-weight: 900; } -.problem-copy { padding: clamp(18px, 3vw, 36px); line-height: 1.65; } -.problem-heading { align-items: flex-start; } -.workspace-selectors { display: grid; grid-template-columns: minmax(130px, .7fr) minmax(180px, 1.3fr); gap: 10px; } -.workspace-selectors label, .runner-card label, .import-card label { display: grid; gap: 5px; color: var(--muted); font-size: .65rem; font-weight: 800; } -.workspace-selectors select, .runner-card input, .runner-card textarea, .import-card input, .connection-form select { - width: 100%; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); color: var(--text); padding: 9px 10px; +.launch-card { + display: grid; + width: min(1120px, calc(100% - 36px)); + min-height: 610px; + grid-template-columns: minmax(0, 1.05fr) minmax(340px, .95fr); + overflow: hidden; + margin: clamp(34px, 7vh, 84px) auto; + border: 1px solid var(--line); + border-radius: 28px; + background: var(--panel); + box-shadow: 0 26px 80px color-mix(in srgb, #06131d 14%, transparent); + backdrop-filter: blur(20px); } -.statement { min-height: 4.5em; white-space: pre-wrap; } -.problem-link { color: var(--blue); font-size: .76rem; font-weight: 800; } -.progress-status { color: var(--muted); font-size: .74rem; } -.import-card { margin-top: 22px; padding: 13px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel-2); } -.import-card summary { cursor: pointer; font-size: .75rem; font-weight: 850; } -.import-card label { margin: 12px 0 8px; } -.import-card p { margin-bottom: 0; color: var(--muted); font-size: .72rem; } -.problem-copy h2 { margin-top: 28px; font-size: .76rem; text-transform: uppercase; letter-spacing: .08em; } -code, pre { font-family: "SFMono-Regular", Consolas, monospace; } -pre { overflow: auto; padding: 15px; border-radius: 10px; background: var(--panel-2); font-size: .78rem; line-height: 1.7; } -.trace-card { margin-top: 28px; padding: 14px; border: 1px solid var(--line); border-radius: 13px; background: var(--panel-2); } -.trace-card header { display: flex; justify-content: space-between; gap: 14px; font-size: .72rem; } -.trace-card header span { color: var(--muted); } -.runner-card { display: grid; gap: 11px; } -.runner-card textarea { resize: vertical; font: 12px/1.55 "SFMono-Regular", Consolas, monospace; } -.runner-card > button { padding: 10px; border: 0; border-radius: 8px; background: var(--accent); color: #18130a; font-weight: 850; } -.run-result { min-height: 44px; margin: 0; padding: 10px; background: var(--editor); color: #dfe8f4; white-space: pre-wrap; } -.trace-steps { display: grid; gap: 7px; margin: 12px 0; } -.trace-steps p { display: flex; align-items: center; gap: 9px; margin: 0; font-size: .72rem; } -.trace-steps span { display: grid; width: 24px; height: 24px; place-items: center; border-radius: 50%; background: var(--accent); color: #17120b; font-weight: 900; } -.secondary { width: 100%; padding: 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); color: var(--text); font-weight: 750; } -.editor-pane { display: grid; grid-template-rows: auto 1fr auto; background: var(--editor); color: #e7edf5; } -.editor-pane .pane-title { border-color: #283140; } -.editor-pane .pane-title p { color: #82a7ff; } -.editor-pane .saved { color: #89d5a0; } -#code { width: 100%; min-height: 500px; resize: none; border: 0; outline: 0; padding: 28px; background: transparent; color: #e8eef6; font: 14px/1.75 "SFMono-Regular", Consolas, monospace; tab-size: 4; } -.editor-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-top: 1px solid #283140; color: #96a4b6; font-size: .7rem; } -.editor-footer button, .chat-form button, .connection-form button, .connection-form .local-open { padding: 10px 14px; border: 0; border-radius: 8px; background: var(--accent); color: #18130a; font-weight: 850; } -.connection-form .local-open { display: block; text-align: center; text-decoration: none; } -.editor-actions { display: flex; justify-content: flex-end; gap: 8px; } -.editor-actions .secondary { width: auto; border-color: #3a4658; background: transparent; color: #d7e0ec; } +.launch-copy, .launch-actions { padding: clamp(34px, 5vw, 68px); } +.launch-copy { display: flex; flex-direction: column; justify-content: center; border-right: 1px solid var(--line); } +.eyebrow { margin: 0 0 14px; color: var(--blue); font-size: .69rem; font-weight: 900; letter-spacing: .15em; } +h1 { max-width: 650px; margin: 0; color: var(--navy); font-size: clamp(2.5rem, 5.4vw, 5.6rem); line-height: .98; letter-spacing: -.06em; } +.intro { max-width: 620px; margin: 26px 0; color: var(--muted); font-size: clamp(.98rem, 1.5vw, 1.12rem); line-height: 1.75; } +.privacy-note { display: flex; align-items: flex-start; gap: 10px; max-width: 570px; padding: 14px 16px; border: 1px solid color-mix(in srgb, var(--blue) 30%, var(--line)); border-radius: 14px; background: color-mix(in srgb, var(--blue) 7%, var(--panel-solid)); color: var(--muted); font-size: .76rem; line-height: 1.55; } +.privacy-note > span:first-child { flex: 0 0 auto; width: 9px; height: 9px; margin-top: 4px; border-radius: 50%; background: #44b86d; box-shadow: 0 0 0 5px color-mix(in srgb, #44b86d 14%, transparent); } -.mentor-pane { display: grid; grid-template-rows: auto 1fr auto; } -.mentor-header { display: flex; min-height: 78px; align-items: center; gap: 11px; padding: 14px 17px; border-bottom: 1px solid var(--line); } -.jarvis-orb { display: grid; width: 39px; height: 39px; place-items: center; border: 1px solid var(--blue); border-radius: 50%; color: var(--blue); font-weight: 900; box-shadow: inset 0 0 14px color-mix(in srgb, var(--blue) 30%, transparent); } -.connection-dot { margin-left: auto; background: #8993a0; box-shadow: none; } -.connection-dot.is-live { background: #56bb72; box-shadow: 0 0 0 4px color-mix(in srgb, #56bb72 17%, transparent); } -.messages { overflow: auto; max-height: calc(100vh - 320px); padding: 18px; } -.message { display: grid; grid-template-columns: 27px 1fr; gap: 9px; margin-bottom: 13px; } -.message > span { display: grid; width: 27px; height: 27px; place-items: center; border-radius: 8px; background: var(--blue); color: white; font-size: .57rem; font-weight: 900; } -.message.user > span { background: var(--text); color: var(--panel); } -.message p { margin: 0; padding: 10px 12px; border-radius: 0 11px 11px; background: var(--panel-2); font-size: .78rem; line-height: 1.55; white-space: pre-wrap; } -.chat-form { display: grid; gap: 7px; padding: 14px; border-top: 1px solid var(--line); } -.chat-form label, .connection-form label { color: var(--muted); font-size: .65rem; font-weight: 800; } -.chat-form textarea, .connection-form input { width: 100%; border: 1px solid var(--line); border-radius: 9px; background: var(--panel-2); color: var(--text); padding: 10px; } -.connection-form input[readonly] { color: var(--muted); } -body.is-connected .privacy-pill > span:first-child { animation: local-pulse 1.8s ease-in-out infinite; } -@keyframes local-pulse { 50% { box-shadow: 0 0 0 8px color-mix(in srgb, #56bb72 8%, transparent); } } +.launch-actions { display: flex; flex-direction: column; justify-content: center; background: color-mix(in srgb, var(--panel-solid) 78%, transparent); } +.steps { display: grid; gap: 22px; margin: 0 0 28px; padding: 0; list-style: none; } +.steps li { display: grid; grid-template-columns: 34px 1fr; gap: 13px; } +.steps li > span { display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid color-mix(in srgb, var(--blue) 45%, var(--line)); border-radius: 50%; color: var(--blue); font-size: .78rem; font-weight: 900; } +.steps strong { display: block; color: var(--text); font-size: .9rem; } +.steps p { margin: 4px 0 8px; color: var(--muted); font-size: .76rem; line-height: 1.5; } +code { display: inline-block; max-width: 100%; overflow: auto; padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--text); font: .73rem/1.4 "SFMono-Regular", Consolas, monospace; white-space: nowrap; } -.connection-panel { display: grid; grid-template-columns: minmax(240px, 1fr) minmax(280px, .8fr) minmax(250px, .8fr); gap: clamp(24px, 4vw, 60px); padding: clamp(32px, 6vw, 82px); background: var(--panel); } -body:not(.is-connected) .workspace { display: none; } -body.is-connected .connection-panel { display: none; } -.connection-panel h2 { max-width: 620px; margin: 8px 0; font-size: clamp(1.7rem, 3vw, 3rem); letter-spacing: -.045em; } -.connection-panel p { color: var(--muted); line-height: 1.6; } -.connection-form { display: grid; align-content: start; gap: 10px; } -.connection-form label { display: grid; gap: 5px; } -.connection-form p { margin: 0; font-size: .72rem; } -.connection-panel details { align-self: start; padding: 15px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel-2); } -.connection-panel summary { cursor: pointer; font-weight: 850; } -.connection-panel li { margin: 9px 0; color: var(--muted); font-size: .76rem; line-height: 1.5; } -.connection-panel a { color: var(--blue); font-weight: 800; } +#connect-original, .fallback { display: block; width: 100%; padding: 13px 16px; border-radius: 10px; font-weight: 850; text-align: center; } +#connect-original { border: 0; background: linear-gradient(110deg, var(--blue), var(--cyan)); color: #fff; box-shadow: 0 12px 30px color-mix(in srgb, var(--blue) 24%, transparent); } +#connect-original:hover { filter: brightness(1.04); transform: translateY(-1px); } +#connect-original:disabled { cursor: wait; opacity: .72; transform: none; } +.fallback { margin-top: 9px; border: 1px solid var(--line); color: var(--text); text-decoration: none; } +.status { min-height: 1.3em; margin: 12px 0 0; color: var(--muted); font-size: .72rem; text-align: center; } +details { margin-top: 18px; color: var(--muted); font-size: .72rem; } +summary { color: var(--text); font-weight: 800; } +details p { margin: 8px 0 0; line-height: 1.55; } +.is-connecting .status { color: var(--blue); } -@media (max-width: 1080px) { - .topbar { grid-template-columns: 1fr auto; } - .privacy-pill { display: none; } - .workspace { grid-template-columns: 1fr 1fr; } - .mentor-pane { grid-column: 1 / -1; min-height: 560px; } - .messages { max-height: 360px; } - .connection-panel { grid-template-columns: 1fr 1fr; } - .connection-panel details { grid-column: 1 / -1; } -} +.app-view, #original-app { width: 100%; height: 100vh; height: 100dvh; } +.app-view { overflow: hidden; background: #fff; } +#original-app { display: block; border: 0; } -@media (max-width: 700px) { - .topbar { grid-template-columns: 1fr; } - .controls { justify-content: start; } - .workspace { display: block; } - .workspace > * { border-right: 0; border-bottom: 1px solid var(--line); } - .workspace-selectors { grid-template-columns: 1fr; } - #code { min-height: 430px; } - .editor-footer { align-items: flex-start; flex-direction: column; } - .editor-actions { width: 100%; flex-direction: column; } - .editor-actions button { width: 100%; } - .connection-panel { grid-template-columns: 1fr; padding: 30px 18px; } - .connection-panel details { grid-column: auto; } +@media (max-width: 820px) { + .topbar { align-items: flex-start; flex-direction: column; } + .controls { width: 100%; } + .controls label { flex: 1; } + .controls select { width: 100%; } + .launch-card { grid-template-columns: 1fr; margin: 20px auto; border-radius: 20px; } + .launch-copy { border-right: 0; border-bottom: 1px solid var(--line); } + .launch-copy, .launch-actions { padding: 30px 24px; } + h1 { font-size: clamp(2.5rem, 13vw, 4.3rem); } }