feat: add workspace-aware extension dashboard
Build / build (push) Successful in 12s

This commit is contained in:
2026-07-17 15:02:08 -05:00
parent 8351fc65f5
commit 85379f42f3
14 changed files with 1369 additions and 94 deletions
+3
View File
@@ -37,10 +37,13 @@ Use `.env.local` for local secrets. Keep `.env.local` and any real token-bearing
- `dist/` is the deployable output directory. Deployment or Chrome load-unpacked flows should point directly at `dist/`.
- `dist/` is generated output and should not be edited directly.
- Static extension assets live in `public/`; the current favicon source is `public/favicon.svg`.
- `scripts/generate-local-context.mjs` generates `public/local-context.json` from `/Users/Tabitha/.openclaw/workspace_Father` before builds.
- `public/local-context.json` is a committed fallback snapshot so CI builds can still produce a self-contained `dist/` if the local workspace root is unavailable.
- `npm run build` runs `scripts/verify-dist.mjs` after Vite to ensure the generated output has the required extension files.
- Host permissions are derived from `VITE_GITEA_BASE_URL`. Rebuild after changing the base URL.
- Runtime API calls are centralized in `src/api.ts`.
- Popup state is centralized in the vanilla Zustand store at `src/store.ts`.
- The popup UI is a movable drag/drop window with icon menu sections for URLs, Gitea repos, and workspaces. Add URL and Add Issue are intentionally disabled.
## Chrome Extension Management
+4
View File
@@ -26,6 +26,10 @@ The deployable extension output is `dist/`. Load `dist/` in Chrome as an unpacke
Static assets, including the favicon, live in `public/` and are copied into `dist/` during the build.
The app opens as a draggable window inside the extension popup. The top icon menu includes URLs, Gitea repos, and workspaces; Add URL and Add Issue are present but disabled for now.
Builds generate `public/local-context.json` from `/Users/Tabitha/.openclaw/workspace_Father`. That snapshot links known workspaces to local Gitea repos and is copied into `dist/` so the built extension is self-contained.
## Extension Management
See [docs/extension-management.md](docs/extension-management.md) before loading, reloading, testing, or automating the Chrome extension.
+38 -21
View File
@@ -7,33 +7,50 @@
<title>Silma AI Aide</title>
</head>
<body>
<main class="app-shell">
<header class="masthead">
<main class="desktop-canvas">
<section id="app-window" class="app-window" aria-label="Silma AI Aide window">
<header id="window-drag-handle" class="window-titlebar">
<div>
<p class="eyebrow">Gitea</p>
<h1>Silma AI Aide</h1>
<p class="eyebrow">Silma</p>
<h1>AI Aide</h1>
</div>
<span id="config-badge" class="badge">Checking</span>
</header>
<section class="panel">
<dl class="config-list">
<div>
<dt>Base URL</dt>
<dd id="base-url">Not configured</dd>
</div>
<div>
<dt>Token</dt>
<dd id="token-state">Not configured</dd>
</div>
<div>
<dt>Target repo</dt>
<dd id="target-repo">Not configured</dd>
</div>
</dl>
<nav class="toolbar" aria-label="Aide sections">
<button class="tool-button" type="button" data-action="add-url" disabled title="Add URL">
<i data-lucide="link"></i>
<span>Add URL</span>
</button>
<button class="tool-button" type="button" data-view="urls" title="URLs">
<i data-lucide="list"></i>
<span>URLs</span>
</button>
<button class="tool-button" type="button" data-view="repos" title="Gitea repos">
<i data-lucide="git-branch"></i>
<span>Gitea Repos</span>
</button>
<button class="tool-button" type="button" data-view="workspaces" title="Workspaces">
<i data-lucide="folder-git-2"></i>
<span>Workspaces</span>
</button>
<button class="tool-button" type="button" data-action="add-issue" disabled title="Add issue">
<i data-lucide="circle-plus"></i>
<span>Add Issue</span>
</button>
</nav>
<button id="test-connection" type="button">Test connection</button>
<p id="status" role="status">Ready.</p>
<section class="content-panel">
<div id="view-header" class="view-header"></div>
<div id="view-content" class="view-content"></div>
</section>
<footer class="statusbar">
<span id="status" role="status">Ready.</span>
<button id="refresh-data" class="icon-button" type="button" title="Refresh data">
<i data-lucide="refresh-cw"></i>
</button>
</footer>
</section>
</main>
<script type="module" src="/src/main.ts"></script>
+4
View File
@@ -14,9 +14,13 @@ Important files:
- `src/main.ts`: Popup UI behavior.
- `index.html`: Popup document.
- `public/favicon.svg`: Source favicon copied into `dist/` by Vite.
- `public/local-context.json`: Generated workspace/repo/URL context copied into `dist/`.
- `scripts/generate-local-context.mjs`: Scans `/Users/Tabitha/.openclaw/workspace_Father` and writes `public/local-context.json`.
Build with `npm run build`. The deployable output directory is `dist/`; load that folder in Chrome as an unpacked extension or point deployment tooling at it. Do not edit `dist/` directly.
UI behavior: the app renders as a movable drag/drop window inside the browser surface. The menu items are Add URL (disabled), URLs, Gitea Repos, Workspaces, and Add Issue (disabled). Gitea repos are ordered by latest default-branch commit. Workspace and URL views show established links between local workspaces, known URLs, and Gitea repos.
Extension management rule: use the Codex Chrome extension with the Chrome profile named `Silma`. Do not use `Profile 1`.
Codex commands:
+36
View File
@@ -15,10 +15,46 @@
"popup_script": "src/main.ts",
"state_store": "src/store.ts",
"api_client": "src/api.ts",
"local_context_types": "src/localContext.ts",
"vite_config": "vite.config.ts",
"favicon": "public/favicon.svg",
"local_context": "public/local-context.json",
"local_context_generator": "scripts/generate-local-context.mjs",
"generated_manifest": "dist/manifest.json"
},
"ui": {
"shell": "movable drag/drop window inside the browser surface",
"menu_items": [
{
"name": "Add URL",
"enabled": false
},
{
"name": "URLs",
"enabled": true
},
{
"name": "Gitea Repos",
"enabled": true
},
{
"name": "Workspaces",
"enabled": true
},
{
"name": "Add Issue",
"enabled": false
}
],
"repo_ordering": "Gitea repos are ordered by latest default-branch commit timestamp.",
"known_urls": [
{
"url": "http://100.66.226.22:23030/",
"workspace": "/Users/Tabitha/.openclaw/workspace_Father/workspace_Data",
"gitea_repo": "jacob-mathison/home-grown-llm-data"
}
]
},
"documentation": {
"agent_instructions": "AGENTS.md",
"llm_summary": "llm.txt",
+7
View File
@@ -11,6 +11,7 @@
"dependencies": {
"axios": "^1.18.1",
"dotenv": "^17.4.2",
"lucide": "^1.25.0",
"zustand": "^5.0.14"
},
"devDependencies": {
@@ -1375,6 +1376,12 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lucide": {
"version": "1.25.0",
"resolved": "https://registry.npmjs.org/lucide/-/lucide-1.25.0.tgz",
"integrity": "sha512-Pg/9Ga1xTbrnI6GY/7J9krIYmJIkAf3PkK6wPmsAeUtAQsBdGxs1+dfvLqjf9nPI5aFm9+VquT75JGpO3QXSpQ==",
"license": "ISC"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+2
View File
@@ -8,6 +8,7 @@
"codex:setup": "npm ci",
"dev": "vite --host 0.0.0.0",
"dev:codex": "npm run dev",
"prebuild": "node scripts/generate-local-context.mjs",
"build": "tsc --noEmit && vite build && npm run verify:dist",
"build:codex": "npm run build",
"preview": "vite preview --host 0.0.0.0",
@@ -22,6 +23,7 @@
"dependencies": {
"axios": "^1.18.1",
"dotenv": "^17.4.2",
"lucide": "^1.25.0",
"zustand": "^5.0.14"
},
"devDependencies": {
+306
View File
@@ -0,0 +1,306 @@
{
"generatedAt": "build-time-local-context",
"workspaceRoot": "/Users/Tabitha/.openclaw/workspace_Father",
"workspaces": [
{
"name": "aarete",
"path": "/Users/Tabitha/.openclaw/workspace_Father/aarete",
"linkedRepos": [
{
"worktreePath": "/Users/Tabitha/.openclaw/workspace_Father/aarete/workspace/aarete-doczyai-app",
"worktreeRelativePath": "aarete/workspace/aarete-doczyai-app",
"fullName": "jacob-mathison/aarete-doczyai-app",
"webUrl": "http://gitea.orson.tealthrone/jacob-mathison/aarete-doczyai-app"
},
{
"worktreePath": "/Users/Tabitha/.openclaw/workspace_Father/aarete/workspace/aarete-query-orchestration",
"worktreeRelativePath": "aarete/workspace/aarete-query-orchestration",
"fullName": "jacob-mathison/query-orchestration",
"webUrl": "http://gitea.orson.tealthrone/jacob-mathison/query-orchestration"
},
{
"worktreePath": "/Users/Tabitha/.openclaw/workspace_Father/aarete/workspace/doczyai-pipelines",
"worktreeRelativePath": "aarete/workspace/doczyai-pipelines",
"fullName": "jacob-mathison/doczyai-pipelines",
"webUrl": "http://gitea.orson.tealthrone/jacob-mathison/doczyai-pipelines"
}
]
},
{
"name": "aarete-unified",
"path": "/Users/Tabitha/.openclaw/workspace_Father/aarete-unified",
"linkedRepos": [
{
"worktreePath": "/Users/Tabitha/.openclaw/workspace_Father/aarete-unified",
"worktreeRelativePath": "aarete-unified",
"fullName": "jacob-mathison/aarete-doczyai-unified",
"webUrl": "http://gitea.orson.tealthrone/jacob-mathison/aarete-doczyai-unified"
}
]
},
{
"name": "banner-and-pyre",
"path": "/Users/Tabitha/.openclaw/workspace_Father/banner-and-pyre",
"linkedRepos": [
{
"worktreePath": "/Users/Tabitha/.openclaw/workspace_Father/banner-and-pyre/mp-banner-and-pyre",
"worktreeRelativePath": "banner-and-pyre/mp-banner-and-pyre",
"fullName": "jacob-mathison/mp-banner-and-pyre",
"webUrl": "http://gitea.orson.tealthrone/jacob-mathison/mp-banner-and-pyre"
},
{
"worktreePath": "/Users/Tabitha/.openclaw/workspace_Father/banner-and-pyre/mp-banner-and-pyre-migration",
"worktreeRelativePath": "banner-and-pyre/mp-banner-and-pyre-migration",
"fullName": "jacob-mathison/mp-banner-and-pyre-migration",
"webUrl": "http://gitea.orson.tealthrone/jacob-mathison/mp-banner-and-pyre-migration"
},
{
"worktreePath": "/Users/Tabitha/.openclaw/workspace_Father/banner-and-pyre/mp-banner-and-pyre-web",
"worktreeRelativePath": "banner-and-pyre/mp-banner-and-pyre-web",
"fullName": "jacob-mathison/mp-banner-and-pyre-web",
"webUrl": "http://gitea.orson.tealthrone/jacob-mathison/mp-banner-and-pyre-web"
}
]
},
{
"name": "blb-throne-of-the-bone-king",
"path": "/Users/Tabitha/.openclaw/workspace_Father/blb-throne-of-the-bone-king",
"linkedRepos": [
{
"worktreePath": "/Users/Tabitha/.openclaw/workspace_Father/blb-throne-of-the-bone-king",
"worktreeRelativePath": "blb-throne-of-the-bone-king",
"fullName": "jacob-mathison/blb-throne-of-the-bone-king",
"webUrl": "http://gitea.orson.tealthrone/jacob-mathison/blb-throne-of-the-bone-king"
}
]
},
{
"name": "midnights-eclipse-migration",
"path": "/Users/Tabitha/.openclaw/workspace_Father/midnights-eclipse-migration",
"linkedRepos": [
{
"worktreePath": "/Users/Tabitha/.openclaw/workspace_Father/midnights-eclipse-migration",
"worktreeRelativePath": "midnights-eclipse-migration",
"fullName": "jacob-mathison/midnights-eclipse-migration",
"webUrl": "http://gitea.orson.tealthrone/jacob-mathison/midnights-eclipse-migration"
}
]
},
{
"name": "mp-silma-ai-aide",
"path": "/Users/Tabitha/.openclaw/workspace_Father/mp-silma-ai-aide",
"linkedRepos": [
{
"worktreePath": "/Users/Tabitha/.openclaw/workspace_Father/mp-silma-ai-aide",
"worktreeRelativePath": "mp-silma-ai-aide",
"fullName": "jacob-mathison/mp-silma-ai-aide",
"webUrl": "http://gitea.orson.tealthrone/jacob-mathison/mp-silma-ai-aide"
}
]
},
{
"name": "tuono",
"path": "/Users/Tabitha/.openclaw/workspace_Father/tuono",
"linkedRepos": []
},
{
"name": "workspace_Data",
"path": "/Users/Tabitha/.openclaw/workspace_Father/workspace_Data",
"linkedRepos": [
{
"worktreePath": "/Users/Tabitha/.openclaw/workspace_Father/workspace_Data",
"worktreeRelativePath": "workspace_Data",
"fullName": "jacob-mathison/home-grown-llm-data",
"webUrl": "http://gitea.orson.tealthrone/jacob-mathison/home-grown-llm-data"
}
]
}
],
"gitWorktrees": [
{
"name": "aarete-unified",
"path": "/Users/Tabitha/.openclaw/workspace_Father/aarete-unified",
"relativePath": "aarete-unified",
"topWorkspaceName": "aarete-unified",
"branch": "main",
"giteaRepo": {
"fullName": "jacob-mathison/aarete-doczyai-unified",
"owner": "jacob-mathison",
"name": "aarete-doczyai-unified",
"webUrl": "http://gitea.orson.tealthrone/jacob-mathison/aarete-doczyai-unified",
"cloneUrl": "http://100.79.253.19:3000/jacob-mathison/aarete-doczyai-unified.git"
}
},
{
"name": "automations",
"path": "/Users/Tabitha/.openclaw/workspace_Father/aarete/automations",
"relativePath": "aarete/automations",
"topWorkspaceName": "aarete",
"branch": "main",
"giteaRepo": null
},
{
"name": "aarete-doczyai-app",
"path": "/Users/Tabitha/.openclaw/workspace_Father/aarete/workspace/aarete-doczyai-app",
"relativePath": "aarete/workspace/aarete-doczyai-app",
"topWorkspaceName": "aarete",
"branch": "main",
"giteaRepo": {
"fullName": "jacob-mathison/aarete-doczyai-app",
"owner": "jacob-mathison",
"name": "aarete-doczyai-app",
"webUrl": "http://gitea.orson.tealthrone/jacob-mathison/aarete-doczyai-app",
"cloneUrl": "http://100.79.253.19:3000/jacob-mathison/aarete-doczyai-app.git"
}
},
{
"name": "aarete-query-orchestration",
"path": "/Users/Tabitha/.openclaw/workspace_Father/aarete/workspace/aarete-query-orchestration",
"relativePath": "aarete/workspace/aarete-query-orchestration",
"topWorkspaceName": "aarete",
"branch": "main",
"giteaRepo": {
"fullName": "jacob-mathison/query-orchestration",
"owner": "jacob-mathison",
"name": "query-orchestration",
"webUrl": "http://gitea.orson.tealthrone/jacob-mathison/query-orchestration",
"cloneUrl": "http://100.79.253.19:3000/jacob-mathison/query-orchestration.git"
}
},
{
"name": "doczyai-pipelines",
"path": "/Users/Tabitha/.openclaw/workspace_Father/aarete/workspace/doczyai-pipelines",
"relativePath": "aarete/workspace/doczyai-pipelines",
"topWorkspaceName": "aarete",
"branch": "main",
"giteaRepo": {
"fullName": "jacob-mathison/doczyai-pipelines",
"owner": "jacob-mathison",
"name": "doczyai-pipelines",
"webUrl": "http://gitea.orson.tealthrone/jacob-mathison/doczyai-pipelines",
"cloneUrl": "http://100.79.253.19:3000/jacob-mathison/doczyai-pipelines.git"
}
},
{
"name": "mp-banner-and-pyre",
"path": "/Users/Tabitha/.openclaw/workspace_Father/banner-and-pyre/mp-banner-and-pyre",
"relativePath": "banner-and-pyre/mp-banner-and-pyre",
"topWorkspaceName": "banner-and-pyre",
"branch": "main",
"giteaRepo": {
"fullName": "jacob-mathison/mp-banner-and-pyre",
"owner": "jacob-mathison",
"name": "mp-banner-and-pyre",
"webUrl": "http://gitea.orson.tealthrone/jacob-mathison/mp-banner-and-pyre",
"cloneUrl": "http://100.79.253.19:3000/jacob-mathison/mp-banner-and-pyre.git"
}
},
{
"name": "mp-banner-and-pyre-migration",
"path": "/Users/Tabitha/.openclaw/workspace_Father/banner-and-pyre/mp-banner-and-pyre-migration",
"relativePath": "banner-and-pyre/mp-banner-and-pyre-migration",
"topWorkspaceName": "banner-and-pyre",
"branch": "master",
"giteaRepo": {
"fullName": "jacob-mathison/mp-banner-and-pyre-migration",
"owner": "jacob-mathison",
"name": "mp-banner-and-pyre-migration",
"webUrl": "http://gitea.orson.tealthrone/jacob-mathison/mp-banner-and-pyre-migration",
"cloneUrl": "http://100.79.253.19:3000/jacob-mathison/mp-banner-and-pyre-migration.git"
}
},
{
"name": "mp-banner-and-pyre-web",
"path": "/Users/Tabitha/.openclaw/workspace_Father/banner-and-pyre/mp-banner-and-pyre-web",
"relativePath": "banner-and-pyre/mp-banner-and-pyre-web",
"topWorkspaceName": "banner-and-pyre",
"branch": "main",
"giteaRepo": {
"fullName": "jacob-mathison/mp-banner-and-pyre-web",
"owner": "jacob-mathison",
"name": "mp-banner-and-pyre-web",
"webUrl": "http://gitea.orson.tealthrone/jacob-mathison/mp-banner-and-pyre-web",
"cloneUrl": "http://100.79.253.19:3000/jacob-mathison/mp-banner-and-pyre-web.git"
}
},
{
"name": "blb-throne-of-the-bone-king",
"path": "/Users/Tabitha/.openclaw/workspace_Father/blb-throne-of-the-bone-king",
"relativePath": "blb-throne-of-the-bone-king",
"topWorkspaceName": "blb-throne-of-the-bone-king",
"branch": "main",
"giteaRepo": {
"fullName": "jacob-mathison/blb-throne-of-the-bone-king",
"owner": "jacob-mathison",
"name": "blb-throne-of-the-bone-king",
"webUrl": "http://gitea.orson.tealthrone/jacob-mathison/blb-throne-of-the-bone-king",
"cloneUrl": "http://100.79.253.19:3000/jacob-mathison/blb-throne-of-the-bone-king.git"
}
},
{
"name": "midnights-eclipse-migration",
"path": "/Users/Tabitha/.openclaw/workspace_Father/midnights-eclipse-migration",
"relativePath": "midnights-eclipse-migration",
"topWorkspaceName": "midnights-eclipse-migration",
"branch": "main",
"giteaRepo": {
"fullName": "jacob-mathison/midnights-eclipse-migration",
"owner": "jacob-mathison",
"name": "midnights-eclipse-migration",
"webUrl": "http://gitea.orson.tealthrone/jacob-mathison/midnights-eclipse-migration",
"cloneUrl": "http://100.79.253.19:3000/jacob-mathison/midnights-eclipse-migration.git"
}
},
{
"name": "mp-silma-ai-aide",
"path": "/Users/Tabitha/.openclaw/workspace_Father/mp-silma-ai-aide",
"relativePath": "mp-silma-ai-aide",
"topWorkspaceName": "mp-silma-ai-aide",
"branch": "main",
"giteaRepo": {
"fullName": "jacob-mathison/mp-silma-ai-aide",
"owner": "jacob-mathison",
"name": "mp-silma-ai-aide",
"webUrl": "http://gitea.orson.tealthrone/jacob-mathison/mp-silma-ai-aide",
"cloneUrl": "http://100.79.253.19:3000/jacob-mathison/mp-silma-ai-aide.git"
}
},
{
"name": "tuono",
"path": "/Users/Tabitha/.openclaw/workspace_Father/tuono",
"relativePath": "tuono",
"topWorkspaceName": "tuono",
"branch": "main",
"giteaRepo": null
},
{
"name": "workspace_Data",
"path": "/Users/Tabitha/.openclaw/workspace_Father/workspace_Data",
"relativePath": "workspace_Data",
"topWorkspaceName": "workspace_Data",
"branch": "main",
"giteaRepo": {
"fullName": "jacob-mathison/home-grown-llm-data",
"owner": "jacob-mathison",
"name": "home-grown-llm-data",
"webUrl": "http://gitea.orson.tealthrone/jacob-mathison/home-grown-llm-data",
"cloneUrl": "http://100.79.253.19:3000/jacob-mathison/home-grown-llm-data.git"
}
}
],
"knownUrls": [
{
"label": "Automated Training Monitor",
"url": "http://100.66.226.22:23030/",
"workspacePath": "/Users/Tabitha/.openclaw/workspace_Father/workspace_Data",
"workspaceName": "workspace_Data",
"giteaRepo": {
"fullName": "jacob-mathison/home-grown-llm-data",
"owner": "jacob-mathison",
"name": "home-grown-llm-data",
"webUrl": "http://gitea.orson.tealthrone/jacob-mathison/home-grown-llm-data",
"cloneUrl": "http://100.79.253.19:3000/jacob-mathison/home-grown-llm-data.git"
}
}
]
}
+158
View File
@@ -0,0 +1,158 @@
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
import { basename, join, relative } from "node:path";
import { execFileSync } from "node:child_process";
const workspaceRoot =
process.env.WORKSPACE_FATHER_PATH || "/Users/Tabitha/.openclaw/workspace_Father";
const outputPath = join(process.cwd(), "public", "local-context.json");
const giteaBaseUrl = "http://gitea.orson.tealthrone";
function runGit(args, cwd) {
try {
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
} catch {
return "";
}
}
function findGitWorktrees(root, maxDepth = 4) {
const found = [];
function walk(dir, depth) {
if (depth > maxDepth) {
return;
}
if (existsSync(join(dir, ".git"))) {
found.push(dir);
return;
}
let entries = [];
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === "node_modules" || entry.name === ".git") {
continue;
}
walk(join(dir, entry.name), depth + 1);
}
}
walk(root, 0);
return found.sort();
}
function normalizeGiteaRemote(remoteUrl) {
if (!remoteUrl) {
return null;
}
const match = remoteUrl.match(
/(?:gitea\.orson\.tealthrone|100\.79\.253\.19(?::3000)?)[/:]([^/]+)\/([^/.]+)(?:\.git)?$/
);
if (!match) {
return null;
}
const [, owner, name] = match;
return {
fullName: `${owner}/${name}`,
owner,
name,
webUrl: `${giteaBaseUrl}/${owner}/${name}`,
cloneUrl: `http://100.79.253.19:3000/${owner}/${name}.git`
};
}
function repoForWorktree(path) {
const remoteNames = runGit(["remote"], path).split(/\s+/).filter(Boolean);
const orderedNames = ["gitea", "hagia", "origin", ...remoteNames].filter((name, index, names) => {
return remoteNames.includes(name) && names.indexOf(name) === index;
});
for (const remoteName of orderedNames) {
const repo = normalizeGiteaRemote(runGit(["remote", "get-url", remoteName], path));
if (repo) {
return repo;
}
}
return null;
}
function buildContext() {
const topLevelWorkspaces = readdirSync(workspaceRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => ({
name: entry.name,
path: join(workspaceRoot, entry.name)
}))
.sort((a, b) => a.name.localeCompare(b.name));
const gitWorktrees = findGitWorktrees(workspaceRoot).map((path) => {
const repo = repoForWorktree(path);
const topWorkspaceName = relative(workspaceRoot, path).split("/")[0] || basename(path);
return {
name: basename(path),
path,
relativePath: relative(workspaceRoot, path),
topWorkspaceName,
branch: runGit(["branch", "--show-current"], path) || null,
giteaRepo: repo
};
});
const workspaces = topLevelWorkspaces.map((workspace) => ({
...workspace,
linkedRepos: gitWorktrees
.filter((worktree) => worktree.topWorkspaceName === workspace.name && worktree.giteaRepo)
.map((worktree) => ({
worktreePath: worktree.path,
worktreeRelativePath: worktree.relativePath,
fullName: worktree.giteaRepo.fullName,
webUrl: worktree.giteaRepo.webUrl
}))
}));
const workspaceData = gitWorktrees.find(
(worktree) => worktree.path === join(workspaceRoot, "workspace_Data")
);
return {
generatedAt: "build-time-local-context",
workspaceRoot,
workspaces,
gitWorktrees,
knownUrls: [
{
label: "Automated Training Monitor",
url: "http://100.66.226.22:23030/",
workspacePath: join(workspaceRoot, "workspace_Data"),
workspaceName: "workspace_Data",
giteaRepo: workspaceData?.giteaRepo ?? {
fullName: "jacob-mathison/home-grown-llm-data",
webUrl: `${giteaBaseUrl}/jacob-mathison/home-grown-llm-data`
}
}
]
};
}
mkdirSync(join(process.cwd(), "public"), { recursive: true });
if (!existsSync(workspaceRoot)) {
if (existsSync(outputPath)) {
console.log(`Workspace root missing; preserving ${outputPath}`);
process.exit(0);
}
throw new Error(`Workspace root does not exist: ${workspaceRoot}`);
}
const context = buildContext();
writeFileSync(outputPath, `${JSON.stringify(context, null, 2)}\n`);
console.log(`Wrote ${outputPath} with ${context.workspaces.length} workspaces`);
+118 -4
View File
@@ -12,6 +12,46 @@ export interface GiteaUser {
email?: string;
}
interface GiteaRepoOwner {
login: string;
}
interface GiteaRepoResponse {
name: string;
full_name?: string;
description?: string;
html_url?: string;
clone_url?: string;
ssh_url?: string;
updated_at?: string;
default_branch?: string;
private?: boolean;
owner?: GiteaRepoOwner;
}
interface GiteaBranchResponse {
name: string;
commit?: {
id?: string;
message?: string;
timestamp?: string;
url?: string;
};
}
export interface GiteaRepoSummary {
name: string;
fullName: string;
description: string;
webUrl: string;
cloneUrl: string;
defaultBranch: string;
isPrivate: boolean;
lastCommitAt: string;
lastCommitSha: string;
lastCommitMessage: string;
}
export const giteaConfig = {
baseUrl: rawGiteaBaseUrl,
hasToken: giteaToken.length > 0,
@@ -24,16 +64,34 @@ export const giteaConfig = {
isConfigured: rawGiteaBaseUrl.length > 0 && giteaToken.length > 0
};
function getApiBaseUrl(baseUrl: string): string {
export function getApiBaseUrl(baseUrl: string): string {
const normalized = baseUrl.replace(/\/+$/, "");
return normalized.endsWith("/api/v1") ? normalized : `${normalized}/api/v1`;
}
export async function getCurrentGiteaUser(): Promise<GiteaUser> {
function assertGiteaConfigured(): void {
if (!giteaConfig.isConfigured) {
throw new Error("Set VITE_GITEA_BASE_URL and VITE_GITEA_TOKEN before testing the connection.");
}
}
function createGiteaClient() {
assertGiteaConfigured();
return axios.create({
baseURL: getApiBaseUrl(giteaConfig.baseUrl),
headers: {
Authorization: `token ${giteaToken}`
}
});
}
export async function getCurrentGiteaUser(): Promise<GiteaUser> {
const client = createGiteaClient();
const response = await client.get<GiteaUser>("/user");
return response.data;
}
export async function getReposOrderedByLastCommit(): Promise<GiteaRepoSummary[]> {
const client = axios.create({
baseURL: getApiBaseUrl(giteaConfig.baseUrl),
headers: {
@@ -41,6 +99,62 @@ export async function getCurrentGiteaUser(): Promise<GiteaUser> {
}
});
const response = await client.get<GiteaUser>("/user");
return response.data;
assertGiteaConfigured();
const repos: GiteaRepoResponse[] = [];
let page = 1;
while (page <= 10) {
const response = await client.get<GiteaRepoResponse[]>("/user/repos", {
params: {
limit: 50,
page
}
});
repos.push(...response.data);
if (response.data.length < 50) {
break;
}
page += 1;
}
const repoDetails = await Promise.all(
repos.map(async (repo): Promise<GiteaRepoSummary> => {
const owner = repo.owner?.login || giteaConfig.repoOwner;
const defaultBranch = repo.default_branch || "main";
const fullName = repo.full_name || `${owner}/${repo.name}`;
let branch: GiteaBranchResponse | null = null;
try {
const branchResponse = await client.get<GiteaBranchResponse>(
`/repos/${owner}/${repo.name}/branches/${defaultBranch}`
);
branch = branchResponse.data;
} catch {
branch = null;
}
return {
name: repo.name,
fullName,
description: repo.description || "",
webUrl: repo.html_url || `${giteaConfig.baseUrl.replace(/\/+$/, "")}/${fullName}`,
cloneUrl: repo.clone_url || "",
defaultBranch,
isPrivate: Boolean(repo.private),
lastCommitAt: branch?.commit?.timestamp || repo.updated_at || "",
lastCommitSha: branch?.commit?.id || "",
lastCommitMessage: branch?.commit?.message?.split("\n")[0] || "No commit message available"
};
})
);
return repoDetails.sort((a, b) => {
const aTime = a.lastCommitAt ? Date.parse(a.lastCommitAt) : 0;
const bTime = b.lastCommitAt ? Date.parse(b.lastCommitAt) : 0;
return bTime - aTime || a.fullName.localeCompare(b.fullName);
});
}
+50
View File
@@ -0,0 +1,50 @@
export interface LocalGiteaRepoLink {
fullName: string;
webUrl: string;
}
export interface WorkspaceRepoLink extends LocalGiteaRepoLink {
worktreePath: string;
worktreeRelativePath: string;
}
export interface LocalWorkspace {
name: string;
path: string;
linkedRepos: WorkspaceRepoLink[];
}
export interface LocalGitWorktree {
name: string;
path: string;
relativePath: string;
topWorkspaceName: string;
branch: string | null;
giteaRepo: LocalGiteaRepoLink | null;
}
export interface KnownUrl {
label: string;
url: string;
workspaceName: string;
workspacePath: string;
giteaRepo: LocalGiteaRepoLink | null;
}
export interface LocalContext {
generatedAt: string;
workspaceRoot: string;
workspaces: LocalWorkspace[];
gitWorktrees: LocalGitWorktree[];
knownUrls: KnownUrl[];
}
export async function loadLocalContext(): Promise<LocalContext> {
const response = await fetch("/local-context.json", { cache: "no-store" });
if (!response.ok) {
throw new Error(`Could not load local context (${response.status}). Run npm run build.`);
}
return response.json() as Promise<LocalContext>;
}
+286 -20
View File
@@ -1,29 +1,222 @@
import { createIcons, CirclePlus, FolderGit2, GitBranch, Link, List, RefreshCw } from "lucide";
import "./styles.css";
import { appStore } from "./store";
import { appStore, type AppView } from "./store";
import type { GiteaRepoSummary } from "./api";
import type { KnownUrl, LocalWorkspace } from "./localContext";
const appWindow = document.querySelector<HTMLElement>("#app-window");
const dragHandle = document.querySelector<HTMLElement>("#window-drag-handle");
const configBadge = document.querySelector<HTMLSpanElement>("#config-badge");
const baseUrl = document.querySelector<HTMLElement>("#base-url");
const tokenState = document.querySelector<HTMLElement>("#token-state");
const targetRepo = document.querySelector<HTMLElement>("#target-repo");
const status = document.querySelector<HTMLElement>("#status");
const testConnection = document.querySelector<HTMLButtonElement>("#test-connection");
const refreshData = document.querySelector<HTMLButtonElement>("#refresh-data");
const viewHeader = document.querySelector<HTMLElement>("#view-header");
const viewContent = document.querySelector<HTMLElement>("#view-content");
const viewButtons = Array.from(document.querySelectorAll<HTMLButtonElement>("[data-view]"));
function setText(element: HTMLElement | null, text: string): void {
if (element) {
element.textContent = text;
createIcons({
icons: {
CirclePlus,
FolderGit2,
GitBranch,
Link,
List,
RefreshCw
}
});
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (char) => {
const replacements: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;"
};
return replacements[char];
});
}
function render(): void {
function formatDate(value: string): string {
if (!value) {
return "No commit date";
}
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short"
}).format(new Date(value));
}
function formatSha(value: string): string {
return value ? value.slice(0, 8) : "unknown";
}
function getRepoWorkspaceLink(repo: GiteaRepoSummary): string {
const context = appStore.getState().localContext;
const worktree = context?.gitWorktrees.find((item) => item.giteaRepo?.fullName === repo.fullName);
if (!worktree) {
return `<span class="muted">No workspace link</span>`;
}
return `<span class="link-chip">Workspace link</span><span class="path-text">${escapeHtml(worktree.path)}</span>`;
}
function renderRepos(repos: GiteaRepoSummary[]): string {
if (!repos.length) {
return `<div class="empty-state">No Gitea repos loaded yet.</div>`;
}
return repos
.map(
(repo) => `
<article class="item-card">
<div class="item-heading">
<div>
<a class="item-title" href="${repo.webUrl}" target="_blank" rel="noreferrer">${escapeHtml(repo.fullName)}</a>
<p class="item-subtitle">${escapeHtml(repo.description || "No description")}</p>
</div>
<span class="badge quiet">${repo.isPrivate ? "Private" : "Public"}</span>
</div>
<div class="meta-grid">
<span>Branch: <strong>${escapeHtml(repo.defaultBranch)}</strong></span>
<span>Last commit: <strong>${formatDate(repo.lastCommitAt)}</strong></span>
<span>SHA: <strong>${formatSha(repo.lastCommitSha)}</strong></span>
</div>
<p class="commit-message">${escapeHtml(repo.lastCommitMessage)}</p>
<div class="linked-row">${getRepoWorkspaceLink(repo)}</div>
</article>
`
)
.join("");
}
function renderWorkspaces(workspaces: LocalWorkspace[]): string {
if (!workspaces.length) {
return `<div class="empty-state">No workspaces found in workspace_Father.</div>`;
}
return workspaces
.map((workspace) => {
const links = workspace.linkedRepos.length
? workspace.linkedRepos
.map(
(repo) =>
`<a class="repo-link" href="${repo.webUrl}" target="_blank" rel="noreferrer">${escapeHtml(repo.fullName)}</a>`
)
.join("")
: `<span class="muted">No Gitea repo link established</span>`;
return `
<article class="item-card">
<div class="item-heading">
<div>
<h2 class="item-title">${escapeHtml(workspace.name)}</h2>
<p class="path-text">${escapeHtml(workspace.path)}</p>
</div>
${workspace.linkedRepos.length ? `<span class="link-chip">Linked</span>` : ""}
</div>
<div class="repo-link-list">${links}</div>
</article>
`;
})
.join("");
}
function renderUrls(urls: KnownUrl[]): string {
if (!urls.length) {
return `<div class="empty-state">No known URLs registered.</div>`;
}
return urls
.map(
(knownUrl) => `
<article class="item-card">
<div class="item-heading">
<div>
<h2 class="item-title">${escapeHtml(knownUrl.label)}</h2>
<a class="url-text" href="${knownUrl.url}" target="_blank" rel="noreferrer">${escapeHtml(knownUrl.url)}</a>
</div>
<button class="visit-button" type="button" data-open-url="${knownUrl.url}">Quick visit</button>
</div>
<div class="linked-row">
<span class="link-chip">Workspace link</span>
<span class="path-text">${escapeHtml(knownUrl.workspacePath)}</span>
</div>
<div class="linked-row">
<span class="link-chip">Gitea link</span>
${
knownUrl.giteaRepo
? `<a class="repo-link" href="${knownUrl.giteaRepo.webUrl}" target="_blank" rel="noreferrer">${escapeHtml(knownUrl.giteaRepo.fullName)}</a>`
: `<span class="muted">No Gitea repo link established</span>`
}
</div>
</article>
`
)
.join("");
}
function openUrl(url: string): void {
if (globalThis.chrome?.tabs?.create) {
void globalThis.chrome.tabs.create({ url });
return;
}
window.open(url, "_blank", "noopener,noreferrer");
}
function renderView(): void {
if (!viewHeader || !viewContent) {
return;
}
const state = appStore.getState();
const context = state.localContext;
const titles: Record<AppView, string> = {
urls: "URLs",
repos: "Gitea repos",
workspaces: "Workspaces"
};
const descriptions: Record<AppView, string> = {
urls: "Known browser targets and their workspace/repo links.",
repos: "Repositories ordered by latest default-branch commit.",
workspaces: `Authoritative workspaces from ${context?.workspaceRoot || "workspace_Father"}.`
};
viewHeader.innerHTML = `
<div>
<h2>${titles[state.activeView]}</h2>
<p>${escapeHtml(descriptions[state.activeView])}</p>
</div>
`;
if (state.activeView === "repos") {
viewContent.innerHTML = renderRepos(state.repos);
} else if (state.activeView === "workspaces") {
viewContent.innerHTML = renderWorkspaces(context?.workspaces ?? []);
} else {
viewContent.innerHTML = renderUrls(context?.knownUrls ?? []);
}
viewContent.querySelectorAll<HTMLButtonElement>("[data-open-url]").forEach((button) => {
button.addEventListener("click", () => {
const url = button.dataset.openUrl;
if (url) {
openUrl(url);
}
});
});
}
function renderChrome(): void {
const state = appStore.getState();
const config = state.config;
setText(baseUrl, config.baseUrl || "Not configured");
setText(tokenState, config.hasToken ? "Configured" : "Not configured");
setText(targetRepo, config.targetRepo || "Not configured");
setText(configBadge, config.isConfigured ? "Configured" : "Missing env");
if (configBadge) {
configBadge.textContent = config.isConfigured ? "Configured" : "Missing env";
configBadge.dataset.tone = config.isConfigured ? "success" : "warning";
}
@@ -32,14 +225,87 @@ function render(): void {
status.dataset.tone = state.statusTone;
}
if (testConnection) {
testConnection.disabled = state.isTestingConnection;
if (refreshData) {
refreshData.disabled = state.isLoadingData;
}
if (appWindow) {
appWindow.style.transform = `translate(${state.windowPosition.x}px, ${state.windowPosition.y}px)`;
}
viewButtons.forEach((button) => {
const view = button.dataset.view;
button.dataset.active = view === state.activeView ? "true" : "false";
});
}
function render(): void {
renderChrome();
renderView();
}
function setupDragging(): void {
if (!appWindow || !dragHandle) {
return;
}
let dragStart:
| {
pointerId: number;
pointerX: number;
pointerY: number;
windowX: number;
windowY: number;
}
| null = null;
dragHandle.addEventListener("pointerdown", (event) => {
const state = appStore.getState();
dragStart = {
pointerId: event.pointerId,
pointerX: event.clientX,
pointerY: event.clientY,
windowX: state.windowPosition.x,
windowY: state.windowPosition.y
};
dragHandle.setPointerCapture(event.pointerId);
});
dragHandle.addEventListener("pointermove", (event) => {
if (!dragStart || event.pointerId !== dragStart.pointerId) {
return;
}
const rect = appWindow.getBoundingClientRect();
const maxX = Math.max(0, window.innerWidth - rect.width);
const maxY = Math.max(0, window.innerHeight - rect.height);
const x = Math.min(Math.max(0, dragStart.windowX + event.clientX - dragStart.pointerX), maxX);
const y = Math.min(Math.max(0, dragStart.windowY + event.clientY - dragStart.pointerY), maxY);
appStore.getState().setWindowPosition({ x, y });
});
dragHandle.addEventListener("pointerup", (event) => {
if (dragStart?.pointerId === event.pointerId) {
dragStart = null;
dragHandle.releasePointerCapture(event.pointerId);
}
});
}
viewButtons.forEach((button) => {
button.addEventListener("click", () => {
const view = button.dataset.view as AppView | undefined;
if (view) {
appStore.getState().setActiveView(view);
}
});
});
refreshData?.addEventListener("click", () => {
void appStore.getState().loadData();
});
setupDragging();
appStore.subscribe(render);
render();
testConnection?.addEventListener("click", () => {
void appStore.getState().testConnection();
});
void appStore.getState().loadData();
+58 -1
View File
@@ -1,23 +1,80 @@
import { createStore } from "zustand/vanilla";
import { getCurrentGiteaUser, giteaConfig, type GiteaUser } from "./api";
import {
getCurrentGiteaUser,
getReposOrderedByLastCommit,
giteaConfig,
type GiteaRepoSummary,
type GiteaUser
} from "./api";
import { loadLocalContext, type LocalContext } from "./localContext";
export type StatusTone = "neutral" | "success" | "error";
export type AppView = "urls" | "repos" | "workspaces";
export interface WindowPosition {
x: number;
y: number;
}
interface AppState {
activeView: AppView;
config: typeof giteaConfig;
connectedUser: GiteaUser | null;
repos: GiteaRepoSummary[];
localContext: LocalContext | null;
windowPosition: WindowPosition;
isLoadingData: boolean;
isTestingConnection: boolean;
statusMessage: string;
statusTone: StatusTone;
setActiveView: (view: AppView) => void;
setWindowPosition: (position: WindowPosition) => void;
loadData: () => Promise<void>;
testConnection: () => Promise<void>;
}
export const appStore = createStore<AppState>()((set) => ({
activeView: "repos",
config: giteaConfig,
connectedUser: null,
repos: [],
localContext: null,
windowPosition: { x: 10, y: 10 },
isLoadingData: false,
isTestingConnection: false,
statusMessage: "Ready.",
statusTone: "neutral",
setActiveView: (view) => set({ activeView: view }),
setWindowPosition: (position) => set({ windowPosition: position }),
loadData: async () => {
set({
isLoadingData: true,
statusMessage: "Loading workspace and Gitea context...",
statusTone: "neutral"
});
try {
const [localContext, repos] = await Promise.all([
loadLocalContext(),
getReposOrderedByLastCommit()
]);
set({
localContext,
repos,
statusMessage: `Loaded ${repos.length} repos and ${localContext.workspaces.length} workspaces.`,
statusTone: "success"
});
} catch (error) {
const message = error instanceof Error ? error.message : "Could not load app data.";
set({
statusMessage: message,
statusTone: "error"
});
} finally {
set({ isLoadingData: false });
}
},
testConnection: async () => {
set({
connectedUser: null,
+295 -44
View File
@@ -1,6 +1,6 @@
:root {
color: #1f2933;
background: #f5f7fa;
background: #e7edf3;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
@@ -13,37 +13,75 @@
}
body {
min-width: 360px;
width: 720px;
min-width: 720px;
height: 620px;
margin: 0;
background: #f5f7fa;
overflow: hidden;
background:
linear-gradient(135deg, rgb(255 255 255 / 72%), rgb(218 228 238 / 72%)),
#e7edf3;
}
.app-shell {
button,
a {
-webkit-tap-highlight-color: transparent;
}
.desktop-canvas {
position: relative;
width: 100vw;
height: 100vh;
}
.app-window {
position: absolute;
display: grid;
gap: 14px;
padding: 16px;
grid-template-rows: auto auto 1fr auto;
width: min(700px, calc(100vw - 20px));
height: min(600px, calc(100vh - 20px));
overflow: hidden;
border: 1px solid #bcccdc;
border-radius: 8px;
background: #f8fafc;
box-shadow: 0 18px 48px rgb(16 42 67 / 18%);
}
.masthead {
.window-titlebar {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 14px;
gap: 16px;
padding: 12px 14px;
border-bottom: 1px solid #d9e2ec;
background: #ffffff;
cursor: grab;
user-select: none;
touch-action: none;
}
.window-titlebar:active {
cursor: grabbing;
}
.eyebrow {
margin: 0 0 4px;
margin: 0 0 2px;
color: #52616f;
font-size: 12px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
h1 {
h1,
h2,
p {
margin: 0;
}
h1 {
color: #102a43;
font-size: 22px;
font-size: 20px;
line-height: 1.15;
}
@@ -70,64 +108,244 @@ h1 {
color: #8d6e00;
}
.panel {
.badge.quiet {
border-color: #d9e2ec;
background: #f5f7fa;
color: #52616f;
}
.toolbar {
display: grid;
gap: 14px;
border: 1px solid #d9e2ec;
border-radius: 8px;
padding: 14px;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 1px;
border-bottom: 1px solid #d9e2ec;
background: #d9e2ec;
}
.tool-button,
.icon-button,
.visit-button {
border: 0;
font: inherit;
}
.tool-button {
display: grid;
justify-items: center;
gap: 4px;
min-width: 0;
min-height: 58px;
padding: 8px 6px;
background: #ffffff;
box-shadow: 0 8px 24px rgb(16 42 67 / 8%);
color: #334e68;
cursor: pointer;
}
.config-list {
.tool-button svg {
width: 19px;
height: 19px;
stroke-width: 2.25;
}
.tool-button span {
max-width: 100%;
overflow: hidden;
font-size: 11px;
font-weight: 700;
line-height: 1.15;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-button[data-active="true"] {
background: #e6f6ff;
color: #075985;
}
.tool-button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.content-panel {
display: grid;
gap: 10px;
margin: 0;
grid-template-rows: auto 1fr;
min-height: 0;
}
.config-list div {
display: grid;
gap: 3px;
.view-header {
padding: 14px 16px 10px;
border-bottom: 1px solid #edf2f7;
background: #f8fafc;
}
dt {
.view-header h2 {
color: #102a43;
font-size: 17px;
line-height: 1.2;
}
.view-header p {
margin-top: 3px;
color: #52616f;
font-size: 12px;
font-weight: 700;
line-height: 1.4;
}
dd {
margin: 0;
overflow-wrap: anywhere;
.view-content {
display: grid;
align-content: start;
gap: 10px;
min-height: 0;
overflow-y: auto;
padding: 12px;
}
.item-card {
display: grid;
gap: 9px;
border: 1px solid #d9e2ec;
border-radius: 8px;
padding: 12px;
background: #ffffff;
}
.item-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.item-title {
color: #102a43;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 13px;
font-size: 14px;
font-weight: 800;
line-height: 1.25;
text-decoration: none;
}
button {
width: 100%;
border: 0;
a.item-title:hover,
.repo-link:hover,
.url-text:hover {
text-decoration: underline;
}
.item-subtitle {
margin-top: 3px;
color: #52616f;
font-size: 12px;
line-height: 1.4;
}
.meta-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
color: #52616f;
font-size: 11px;
}
.meta-grid strong {
color: #243b53;
font-weight: 800;
}
.commit-message {
color: #243b53;
font-size: 12px;
line-height: 1.4;
}
.linked-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 7px;
color: #52616f;
font-size: 12px;
}
.link-chip {
display: inline-flex;
align-items: center;
border: 1px solid #9fb3c8;
border-radius: 999px;
padding: 2px 7px;
background: #f0f4f8;
color: #334e68;
font-size: 11px;
font-weight: 800;
}
.path-text {
overflow-wrap: anywhere;
color: #334e68;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 11px;
line-height: 1.35;
}
.repo-link-list {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.repo-link,
.url-text {
overflow-wrap: anywhere;
color: #0b6bcb;
font-size: 12px;
font-weight: 700;
text-decoration: none;
}
.visit-button {
flex: 0 0 auto;
border-radius: 6px;
padding: 10px 12px;
padding: 7px 10px;
background: #0b6bcb;
color: #ffffff;
cursor: pointer;
font: inherit;
font-weight: 700;
font-size: 12px;
font-weight: 800;
}
button:disabled {
cursor: wait;
opacity: 0.7;
.empty-state {
border: 1px dashed #bcccdc;
border-radius: 8px;
padding: 18px;
color: #52616f;
background: #ffffff;
font-size: 13px;
text-align: center;
}
.muted {
color: #829ab1;
font-size: 12px;
}
.statusbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 42px;
border-top: 1px solid #d9e2ec;
padding: 8px 10px 8px 14px;
background: #ffffff;
}
#status {
min-height: 20px;
margin: 0;
min-width: 0;
overflow: hidden;
color: #52616f;
font-size: 13px;
font-size: 12px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
#status[data-tone="success"] {
@@ -137,3 +355,36 @@ button:disabled {
#status[data-tone="error"] {
color: #b42318;
}
.icon-button {
display: inline-grid;
flex: 0 0 auto;
place-items: center;
width: 30px;
height: 30px;
border-radius: 6px;
background: #eef4f9;
color: #334e68;
cursor: pointer;
}
.icon-button:disabled {
cursor: wait;
opacity: 0.65;
}
.icon-button svg {
width: 16px;
height: 16px;
}
@media (max-width: 520px) {
body {
width: 420px;
min-width: 420px;
}
.meta-grid {
grid-template-columns: 1fr;
}
}