buddy-workbench 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ import { Router } from 'express';
2
+ import { listStaticPages, saveStaticPages } from '../repositories/static-pages.js';
3
+
4
+ const router = Router();
5
+
6
+ // List static pages
7
+ router.get('/', (_req, res) => {
8
+ res.json(listStaticPages());
9
+ });
10
+
11
+ // Save all static pages
12
+ router.put('/', (req, res) => {
13
+ if (!Array.isArray(req.body)) {
14
+ return res.status(400).json({ error: 'Expected an array of static pages.' });
15
+ }
16
+ saveStaticPages(req.body);
17
+ res.json(listStaticPages());
18
+ });
19
+
20
+ // Create single static page
21
+ router.post('/', (req, res) => {
22
+ const { name, url } = req.body || {};
23
+ if (!name || typeof name !== 'string' || !name.trim()) {
24
+ return res.status(400).json({ error: 'Page name is required.' });
25
+ }
26
+ if (!url || typeof url !== 'string' || !url.trim()) {
27
+ return res.status(400).json({ error: 'URL is required.' });
28
+ }
29
+
30
+ const pages = listStaticPages();
31
+ const newPage = {
32
+ id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
33
+ name: name.trim(),
34
+ url: url.trim(),
35
+ icon: 'GlobalOutlined'
36
+ };
37
+ pages.push(newPage);
38
+ saveStaticPages(pages);
39
+ res.status(201).json(newPage);
40
+ });
41
+
42
+ // Update single static page
43
+ router.put('/:id', (req, res) => {
44
+ const { id } = req.params;
45
+ const { name, url } = req.body || {};
46
+ const pages = listStaticPages();
47
+ const index = pages.findIndex((p) => String(p.id) === String(id));
48
+ if (index === -1) {
49
+ return res.status(404).json({ error: 'Static page not found.' });
50
+ }
51
+
52
+ if (name !== undefined) {
53
+ if (typeof name !== 'string' || !name.trim()) {
54
+ return res.status(400).json({ error: 'Page name cannot be empty.' });
55
+ }
56
+ pages[index].name = name.trim();
57
+ }
58
+
59
+ if (url !== undefined) {
60
+ if (typeof url !== 'string' || !url.trim()) {
61
+ return res.status(400).json({ error: 'URL cannot be empty.' });
62
+ }
63
+ pages[index].url = url.trim();
64
+ }
65
+
66
+ saveStaticPages(pages);
67
+ res.json(pages[index]);
68
+ });
69
+
70
+ // Delete static page
71
+ router.delete('/:id', (req, res) => {
72
+ const { id } = req.params;
73
+ const pages = listStaticPages();
74
+ const filtered = pages.filter((p) => String(p.id) !== String(id));
75
+ if (filtered.length === pages.length) {
76
+ return res.status(404).json({ error: 'Static page not found.' });
77
+ }
78
+ saveStaticPages(filtered);
79
+ res.status(204).end();
80
+ });
81
+
82
+ export default router;
@@ -0,0 +1,177 @@
1
+ import { Router } from 'express';
2
+ import { listTodos, saveTodos } from '../repositories/todos.js';
3
+
4
+ const router = Router();
5
+
6
+ function validTodo(body) {
7
+ const title = String(body?.title || '').trim();
8
+ if (!title) return null;
9
+
10
+ const priority = ['high', 'medium', 'low'].includes(body?.priority) ? body.priority : 'medium';
11
+ const category = String(body?.category || 'work').trim() || 'work';
12
+ const description = String(body?.description || '').trim();
13
+ const dueDate = body?.dueDate ? String(body.dueDate).trim() : null;
14
+ const completed = Boolean(body?.completed);
15
+ const archived = Boolean(body?.archived);
16
+
17
+ return {
18
+ title,
19
+ description,
20
+ priority,
21
+ category,
22
+ dueDate,
23
+ completed,
24
+ archived
25
+ };
26
+ }
27
+
28
+ router.get('/', (_req, res) => {
29
+ res.json(listTodos());
30
+ });
31
+
32
+ router.post('/', (req, res) => {
33
+ const data = validTodo(req.body);
34
+ if (!data) {
35
+ return res.status(400).json({ error: 'Title is required.' });
36
+ }
37
+
38
+ const now = new Date().toISOString();
39
+ const newTodo = {
40
+ id: crypto.randomUUID(),
41
+ ...data,
42
+ completedAt: data.completed ? now : null,
43
+ archivedAt: data.archived ? now : null,
44
+ createdAt: now,
45
+ updatedAt: now
46
+ };
47
+
48
+ const todos = listTodos();
49
+ todos.unshift(newTodo);
50
+ saveTodos(todos);
51
+
52
+ res.status(201).json(newTodo);
53
+ });
54
+
55
+ router.put('/:id', (req, res) => {
56
+ const { id } = req.params;
57
+ const todos = listTodos();
58
+ const index = todos.findIndex((item) => item.id === id);
59
+
60
+ if (index < 0) {
61
+ return res.status(404).json({ error: 'Todo item not found.' });
62
+ }
63
+
64
+ const data = validTodo(req.body);
65
+ if (!data) {
66
+ return res.status(400).json({ error: 'Title is required.' });
67
+ }
68
+
69
+ const existing = todos[index];
70
+ const now = new Date().toISOString();
71
+ const completedAt = data.completed
72
+ ? (existing.completed ? existing.completedAt : now)
73
+ : null;
74
+ const archivedAt = data.archived
75
+ ? (existing.archived ? existing.archivedAt : now)
76
+ : null;
77
+
78
+ const updated = {
79
+ ...existing,
80
+ ...data,
81
+ completedAt,
82
+ archivedAt,
83
+ updatedAt: now
84
+ };
85
+
86
+ todos[index] = updated;
87
+ saveTodos(todos);
88
+
89
+ res.json(updated);
90
+ });
91
+
92
+ router.post('/:id/archive', (req, res) => {
93
+ const { id } = req.params;
94
+ const todos = listTodos();
95
+ const index = todos.findIndex((item) => item.id === id);
96
+
97
+ if (index < 0) {
98
+ return res.status(404).json({ error: 'Todo item not found.' });
99
+ }
100
+
101
+ const now = new Date().toISOString();
102
+ const updated = {
103
+ ...todos[index],
104
+ archived: true,
105
+ archivedAt: now,
106
+ updatedAt: now
107
+ };
108
+
109
+ todos[index] = updated;
110
+ saveTodos(todos);
111
+ res.json(updated);
112
+ });
113
+
114
+ router.post('/:id/unarchive', (req, res) => {
115
+ const { id } = req.params;
116
+ const todos = listTodos();
117
+ const index = todos.findIndex((item) => item.id === id);
118
+
119
+ if (index < 0) {
120
+ return res.status(404).json({ error: 'Todo item not found.' });
121
+ }
122
+
123
+ const now = new Date().toISOString();
124
+ const updated = {
125
+ ...todos[index],
126
+ archived: false,
127
+ archivedAt: null,
128
+ updatedAt: now
129
+ };
130
+
131
+ todos[index] = updated;
132
+ saveTodos(todos);
133
+ res.json(updated);
134
+ });
135
+
136
+ router.delete('/:id', (req, res) => {
137
+ const { id } = req.params;
138
+ const todos = listTodos();
139
+ const next = todos.filter((item) => item.id !== id);
140
+
141
+ if (next.length === todos.length) {
142
+ return res.status(404).json({ error: 'Todo item not found.' });
143
+ }
144
+
145
+ saveTodos(next);
146
+ res.status(204).end();
147
+ });
148
+
149
+ router.post('/batch', (req, res) => {
150
+ const { action, ids } = req.body || {};
151
+ let todos = listTodos();
152
+ const now = new Date().toISOString();
153
+ const idSet = new Set(Array.isArray(ids) ? ids : []);
154
+
155
+ if (action === 'markCompleted') {
156
+ todos = todos.map((item) => (idSet.has(item.id) ? { ...item, completed: true, completedAt: item.completedAt || now, updatedAt: now } : item));
157
+ } else if (action === 'markPending') {
158
+ todos = todos.map((item) => (idSet.has(item.id) ? { ...item, completed: false, completedAt: null, updatedAt: now } : item));
159
+ } else if (action === 'archive') {
160
+ todos = todos.map((item) => (idSet.has(item.id) ? { ...item, archived: true, archivedAt: now, updatedAt: now } : item));
161
+ } else if (action === 'unarchive') {
162
+ todos = todos.map((item) => (idSet.has(item.id) ? { ...item, archived: false, archivedAt: null, updatedAt: now } : item));
163
+ } else if (action === 'delete') {
164
+ todos = todos.filter((item) => !idSet.has(item.id));
165
+ } else if (action === 'clearCompleted') {
166
+ todos = todos.filter((item) => !item.completed || item.archived);
167
+ } else if (action === 'clearArchived') {
168
+ todos = todos.filter((item) => !item.archived);
169
+ } else {
170
+ return res.status(400).json({ error: 'Invalid batch action.' });
171
+ }
172
+
173
+ saveTodos(todos);
174
+ res.json(todos);
175
+ });
176
+
177
+ export default router;
@@ -0,0 +1,28 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readSettings } from '../repositories/settings.js';
3
+
4
+ export function openInSystemBrowser(url) {
5
+ if (!url || typeof url !== 'string') return;
6
+ const settings = readSettings();
7
+ const browser = settings.defaultBrowser || 'chrome';
8
+
9
+ if (process.platform === 'darwin') {
10
+ const macAppMap = {
11
+ chrome: 'Google Chrome',
12
+ edge: 'Microsoft Edge',
13
+ safari: 'Safari'
14
+ };
15
+ const appName = macAppMap[browser] || 'Google Chrome';
16
+ execFile('open', ['-a', appName, url]);
17
+ } else if (process.platform === 'win32') {
18
+ const winCmdMap = {
19
+ chrome: 'chrome',
20
+ edge: 'msedge',
21
+ safari: 'msedge'
22
+ };
23
+ const cmd = winCmdMap[browser] || 'chrome';
24
+ execFile('cmd', ['/c', 'start', '', cmd, url]);
25
+ } else {
26
+ execFile('xdg-open', [url]);
27
+ }
28
+ }
@@ -19,8 +19,74 @@ export function clipboardDates() {
19
19
  return readdirSync(paths.clipboardDir, { withFileTypes: true }).filter((year) => year.isDirectory() && /^\d{4}$/.test(year.name)).flatMap((year) => readdirSync(join(paths.clipboardDir, year.name), { withFileTypes: true }).filter((month) => month.isDirectory() && /^\d{2}$/.test(month.name)).flatMap((month) => readdirSync(join(paths.clipboardDir, year.name, month.name), { withFileTypes: true }).filter((day) => day.isFile() && /^\d{2}\.json$/.test(day.name)).map((day) => `${year.name}-${month.name}-${day.name.slice(0, 2)}`))).sort().reverse();
20
20
  }
21
21
 
22
+ function getEditorUrl(editor = 'vscode', path = '') {
23
+ if (editor === 'idea') return `idea://open?file=${encodeURIComponent(path)}`;
24
+ if (editor === 'devin') return `devin://file${encodeURI(path)}`;
25
+ return `vscode://file${encodeURI(path)}`;
26
+ }
27
+
22
28
  function dayItems(date) { return readJson(dayFile(date), []); }
23
- export function clipboardItems(date = today()) { return dayItems(date).map((item) => item.contentFile ? { ...item, editorUrl: `vscode://file${encodeURI(join(paths.clipboardDir, 'content', item.contentFile))}` } : item); }
29
+ export function clipboardItems(date = today()) {
30
+ const settings = readSettings();
31
+ const editor = settings.defaultEditor || 'vscode';
32
+ const editorName = editor === 'idea' ? 'IntelliJ IDEA' : editor === 'devin' ? 'Devin' : 'VS Code';
33
+ return dayItems(date).map((item) => item.contentFile ? { ...item, editorUrl: getEditorUrl(editor, join(paths.clipboardDir, 'content', item.contentFile)), editorName } : item);
34
+ }
35
+ export function allTaggedClipboardItems() {
36
+ const settings = readSettings();
37
+ const editor = settings.defaultEditor || 'vscode';
38
+ const editorName = editor === 'idea' ? 'IntelliJ IDEA' : editor === 'devin' ? 'Devin' : 'VS Code';
39
+ const dates = clipboardDates();
40
+ const result = [];
41
+
42
+ for (const date of dates) {
43
+ const items = dayItems(date);
44
+ for (const item of items) {
45
+ if (Array.isArray(item.tags) && item.tags.length > 0) {
46
+ result.push(
47
+ item.contentFile
48
+ ? {
49
+ ...item,
50
+ date,
51
+ editorUrl: getEditorUrl(editor, join(paths.clipboardDir, 'content', item.contentFile)),
52
+ editorName
53
+ }
54
+ : { ...item, date }
55
+ );
56
+ }
57
+ }
58
+ }
59
+ return result;
60
+ }
61
+
62
+ export function updateClipboardItemTags(date, id, tags) {
63
+ let targetDate = date;
64
+ let items = dayItems(targetDate);
65
+ let item = items.find((entry) => entry.id === id);
66
+
67
+ if (!item) {
68
+ const dates = clipboardDates();
69
+ for (const d of dates) {
70
+ const dayList = dayItems(d);
71
+ const found = dayList.find((entry) => entry.id === id);
72
+ if (found) {
73
+ targetDate = d;
74
+ items = dayList;
75
+ item = found;
76
+ break;
77
+ }
78
+ }
79
+ }
80
+
81
+ if (!item) return null;
82
+
83
+ const validTags = Array.isArray(tags) ? tags.map((t) => String(t).trim()).filter(Boolean) : [];
84
+ const updatedItem = { ...item, tags: validTags };
85
+ const nextItems = items.map((entry) => (entry.id === id ? updatedItem : entry));
86
+ writeJson(dayFile(targetDate), nextItems);
87
+ return updatedItem;
88
+ }
89
+
24
90
  export function clipboardOriginal(date, id) { const item = dayItems(date).find((entry) => entry.id === id); if (!item?.contentFile) return item?.text || null; try { return readFileSync(join(paths.clipboardDir, 'content', item.contentFile), 'utf8'); } catch { return null; } }
25
91
  export function deleteClipboardItem(date, id) {
26
92
  const items = dayItems(date); const item = items.find((entry) => entry.id === id);
@@ -82,18 +148,18 @@ export async function captureClipboard() {
82
148
  if (alreadyExists) return;
83
149
 
84
150
  const id = crypto.randomUUID(); const isLong = text.length > previewLimit;
85
- if (isLong) { mkdirSync(join(paths.clipboardDir, 'content'), { recursive: true }); writeFileSync(join(paths.clipboardDir, 'content', `${id}.txt`), text, 'utf8'); }
86
- const item = isLong ? { id, preview: `${text.slice(0, previewLimit)}…`, contentFile: `${id}.txt`, createdAt: new Date().toISOString() } : { id, text, createdAt: new Date().toISOString() };
151
+ if (isLong) { mkdirSync(join(paths.clipboardDir, 'content'), { recursive: true }); writeFileSync(join(paths.clipboardDir, 'content', id), text, 'utf8'); }
152
+ const item = isLong ? { id, preview: `${text.slice(0, previewLimit)}…`, contentFile: id, createdAt: new Date().toISOString() } : { id, text, createdAt: new Date().toISOString() };
87
153
  writeJson(dayFile(date), [item, ...items].slice(0, 200));
88
154
  } catch {}
89
155
  }
90
156
 
91
157
  export function startClipboardCapture() { captureClipboard(); const timer = setInterval(captureClipboard, 2000); timer.unref(); }
92
158
 
93
- export function saveClipboardImage(buffer) {
159
+ export function saveClipboardImage(buffer, ext = 'png') {
94
160
  const date = today();
95
161
  const id = crypto.randomUUID();
96
- const filename = `${id}.png`;
162
+ const filename = `${id}.${ext}`;
97
163
 
98
164
  mkdirSync(join(paths.clipboardDir, 'content'), { recursive: true });
99
165
  writeFileSync(join(paths.clipboardDir, 'content', filename), buffer);
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
4
4
  import { promisify } from 'node:util';
5
5
  import { execFile } from 'node:child_process';
6
6
  import { listPortHistory, savePortHistory } from '../repositories/port-history.js';
7
+ import { addErrorRecord } from '../repositories/errors.js';
7
8
 
8
9
  const running = new Map();
9
10
  const logs = new Map();
@@ -217,6 +218,13 @@ export function runScript(launcher, script) {
217
218
  child.on('exit', (code) => {
218
219
  appendLog(key, code === 0 ? 'output' : 'error', `\nProcess exited with code ${code}.\n`);
219
220
  const item = logs.get(key);
221
+ if (code !== null && code !== 0) {
222
+ addErrorRecord({
223
+ source: `Launcher: ${launcher.alias}`,
224
+ message: `Script "${script.name}" exited with code ${code}`,
225
+ details: item?.error || item?.output || `Command: ${script.command}`
226
+ });
227
+ }
220
228
  if (item) item.errorCount = 0;
221
229
  running.delete(key);
222
230
  retireGroup(child.pid);
package/server.js CHANGED
@@ -13,11 +13,46 @@ import groupTaskRoutes from './server/routes/group-tasks.js';
13
13
  import portDiagnosticsRoutes from './server/routes/port-diagnostics.js';
14
14
  import settingsRoutes from './server/routes/settings.js';
15
15
  import prReviewRoutes from './server/routes/pr-review.js';
16
+ import jiraFiltersRoutes from './server/routes/jira-filters.js';
17
+ import todoRoutes from './server/routes/todos.js';
18
+ import staticPagesRoutes from './server/routes/static-pages.js';
19
+ import errorRoutes from './server/routes/errors.js';
20
+ import { addErrorRecord } from './server/repositories/errors.js';
16
21
  import { startClipboardCapture } from './server/services/clipboard-history.js';
17
22
 
18
23
  const port = Number(process.env.PORT || 3100);
19
24
  const app = express();
20
25
  app.use(express.json());
26
+
27
+ // Intercept error responses (HTTP status >= 400) from any API route
28
+ app.use((req, res, next) => {
29
+ const originalJson = res.json;
30
+ res.json = function (body) {
31
+ if (res.statusCode >= 400 && body && body.error && !req.path.startsWith('/api/errors')) {
32
+ const host = req.get('host') || req.headers.host || `localhost:${port}`;
33
+ const fullUrl = `${req.protocol}://${host}${req.originalUrl || req.url}`;
34
+
35
+ const detailsLines = [];
36
+ if (body.targetUrl) {
37
+ detailsLines.push(`Target URL: ${body.targetUrl}`);
38
+ }
39
+ detailsLines.push(`Endpoint: ${req.method} ${fullUrl}`);
40
+ detailsLines.push(`HTTP Status: ${res.statusCode}`);
41
+ if (typeof body.error === 'string' && body.error !== `HTTP ${res.statusCode} Error`) {
42
+ detailsLines.push(`Error: ${body.error}`);
43
+ }
44
+
45
+ addErrorRecord({
46
+ source: `Backend API (${req.method} ${req.path})`,
47
+ message: typeof body.error === 'string' ? body.error : `HTTP ${res.statusCode} Error`,
48
+ details: detailsLines.join('\n')
49
+ });
50
+ }
51
+ return originalJson.call(this, body);
52
+ };
53
+ next();
54
+ });
55
+
21
56
  app.use(express.static(paths.ui));
22
57
  app.use('/plugins', express.static(paths.plugins));
23
58
  app.use('/api/launchers', launcherRoutes);
@@ -27,6 +62,19 @@ app.use('/api/group-tasks', groupTaskRoutes);
27
62
  app.use('/api/port-diagnostics', portDiagnosticsRoutes);
28
63
  app.use('/api/settings', settingsRoutes);
29
64
  app.use('/api/pr-review', prReviewRoutes);
65
+ app.use('/api/jira-filters', jiraFiltersRoutes);
66
+ app.use('/api/todos', todoRoutes);
67
+ app.use('/api/static-pages', staticPagesRoutes);
68
+ app.use('/api/errors', errorRoutes);
69
+
70
+ app.use((err, req, res, _next) => {
71
+ addErrorRecord({
72
+ source: `Backend API (${req.method} ${req.path})`,
73
+ message: err.message || 'Internal Server Error',
74
+ details: err.stack || String(err)
75
+ });
76
+ res.status(500).json({ error: err.message || 'Internal Server Error' });
77
+ });
30
78
 
31
79
  await freePort(port);
32
80
  const server = app.listen(port, () => { console.log(`Buddy Workbench: http://localhost:${port} (pid ${process.pid})`); shutdownTrace(`server started (pid=${process.pid})`); });
@@ -51,6 +99,14 @@ async function shutdown(exitCode = 0) {
51
99
  process.once('SIGINT', () => { shutdownTrace('received SIGINT'); void shutdown(); });
52
100
  process.once('SIGTERM', () => { shutdownTrace('received SIGTERM'); void shutdown(); });
53
101
  process.once('SIGHUP', () => { shutdownTrace('received SIGHUP'); void shutdown(); });
54
- process.once('uncaughtException', (error) => { console.error(error); void shutdown(1); });
55
- process.once('unhandledRejection', (error) => { console.error(error); void shutdown(1); });
102
+ process.once('uncaughtException', (error) => {
103
+ addErrorRecord({ source: 'Server UncaughtException', message: error.message || 'Uncaught Server Exception', details: error.stack || String(error) });
104
+ console.error(error);
105
+ void shutdown(1);
106
+ });
107
+ process.once('unhandledRejection', (error) => {
108
+ addErrorRecord({ source: 'Server UnhandledRejection', message: error?.message || String(error || 'Unhandled Promise Rejection'), details: error?.stack || String(error) });
109
+ console.error(error);
110
+ void shutdown(1);
111
+ });
56
112
  process.once('exit', () => stopAllScripts(true));
@@ -0,0 +1 @@
1
+ html,body{width:100%;height:100%}input::-ms-clear,input::-ms-reveal{display:none}*,*:before,*:after{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:rgba(0,0,0,0)}@-ms-viewport{width:device-width}body{margin:0}[tabindex="-1"]:focus{outline:none}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5em;font-weight:500}p{margin-top:0;margin-bottom:1em}abbr[title],abbr[data-original-title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;border-bottom:0;cursor:help}address{margin-bottom:1em;font-style:normal;line-height:inherit}input[type=text],input[type=password],input[type=number],textarea{-webkit-appearance:none}ol,ul,dl{margin-top:0;margin-bottom:1em}ol ol,ul ul,ol ul,ul ol{margin-bottom:0}dt{font-weight:500}dd{margin-bottom:.5em;margin-left:0}blockquote{margin:0 0 1em}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}pre,code,kbd,samp{font-size:1em;font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,Courier,monospace}pre{margin-top:0;margin-bottom:1em;overflow:auto}figure{margin:0 0 1em}img{vertical-align:middle;border-style:none}a,area,button,[role=button],input:not([type=range]),label,select,summary,textarea{touch-action:manipulation}table{border-collapse:collapse}caption{padding-top:.75em;padding-bottom:.3em;text-align:left;caption-side:bottom}input,button,select,optgroup,textarea{margin:0;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}button,html [type=button],[type=reset],[type=submit]{-webkit-appearance:button}button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{padding:0;border-style:none}input[type=radio],input[type=checkbox]{box-sizing:border-box;padding:0}input[type=date],input[type=time],input[type=datetime-local],input[type=month]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;margin:0;padding:0;border:0}legend{display:block;width:100%;max-width:100%;margin-bottom:.5em;padding:0;color:inherit;font-size:1.5em;line-height:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item}template{display:none}[hidden]{display:none!important}mark{padding:.2em;background-color:#feffe6}:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,PingFang SC,sans-serif;color:#172033;background:#f7f8fc}html,body{height:100vh;overflow:hidden}body{margin:0;background:#f7f8fc}.workbench{height:100vh;overflow:hidden;background:#f7f8fc}.sidebar{position:sticky!important;top:0;height:100vh;background:#171c2b!important;padding:28px 14px}.sidebar .ant-menu-title-content{-webkit-user-select:none;user-select:none}.brand{padding:0 12px 30px;color:#fff;font-size:21px;font-weight:750;letter-spacing:-.4px}.brand span{color:#9589ff}.sidebar .ant-menu{background:transparent;border-inline-end:0}.sidebar-bottom{position:absolute;right:14px;bottom:28px;left:14px}.sidebar-footer{padding:14px 12px 0;color:#727b94;font-size:12px}.page{height:100vh;overflow-y:auto;padding:24px clamp(20px,3.5vw,40px)}.page-header{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;margin-bottom:30px}.page-header .ant-typography{margin:0}.launcher-grid{display:grid;gap:7px}.launcher-card{cursor:pointer;border-color:#e3e6ef}.launcher-card .ant-card-body{padding:10px 14px}.service-card{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:46px}.service-card .ant-typography{display:block;margin:0}.service-card h4.ant-typography{margin:0 0 1px;font-size:15px;line-height:20px}.service-info{min-width:0;flex:1}.service-info>.ant-typography{font-size:12px;line-height:16px}.service-action{display:flex;flex-direction:column;align-items:flex-end;gap:3px;white-space:nowrap}.service-action .ant-badge{font-size:11px;line-height:14px}.launcher-table .ant-table-tbody>tr{cursor:pointer}.start-script-icon{color:#8c8c8c;font-size:12px}.script-add{margin-bottom:10px}.service-config{margin-bottom:10px;margin-top:10px}.service-config .ant-collapse-content-box{padding-bottom:0!important}.service-config .ant-form-item{margin-bottom:14px}.clipboard-picker{min-width:190px}.squoosh-page{position:relative;min-height:calc(100vh - 48px);margin:-24px clamp(-20px,-3.5vw,-40px);padding:24px clamp(20px,3.5vw,40px);color:#172033}.squoosh-page.is-file-dragging{box-shadow:inset 0 0 0 3px #7667e8}.squoosh-page.is-file-dragging:after{position:absolute;z-index:20;top:12px;right:12px;bottom:12px;left:12px;display:grid;place-items:center;border:2px dashed #7667e8;border-radius:12px;background:#f8f7ffc7;color:#5b4cc4;content:"Drop image to compress";font-size:20px;font-weight:700;pointer-events:none}.squoosh-header{display:flex;align-items:center;justify-content:space-between;gap:20px;padding-bottom:20px;border-bottom:1px solid #e4e7ef}.squoosh-header .ant-typography{margin:0;color:inherit}.squoosh-header>div>.ant-typography:first-child,.settings-heading>.ant-typography:first-child{color:#6d5ce7;font-size:11px;font-weight:750;letter-spacing:.12em}.squoosh-header h2.ant-typography{margin-top:4px;font-size:25px}.squoosh-studio{display:grid;grid-template-columns:minmax(0,1fr) 380px;margin-top:16px;border:1px solid #e1e5ed;border-radius:10px;overflow:hidden;background:#fff;box-shadow:0 12px 32px #1f2a4412}.squoosh-preview-area{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));min-width:0;background:#fbfcfe}.preview-pane{display:grid;grid-template-rows:auto minmax(0,1fr);min-width:0;min-height:570px}.preview-pane+.preview-pane{border-left:1px solid #e1e5ed}.preview-pane>header{display:flex;justify-content:space-between;align-items:center;gap:12px;min-height:58px;padding:0 16px;border-bottom:1px solid #e1e5ed;background:#fff}.preview-pane>header>div{display:grid;gap:1px;min-width:0}.preview-pane>header .ant-typography{color:#27344c;font-weight:650}.preview-pane>header span{overflow:hidden;color:#7d8799;font-size:12px;text-overflow:ellipsis;white-space:nowrap}.preview-canvas{position:relative;display:grid;min-width:0;min-height:0;place-items:center;padding:22px;background-color:#f3f5f9;background-image:linear-gradient(45deg,#e9edf4 25%,transparent 25%),linear-gradient(-45deg,#e9edf4 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#e9edf4 75%),linear-gradient(-45deg,transparent 75%,#e9edf4 75%);background-position:0 0,0 12px,12px -12px,-12px 0;background-size:24px 24px}.image-dropzone{width:100%;max-width:420px}.image-dropzone.ant-upload-wrapper .ant-upload-drag{border-color:#cfd6e4;background:#ffffffe0}.image-dropzone.ant-upload-wrapper .ant-upload-drag:hover{border-color:#7667e8}.image-dropzone .ant-upload{padding:76px 18px!important}.image-dropzone .ant-upload-drag-icon .anticon{color:#6d5ce7}.image-dropzone .ant-upload-text{color:#27344c!important}.image-dropzone .ant-upload-hint{color:#7d8799!important}.image-panel{display:flex;align-items:center;justify-content:center;width:100%;height:100%;min-width:0;min-height:0}.image-panel img{display:block;width:auto;max-width:100%;max-height:min(65vh,650px);object-fit:contain;border-radius:5px;box-shadow:0 10px 30px #00000052}.image-panel-meta{display:grid;gap:3px;min-width:0;text-align:center}.image-panel-meta .ant-typography{min-width:0;color:#46516a}.squoosh-settings{overflow-y:auto;padding:22px 20px;background:#fff}.settings-heading{margin-bottom:21px;padding-bottom:17px;border-bottom:1px solid #e7eaf0}.settings-heading .ant-typography{margin:0;color:#27344c}.settings-heading h4.ant-typography{margin-top:5px}.settings-heading .ant-typography-secondary{margin-top:4px;color:#7d8799;font-size:13px}.option-row,.option-switch,.option-select{display:flex;justify-content:space-between;align-items:center;gap:16px}.option-row{margin-bottom:4px}.option-row .ant-typography,.option-switch .ant-typography,.option-select .ant-typography{color:#27344c}.option-row .ant-typography-secondary,.option-switch .ant-typography-secondary,.option-select .ant-typography-secondary{color:#7d8799;font-size:12px}.option-switch{padding:15px 0;border-top:1px solid #e7eaf0}.option-select{padding:14px 0;border-top:1px solid #e7eaf0}.option-select .ant-select,.option-select .ant-input-number{flex:0 0 170px;width:170px}.advanced-options{margin:6px -12px 16px}.advanced-options .ant-collapse-header,.advanced-options .ant-collapse-content{color:#46516a!important}.advanced-options .ant-collapse-content{background:transparent}.advanced-options .ant-collapse-content-box{padding-top:0!important}.image-loading{display:grid;min-height:330px;place-items:center}.image-loading .ant-spin-text{color:#46516a}.preview-pane>header button.ant-btn.ant-btn-color-primary{min-width:116px;border-color:#5f50d8!important;background:#6657dc!important;color:#fff!important;font-weight:650;text-shadow:none}.preview-pane>header button.ant-btn.ant-btn-color-primary>span,.preview-pane>header button.ant-btn.ant-btn-color-primary .anticon{color:#fff!important}.preview-pane>header button.ant-btn.ant-btn-color-primary:hover{border-color:#5143c2!important;background:#5143c2!important;color:#fff!important}.compression-saving{position:absolute;bottom:24px;left:50%;z-index:1;margin:0;padding:9px 12px;border-radius:4px;background:#fff7e6;color:#ad6800;font-weight:600;text-align:center;transform:translate(-50%);white-space:nowrap}.compression-saving.is-saving{background:#f6ffed;color:#389e0d}.clipboard-tabs{margin:-12px 0 16px}.clipboard-tabs .ant-tabs-nav{margin-bottom:0}.clipboard-list{display:grid;gap:8px}.clipboard-item .ant-card-body{position:relative;display:block;padding:12px 14px}.clipboard-item .ant-card-body>div{width:100%;min-width:0}.clipboard-actions{position:absolute;top:8px;right:8px;display:flex}.clipboard-item pre{width:100%;max-height:160px;overflow:auto;margin:5px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}.clipboard-original{display:flex;gap:12px;margin-top:8px;font-size:12px}.port-manual-query{margin:-12px 0 16px}.port-query-result{margin-top:12px}.group-task-list{display:grid;gap:8px}.group-task-header{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:16px}.group-task-empty{margin:16px 0}.group-task-card{display:flex;align-items:center;justify-content:space-between;gap:16px}.group-task-card>div:first-child{display:grid;gap:2px}.group-task-card .ant-space{flex-wrap:nowrap}.group-task-card .ant-btn{width:72px}.group-task-selector{display:grid;gap:8px;width:100%}.group-task-selector .ant-card-body{padding:10px 14px}.group-task-projects{display:grid;gap:8px}.settings-page{width:100%;max-width:none}.settings-page>.ant-typography{margin:0}.settings-page-header{display:flex;align-items:center;justify-content:space-between;gap:16px}.settings-page-header .ant-typography{margin:0}.settings-page-header>.ant-btn{min-width:96px}.settings-card{width:100%;margin-top:20px;border-radius:14px;border:1px solid #e4e8f1;box-shadow:0 2px 12px #1f2a440d}.settings-card h4.ant-typography{margin:0}.settings-icon-wrap{display:flex;align-items:center;justify-content:center;width:40px;height:40px;flex-shrink:0;border-radius:10px;background:linear-gradient(135deg,#ece9fc,#ddd6fe)}.settings-icon{color:#6d5ce7;font-size:18px}.settings-title{display:flex;align-items:center;gap:14px;margin-bottom:24px;padding-bottom:20px;border-bottom:1px solid #eef0f6}.settings-title .ant-typography{margin:0}.settings-title .ant-typography-secondary{font-size:13px;margin-top:2px}.token-settings-form{display:grid;gap:0}.token-row{display:grid;grid-template-columns:200px 1fr;align-items:center;gap:20px;padding:16px 18px;margin:0 -6px;border-radius:10px;border:1px solid transparent;transition:background .18s,border-color .18s}.token-row:hover{background:#f8f9fc;border-color:#eef0f6}.token-row+.token-row{margin-top:2px}.token-row.is-configured{background:#fafbfe}.token-row-info{display:grid;gap:2px;min-width:0}.token-row-name-line{display:flex;align-items:center;gap:8px}.token-row-name{font-size:14px;white-space:nowrap}.token-row-hint{font-size:12px;line-height:1.4}.token-row-tag.ant-tag{margin:0;font-size:11px;line-height:18px;padding-inline:6px;border-radius:4px}.token-row .ant-form-item{margin:0;min-width:0}.token-row .ant-input,.token-row .ant-input-password{border-radius:8px}.script-header{display:flex;align-items:center;gap:16px}.script-header .ant-divider{flex:1;min-width:0;margin:16px 0}.script-header>.ant-btn{flex-shrink:0}.script-editor{margin-bottom:10px}.script-fields{display:grid;grid-template-columns:1fr 1.5fr;gap:12px}.script-fields .ant-form-item{margin-bottom:12px}.log-output{min-height:360px;max-height:58vh;overflow:auto;margin:0;padding:12px 0;border-radius:4px;background:#111827;color:#d1fae5;font:12px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}.log-line{min-height:19px}.log-line:hover{background:#ffffff0a}.log-line code{padding:0 15px;overflow-wrap:anywhere;color:inherit;white-space:pre-wrap;font:inherit}.log-link{color:#7dd3fc;text-decoration:underline;text-underline-offset:2px}.log-link:hover{color:#bae6fd}.ansi-black{color:#475569}.ansi-red,.log-error{color:#fb7185}.ansi-green,.log-success{color:#86efac}.ansi-yellow,.log-warning{color:#fcd34d}.ansi-blue{color:#93c5fd}.ansi-magenta{color:#f0abfc}.ansi-cyan{color:#67e8f9}.ansi-gray{color:#94a3b8}.ansi-white{color:#f8fafc}@media(max-width:1100px){.squoosh-studio{grid-template-columns:1fr}.squoosh-settings{border-top:1px solid #e1e5ed}.preview-pane{min-height:440px}}@media(max-width:650px){.sidebar{width:64px!important;min-width:64px!important;padding:20px 8px}.brand{padding:0 10px 25px;font-size:0}.brand span{font-size:20px}.sidebar .ant-menu-title-content,.sidebar-footer{display:none}.sidebar-bottom{right:8px;bottom:20px;left:8px}.page{padding:32px 20px}.service-card,.page-header{align-items:flex-start;flex-direction:column}.script-fields{grid-template-columns:1fr}.token-row{grid-template-columns:1fr;gap:10px;padding:14px 12px}.squoosh-page{min-height:calc(100vh - 64px);margin:-12px -20px -32px;padding:18px 20px 28px}.squoosh-header{align-items:flex-start;flex-direction:column}.squoosh-preview-area{grid-template-columns:1fr}.preview-pane{min-height:360px}.preview-pane+.preview-pane{border-top:1px solid #e1e5ed;border-left:0}.preview-canvas{min-height:300px}}.standalone-log-container{display:flex;flex-direction:column;height:100vh;background:#111827;overflow:hidden}.standalone-log-header{display:flex;justify-content:space-between;align-items:center;padding:12px 24px;background:#1f2937;border-bottom:1px solid #374151;color:#f3f4f6;flex-shrink:0}.standalone-log-body{flex:1;min-height:0;display:flex;flex-direction:column;background:#111827}.standalone-log-body .log-output{flex:1;min-height:0;max-height:none!important;border-radius:0!important;margin:0!important;padding:16px 0}.page.is-static-page{padding:0!important;overflow:hidden}.static-page-container{width:100%;height:100%;overflow:hidden;background:#fff}.static-page-iframe{width:100%;height:100%;border:none;display:block}.sidebar-bottom-inner{display:flex;align-items:center;gap:4px}.sidebar-bottom-inner .ant-menu{flex:1;min-width:0}.sidebar-error-btn{color:#a6adb4!important;border:none!important;background:transparent!important;display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:6px;flex-shrink:0;transition:all .2s}.sidebar-error-btn:hover{color:#ff4d4f!important;background:#ff4d4f26!important}.sidebar-error-btn.is-active{color:#fff!important;background:#ff4d4f!important}