taskia-prototype-mcp 3.0.0 → 3.1.0

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.
Files changed (3) hide show
  1. package/README.md +129 -120
  2. package/package.json +24 -24
  3. package/server.js +439 -7
package/README.md CHANGED
@@ -1,120 +1,129 @@
1
- # taskia-prototype-mcp
2
-
3
- MCP server that lets agents **browse and see** the live Taskia design prototype.
4
-
5
- The MCP opens a real Chromium browser pointed at the published GitHub Pages URL.
6
- Agents can navigate screens, click UI elements, hover for tooltips, scroll — and every
7
- action returns a screenshot so they can see exactly what happened.
8
-
9
- Because it connects to the **live GitHub Pages deployment**, agents always see
10
- Andreina's latest design — no local clone, no `REPO_PATH` required.
11
-
12
- ---
13
-
14
- ## Tools
15
-
16
- | Tool | What it does |
17
- |---|---|
18
- | `screenshot` | PNG of whatever is visible right now |
19
- | `go_to_screen` | Navigate to a named screen + screenshot |
20
- | `click` | Click an element by visible text + screenshot |
21
- | `hover` | Hover an element (tooltips / hover states) + screenshot |
22
- | `scroll` | Scroll the page + screenshot |
23
- | `list_interactive_elements` | Every clickable element currently on screen |
24
- | `reload` | Reload from GitHub Pages (picks up Andreina's latest push) |
25
- | `reset` | Return to default screen (Tasks, signed in) |
26
- | `list_screens` | All screen names available in the prototype |
27
- | `get_design_tokens` | CSS custom properties (light + dark) from live `styles.css` |
28
- | `get_design_system_docs` | Full `DESIGN_SYSTEM.md` |
29
- | `get_prototype_url` | The live GitHub Pages URL |
30
-
31
- ---
32
-
33
- ## Setup
34
-
35
- ### 1. Clone the repo
36
-
37
- ```bash
38
- git clone https://github.com/Omicron-Group-Solutions/prototipo_diseno.git
39
- cd prototipo_diseno/mcp-server
40
- ```
41
-
42
- ### 2. Install dependencies + Chromium
43
-
44
- ```bash
45
- npm install
46
- npm run install-browser
47
- ```
48
-
49
- > `install-browser` downloads a local Chromium (~130 MB, one-time).
50
-
51
- ### 3. Add to Claude Code
52
-
53
- Edit `~/.claude/claude_desktop_config.json`:
54
-
55
- ```json
56
- {
57
- "mcpServers": {
58
- "taskia-prototype": {
59
- "command": "node",
60
- "args": ["C:\\Users\\You\\Desktop\\prototipo_diseno\\mcp-server\\server.js"]
61
- }
62
- }
63
- }
64
- ```
65
-
66
- > No `REPO_PATH` needed — the MCP fetches everything from GitHub Pages.
67
-
68
- ### 4. Restart Claude Code
69
-
70
- ---
71
-
72
- ## Watch the browser (optional)
73
-
74
- Add `HEADLESS=false` to see the browser window on screen while agents drive it:
75
-
76
- ```json
77
- {
78
- "mcpServers": {
79
- "taskia-prototype": {
80
- "command": "node",
81
- "args": ["C:\\Users\\You\\Desktop\\prototipo_diseno\\mcp-server\\server.js"],
82
- "env": {
83
- "HEADLESS": "false"
84
- }
85
- }
86
- }
87
- }
88
- ```
89
-
90
- ---
91
-
92
- ## Staying up to date
93
-
94
- When Andreina pushes a design update, call `reload()` — the browser refreshes from
95
- GitHub Pages and returns a screenshot of the new design. No restart needed.
96
-
97
- ---
98
-
99
- ## Example usage
100
-
101
- ```
102
- "Show me the Tasks screen"
103
- → go_to_screen({ screen_name: "ScreenTasks" })
104
-
105
- "What can I click on the Kanban board?"
106
- → go_to_screen({ screen_name: "ScreenKanban" })
107
- list_interactive_elements()
108
-
109
- "Click that task to open the detail panel"
110
- → click({ text: "Diseño nuevo sistema" })
111
-
112
- "Show the hover state on the sidebar"
113
- → hover({ text: "Kanban" })
114
-
115
- "What color is the primary button?"
116
- → get_design_tokens() ← look for --brand or --btn-*
117
-
118
- "Andreina just pushed an update show me"
119
- → reload()
120
- ```
1
+ # taskia-prototype-mcp
2
+
3
+ MCP server that lets agents **browse and see** the live Taskia design prototype.
4
+
5
+ The MCP opens a real Chromium browser pointed at the published GitHub Pages URL.
6
+ Agents can navigate screens, click UI elements, hover for tooltips, scroll — and every
7
+ action returns a screenshot so they can see exactly what happened.
8
+
9
+ Because it connects to the **live GitHub Pages deployment**, agents always see
10
+ Andreina's latest design — no local clone, no `REPO_PATH` required.
11
+
12
+ ---
13
+
14
+ ## Tools
15
+
16
+ | Tool | What it does |
17
+ |---|---|
18
+ | `screenshot` | PNG of whatever is visible right now |
19
+ | `go_to_screen` | Navigate to a named screen + screenshot |
20
+ | `click` | Click an element by visible text + screenshot |
21
+ | `hover` | Hover an element (tooltips / hover states) + screenshot |
22
+ | `scroll` | Scroll the page + screenshot |
23
+ | `list_interactive_elements` | Every clickable element currently on screen |
24
+ | `get_html` | Cleaned, pretty-printed **rendered HTML** of a screen or element — near copy-paste ready |
25
+ | `get_css` | **Authored + computed CSS** for an element (matched rules with `var()` names, plus resolved px/color values) |
26
+ | `reload` | Reload from GitHub Pages (picks up Andreina's latest push) |
27
+ | `reset` | Return to default screen (Tasks, signed in) |
28
+ | `list_screens` | All screen names available in the prototype |
29
+ | `get_design_tokens` | CSS custom properties (light + dark) from live `styles.css` |
30
+ | `get_design_system_docs` | Full `DESIGN_SYSTEM.md` |
31
+ | `get_prototype_url` | The live GitHub Pages URL |
32
+
33
+ ---
34
+
35
+ ## Setup
36
+
37
+ ### 1. Clone the repo
38
+
39
+ ```bash
40
+ git clone https://github.com/Omicron-Group-Solutions/prototipo_diseno.git
41
+ cd prototipo_diseno/mcp-server
42
+ ```
43
+
44
+ ### 2. Install dependencies + Chromium
45
+
46
+ ```bash
47
+ npm install
48
+ npm run install-browser
49
+ ```
50
+
51
+ > `install-browser` downloads a local Chromium (~130 MB, one-time).
52
+
53
+ ### 3. Add to Claude Code
54
+
55
+ Edit `~/.claude/claude_desktop_config.json`:
56
+
57
+ ```json
58
+ {
59
+ "mcpServers": {
60
+ "taskia-prototype": {
61
+ "command": "node",
62
+ "args": ["C:\\Users\\You\\Desktop\\prototipo_diseno\\mcp-server\\server.js"]
63
+ }
64
+ }
65
+ }
66
+ ```
67
+
68
+ > No `REPO_PATH` needed — the MCP fetches everything from GitHub Pages.
69
+
70
+ ### 4. Restart Claude Code
71
+
72
+ ---
73
+
74
+ ## Watch the browser (optional)
75
+
76
+ Add `HEADLESS=false` to see the browser window on screen while agents drive it:
77
+
78
+ ```json
79
+ {
80
+ "mcpServers": {
81
+ "taskia-prototype": {
82
+ "command": "node",
83
+ "args": ["C:\\Users\\You\\Desktop\\prototipo_diseno\\mcp-server\\server.js"],
84
+ "env": {
85
+ "HEADLESS": "false"
86
+ }
87
+ }
88
+ }
89
+ }
90
+ ```
91
+
92
+ ---
93
+
94
+ ## Staying up to date
95
+
96
+ When Andreina pushes a design update, call `reload()` — the browser refreshes from
97
+ GitHub Pages and returns a screenshot of the new design. No restart needed.
98
+
99
+ ---
100
+
101
+ ## Example usage
102
+
103
+ ```
104
+ "Show me the Tasks screen"
105
+ go_to_screen({ screen_name: "ScreenTasks" })
106
+
107
+ "What can I click on the Kanban board?"
108
+ → go_to_screen({ screen_name: "ScreenKanban" })
109
+ list_interactive_elements()
110
+
111
+ "Click that task to open the detail panel"
112
+ click({ text: "Diseño nuevo sistema" })
113
+
114
+ "Show the hover state on the sidebar"
115
+ hover({ text: "Kanban" })
116
+
117
+ "What color is the primary button?"
118
+ get_design_tokens() ← look for --brand or --btn-*
119
+
120
+ "Give me the markup for this card so I can rebuild it"
121
+ → get_html({ selector: ".doc-card" }) ← or { text: "Factura" }
122
+
123
+ "What CSS makes the sidebar look like that?"
124
+ → get_css({ selector: ".sidebar" }) ← matched rules + resolved values
125
+ → get_css({ selector: ".sidebar", all: true }) ← full computed style dump
126
+
127
+ "Andreina just pushed an update — show me"
128
+ → reload()
129
+ ```
package/package.json CHANGED
@@ -1,24 +1,24 @@
1
- {
2
- "name": "taskia-prototype-mcp",
3
- "version": "3.0.0",
4
- "description": "MCP server — browse the live Taskia prototype, screenshot any screen, interact with UI elements",
5
- "type": "module",
6
- "bin": {
7
- "taskia-prototype-mcp": "server.js"
8
- },
9
- "files": [
10
- "server.js"
11
- ],
12
- "scripts": {
13
- "start": "node server.js",
14
- "install-browser": "playwright install chromium",
15
- "postinstall": "playwright install chromium"
16
- },
17
- "publishConfig": {
18
- "access": "public"
19
- },
20
- "dependencies": {
21
- "@modelcontextprotocol/sdk": "^1.12.0",
22
- "playwright": "^1.49.0"
23
- }
24
- }
1
+ {
2
+ "name": "taskia-prototype-mcp",
3
+ "version": "3.1.0",
4
+ "description": "MCP server — browse the live Taskia prototype, screenshot any screen, interact with UI elements, extract rendered HTML/CSS",
5
+ "type": "module",
6
+ "bin": {
7
+ "taskia-prototype-mcp": "server.js"
8
+ },
9
+ "files": [
10
+ "server.js"
11
+ ],
12
+ "scripts": {
13
+ "start": "node server.js",
14
+ "install-browser": "playwright install chromium",
15
+ "postinstall": "playwright install chromium"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "dependencies": {
21
+ "@modelcontextprotocol/sdk": "^1.12.0",
22
+ "playwright": "^1.49.0"
23
+ }
24
+ }
package/server.js CHANGED
@@ -19,6 +19,8 @@
19
19
  * hover — hover an element (tooltips / hover states)
20
20
  * scroll — scroll the page in any direction
21
21
  * list_interactive_elements — every clickable element on screen
22
+ * get_html — cleaned, pretty-printed rendered HTML of a screen/element
23
+ * get_css — authored + resolved computed CSS for an element
22
24
  * reload — reload from GitHub Pages (picks up latest push)
23
25
  * reset — return to default screen (Tasks, signed in)
24
26
  * list_screens — all screen names in the prototype
@@ -54,6 +56,96 @@ const SCREEN_IDS = {
54
56
  ScreenLabels: 'labels',
55
57
  };
56
58
 
59
+ // Pointer CRM — its own React SPA under /crm/ (separate page from the Taskia
60
+ // SPA). Reached by loading crm/index.html once, then driving it with the same
61
+ // window.__setScreen(id) hook Taskia uses. window.__APP === 'crm' marks it.
62
+ const CRM_APP_URL = 'crm/index.html';
63
+ const CRM_SCREENS = {
64
+ CrmContacts: 'contacts', // listado personas/empresas — entry point
65
+ CrmContact: 'contact', // ficha de contacto
66
+ CrmCompany: 'company', // ficha de empresa
67
+ CrmNewContact: 'new-contact', // alta de contacto
68
+ CrmDeals: 'deals', // pipeline (kanban + lista)
69
+ CrmDeal: 'deal', // ficha de oportunidad
70
+ };
71
+
72
+ // Pointer ERP — its own React SPA under /pointer/ (the full 6-module ERP).
73
+ // Same mechanics as the CRM SPA: load pointer/index.html once, then drive it
74
+ // with window.__setScreen(id). window.__APP === 'pointer' marks it. Screen ids
75
+ // mirror the source app route hashes (e.g. `currency`, `purchase-request`).
76
+ const POINTER_APP_URL = 'pointer/index.html';
77
+ const POINTER_SCREENS = {
78
+ PointerHome: 'home',
79
+ PointerContactView: 'contact-view',
80
+ PointerContactGroups: 'contact-groups',
81
+ PointerPaymentCondition: 'payment-condition',
82
+ PointerCurrency: 'currency',
83
+ PointerAdvancesReceived: 'advances-received',
84
+ PointerAdvancesGiven: 'advances-given',
85
+ PointerIncome: 'income',
86
+ PointerExpenses: 'expenses',
87
+ PointerAccountPayable: 'account-payable',
88
+ PointerAccountReceivable: 'account-receivable',
89
+ PointerCollection: 'collection',
90
+ PointerPayment: 'payment',
91
+ PointerCurrencyExchange: 'currency-exchange',
92
+ PointerFundTransfer: 'fund-transfer',
93
+ PointerCashRegister: 'cash-register',
94
+ PointerBankAccount: 'bank-account',
95
+ PointerSerie: 'serie',
96
+ PointerAdministrationConfiguration: 'administration-configuration',
97
+ PointerLocation: 'location',
98
+ PointerPriceList: 'price-list',
99
+ PointerItem: 'item',
100
+ PointerUnit: 'unit',
101
+ PointerVariable: 'variable',
102
+ PointerDeliveryNote: 'delivery-note',
103
+ PointerReceiptNote: 'receipt-note',
104
+ PointerConfigurationDescription: 'configuration-description',
105
+ PointerOrderPreparationRequest: 'order-preparation-request',
106
+ PointerOrderPreparation: 'order-preparation',
107
+ PointerInventoryCheckPlanning: 'inventory-check-planning',
108
+ PointerInventoryCheck: 'inventory-check',
109
+ PointerInventoryTransfer: 'inventory-transfer',
110
+ PointerInventoryAdjustment: 'inventory-adjustment',
111
+ PointerProductionOrder: 'production-order',
112
+ PointerCategoryItem: 'category-item',
113
+ PointerBrand: 'brand',
114
+ PointerWarehouseConfiguration: 'warehouse-configuration',
115
+ PointerPurchaseRequest: 'purchase-request',
116
+ PointerPurchaseOrder: 'purchase-order',
117
+ PointerPurchaseInvoice: 'purchase-invoice',
118
+ PointerDebitNoteSuppliers: 'debit-note-suppliers',
119
+ PointerCreditNoteSuppliers: 'credit-note-suppliers',
120
+ PointerPurchaseConfiguration: 'purchase-configuration',
121
+ PointerAccountingAccount: 'accounting-account',
122
+ PointerAccountingAccountGroup: 'accounting-account-group',
123
+ PointerManualVoucher: 'manual-voucher',
124
+ PointerJournalEntry: 'journal-entry',
125
+ PointerTaxes: 'taxes',
126
+ PointerBanks: 'banks',
127
+ PointerBankReconciliation: 'bank-reconciliation',
128
+ PointerAccountingConfiguration: 'accounting-configuration',
129
+ PointerOpportunity: 'opportunity',
130
+ PointerSalesQuote: 'sales-quote',
131
+ PointerSalesOrder: 'sales-order',
132
+ PointerSalesInvoice: 'sales-invoice',
133
+ PointerCreditNoteCustomers: 'credit-note-customers',
134
+ PointerDebitNoteCustomers: 'debit-note-customers',
135
+ PointerAgreements: 'agreements',
136
+ PointerProjectCost: 'project-cost',
137
+ PointerProjectCostReturn: 'project-cost-return',
138
+ PointerSalesConfiguration: 'sales-configuration',
139
+ PointerLabelBuilder: 'label-builder',
140
+ PointerZone: 'zone',
141
+ PointerGeneralConfiguration: 'general-configuration',
142
+ };
143
+
144
+ /** Absolute GitHub Pages URL for a path relative to the deployment root. */
145
+ function pagesUrl(path) {
146
+ return PAGES_URL.replace(/\/$/, '') + '/' + path.replace(/^\//, '');
147
+ }
148
+
57
149
  // ── Persistent browser session ─────────────────────────────────────────────
58
150
 
59
151
  let _browser = null;
@@ -82,7 +174,11 @@ async function getPage() {
82
174
 
83
175
  _page = await ctx.newPage();
84
176
  await _page.goto(PAGES_URL, { waitUntil: 'networkidle', timeout: 30_000 });
85
- await _page.waitForSelector('.app', { timeout: 20_000 });
177
+ // The deployment root is the Design Hub, which does not mount `.app` — the
178
+ // Taskia / CRM / Pointer SPAs each mount it on their own page. Wait for `.app`
179
+ // if this happens to be an SPA page, but don't fail on the hub: go_to_screen
180
+ // navigates to the right SPA and waits for `.app` there.
181
+ await _page.waitForSelector('.app', { timeout: 5_000 }).catch(() => {});
86
182
 
87
183
  return _page;
88
184
  }
@@ -111,6 +207,29 @@ async function fetchAsset(path) {
111
207
  return res.text();
112
208
  }
113
209
 
210
+ /**
211
+ * Resolve a target element for get_html / get_css.
212
+ * Precedence: explicit CSS `selector` → visible `text` → the whole screen (.app).
213
+ * Returns a Playwright ElementHandle plus a human label for the response.
214
+ */
215
+ async function resolveHandle(page, { selector, text } = {}) {
216
+ if (selector) {
217
+ const handle = await page.$(selector);
218
+ if (!handle) throw new Error(`No element matches CSS selector "${selector}".`);
219
+ return { handle, label: `selector "${selector}"` };
220
+ }
221
+ if (text) {
222
+ const loc = page.getByText(text, { exact: false }).first();
223
+ if ((await loc.count()) === 0)
224
+ throw new Error(`No element found with text "${text}". Call list_interactive_elements to see what is on screen.`);
225
+ const handle = await loc.elementHandle();
226
+ return { handle, label: `text "${text}"` };
227
+ }
228
+ const handle = (await page.$('.app')) || (await page.$('body'));
229
+ if (!handle) throw new Error('Nothing is loaded on the page yet.');
230
+ return { handle, label: '.app (whole screen)' };
231
+ }
232
+
114
233
  function parseDesignTokens(css) {
115
234
  const light = {}, dark = {};
116
235
  for (const [, block] of css.matchAll(/:root(?:\[.*?\])?\s*\{([^}]+)\}/g))
@@ -125,7 +244,7 @@ function parseDesignTokens(css) {
125
244
  // ── MCP server ─────────────────────────────────────────────────────────────
126
245
 
127
246
  const server = new Server(
128
- { name: 'taskia-prototype', version: '3.0.0' },
247
+ { name: 'taskia-prototype', version: '3.1.0' },
129
248
  { capabilities: { tools: {} } }
130
249
  );
131
250
 
@@ -150,8 +269,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
150
269
  screen_name: {
151
270
  type: 'string',
152
271
  description:
153
- 'Screen component name, e.g. ScreenTasks, ScreenKanban, ScreenLogin, ' +
154
- 'ScreenDashboard, ScreenCalendar, ScreenReports, ScreenContacts, ScreenLabels.',
272
+ 'Taskia app screen, e.g. ScreenTasks, ScreenKanban, ScreenLogin, ScreenDashboard, ' +
273
+ 'ScreenCalendar, ScreenReports, ScreenContacts, ScreenLabels. ' +
274
+ 'Or a standalone Pointer CRM screen: CrmContacts, CrmContact, CrmCompany, ' +
275
+ 'CrmNewContact, CrmDeals, CrmDeal. Call list_screens for the full list.',
155
276
  },
156
277
  },
157
278
  },
@@ -219,6 +340,59 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
219
340
  'Use this before calling click() to discover what is available.',
220
341
  inputSchema: { type: 'object', properties: {} },
221
342
  },
343
+ {
344
+ name: 'get_html',
345
+ description:
346
+ 'Returns the RENDERED, cleaned, pretty-printed HTML of the current screen (or a specific ' +
347
+ 'element). Since the prototype is React, this is the exact final markup — scripts/styles ' +
348
+ 'and event-handler attributes are stripped, whitespace is collapsed, so it is close to ' +
349
+ 'copy-paste ready when translating a screen to code. ' +
350
+ 'With no arguments it dumps the whole screen (.app). Pass "selector" (any CSS selector) or ' +
351
+ '"text" (visible label) to scope to one component.',
352
+ inputSchema: {
353
+ type: 'object',
354
+ properties: {
355
+ selector: {
356
+ type: 'string',
357
+ description: 'CSS selector of the element to extract, e.g. ".doc-editor" or "#sidebar". Takes precedence over text.',
358
+ },
359
+ text: {
360
+ type: 'string',
361
+ description: 'Visible text of the element to extract (partial match). Used when no selector is given.',
362
+ },
363
+ max_depth: {
364
+ type: 'number',
365
+ description: 'Max nesting depth before children are collapsed with a comment. Default 40 (effectively the full tree). Lower it for a quick structural overview.',
366
+ },
367
+ },
368
+ },
369
+ },
370
+ {
371
+ name: 'get_css',
372
+ description:
373
+ 'Returns the CSS for an element two ways: (1) the AUTHORED rules from the stylesheets that ' +
374
+ 'actually match it (with var() names and media queries intact), and (2) the KEY COMPUTED ' +
375
+ 'styles — the final resolved px/color values the browser painted. This is the accurate ' +
376
+ 'source of truth for translating a component to code. ' +
377
+ 'Target the element with "selector" or "text" (defaults to the whole screen).',
378
+ inputSchema: {
379
+ type: 'object',
380
+ properties: {
381
+ selector: {
382
+ type: 'string',
383
+ description: 'CSS selector of the element to inspect. Takes precedence over text.',
384
+ },
385
+ text: {
386
+ type: 'string',
387
+ description: 'Visible text of the element to inspect (partial match). Used when no selector is given.',
388
+ },
389
+ all: {
390
+ type: 'boolean',
391
+ description: 'Return the full computed style (~350 properties) instead of the curated key set. Default false.',
392
+ },
393
+ },
394
+ },
395
+ },
222
396
  {
223
397
  name: 'reload',
224
398
  description:
@@ -281,7 +455,60 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
281
455
  case 'go_to_screen': {
282
456
  const page = await getPage();
283
457
  const screenName = args.screen_name ?? '';
284
- const id = SCREEN_IDS[screenName] ?? screenName.toLowerCase();
458
+
459
+ // ── Pointer CRM SPA screens ──
460
+ if (CRM_SCREENS[screenName]) {
461
+ const id = CRM_SCREENS[screenName];
462
+ // Load the CRM SPA if we are not already on it (Taskia or a blank tab).
463
+ const onCrm = await page.evaluate(() => window.__APP === 'crm' && typeof window.__setScreen === 'function');
464
+ if (!onCrm) {
465
+ await page.goto(pagesUrl(CRM_APP_URL), { waitUntil: 'networkidle', timeout: 30_000 });
466
+ await page.waitForSelector('.app', { timeout: 20_000 });
467
+ await page.waitForFunction(() => typeof window.__setScreen === 'function', { timeout: 20_000 });
468
+ }
469
+ await page.evaluate((sid) => window.__setScreen(sid), id);
470
+ await page.waitForTimeout(500);
471
+
472
+ const png = await snap();
473
+ return {
474
+ content: [
475
+ { type: 'text', text: `Navigated to CRM screen ${screenName} (id: "${id}")` },
476
+ { type: 'image', data: png, mimeType: 'image/png' },
477
+ ],
478
+ };
479
+ }
480
+
481
+ // ── Pointer ERP SPA screens ──
482
+ if (POINTER_SCREENS[screenName]) {
483
+ const id = POINTER_SCREENS[screenName];
484
+ const onPointer = await page.evaluate(() => window.__APP === 'pointer' && typeof window.__setScreen === 'function');
485
+ if (!onPointer) {
486
+ await page.goto(pagesUrl(POINTER_APP_URL), { waitUntil: 'networkidle', timeout: 30_000 });
487
+ await page.waitForSelector('.app', { timeout: 20_000 });
488
+ await page.waitForFunction(() => typeof window.__setScreen === 'function', { timeout: 20_000 });
489
+ }
490
+ await page.evaluate((sid) => window.__setScreen(sid), id);
491
+ await page.waitForTimeout(500);
492
+
493
+ const png = await snap();
494
+ return {
495
+ content: [
496
+ { type: 'text', text: `Navigated to Pointer screen ${screenName} (id: "${id}")` },
497
+ { type: 'image', data: png, mimeType: 'image/png' },
498
+ ],
499
+ };
500
+ }
501
+
502
+ // ── Taskia SPA screens ──
503
+ const id = SCREEN_IDS[screenName] ?? screenName.toLowerCase();
504
+
505
+ // We may currently be on the CRM SPA (or a non-SPA page) — reload the
506
+ // Taskia shell before driving it. Distinguish by the __APP marker.
507
+ const onTaskia = await page.evaluate(() => typeof window.__setScreen === 'function' && window.__APP !== 'crm' && window.__APP !== 'pointer');
508
+ if (!onTaskia) {
509
+ await page.goto(PAGES_URL, { waitUntil: 'networkidle', timeout: 30_000 });
510
+ await page.waitForSelector('.app', { timeout: 20_000 });
511
+ }
285
512
 
286
513
  if (id === 'login') {
287
514
  await page.evaluate(() => window.__setSignedIn?.(false));
@@ -432,11 +659,201 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
432
659
  };
433
660
  }
434
661
 
662
+ // ── get_html ──────────────────────────────────────────────────────────
663
+ case 'get_html': {
664
+ const page = await getPage();
665
+ const { handle, label } = await resolveHandle(page, args);
666
+ const maxDepth = args.max_depth ?? 40;
667
+
668
+ const html = await handle.evaluate((el, maxDepth) => {
669
+ const VOID = new Set([
670
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
671
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
672
+ ]);
673
+
674
+ const esc = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
675
+ const escAttr = s => s.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
676
+
677
+ function serialize(node, depth) {
678
+ const pad = ' '.repeat(depth);
679
+
680
+ // text node
681
+ if (node.nodeType === 3) {
682
+ const t = node.textContent.replace(/\s+/g, ' ').trim();
683
+ return t ? pad + esc(t) : '';
684
+ }
685
+ if (node.nodeType !== 1) return '';
686
+
687
+ const tag = node.tagName.toLowerCase();
688
+ if (tag === 'script' || tag === 'style' || tag === 'noscript') return '';
689
+
690
+ // attributes — drop event handlers, keep everything else (class, style, data-*, aria-*)
691
+ let attrs = '';
692
+ for (const a of node.attributes) {
693
+ if (/^on/i.test(a.name)) continue;
694
+ attrs += a.value === '' ? ` ${a.name}` : ` ${a.name}="${escAttr(a.value)}"`;
695
+ }
696
+ const open = `<${tag}${attrs}>`;
697
+ if (VOID.has(tag)) return pad + open;
698
+
699
+ const kids = [...node.childNodes];
700
+ if (depth >= maxDepth && node.children.length) {
701
+ return `${pad}${open}<!-- …${node.children.length} child element(s) collapsed --></${tag}>`;
702
+ }
703
+
704
+ const rendered = kids.map(k => serialize(k, depth + 1)).filter(Boolean);
705
+ if (rendered.length === 0) return pad + open + `</${tag}>`;
706
+
707
+ // inline when the only content is a single text node
708
+ if (rendered.length === 1 && kids.filter(k => k.nodeType === 1).length === 0) {
709
+ return pad + open + rendered[0].trim() + `</${tag}>`;
710
+ }
711
+ return pad + open + '\n' + rendered.join('\n') + '\n' + pad + `</${tag}>`;
712
+ }
713
+
714
+ return serialize(el, 0);
715
+ }, maxDepth);
716
+
717
+ await handle.dispose();
718
+
719
+ return {
720
+ content: [{
721
+ type: 'text',
722
+ text: `Rendered HTML for ${label}:\n\n\`\`\`html\n${html}\n\`\`\``,
723
+ }],
724
+ };
725
+ }
726
+
727
+ // ── get_css ───────────────────────────────────────────────────────────
728
+ case 'get_css': {
729
+ const page = await getPage();
730
+ const { handle, label } = await resolveHandle(page, args);
731
+ const wantAll = args.all ?? false;
732
+
733
+ const data = await handle.evaluate((el, wantAll) => {
734
+ const cs = getComputedStyle(el);
735
+
736
+ // Curated, translation-relevant properties (the noise-free default).
737
+ const PROPS = [
738
+ 'display', 'position', 'top', 'right', 'bottom', 'left', 'z-index',
739
+ 'box-sizing', 'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height',
740
+ 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
741
+ 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
742
+ 'flex-direction', 'flex-wrap', 'justify-content', 'align-items', 'align-content',
743
+ 'align-self', 'gap', 'row-gap', 'column-gap', 'flex-grow', 'flex-shrink', 'flex-basis', 'order',
744
+ 'grid-template-columns', 'grid-template-rows', 'grid-template-areas', 'grid-auto-flow',
745
+ 'grid-auto-columns', 'grid-auto-rows', 'grid-column', 'grid-row',
746
+ 'font-family', 'font-size', 'font-weight', 'font-style', 'line-height', 'letter-spacing',
747
+ 'text-align', 'text-transform', 'text-decoration-line', 'white-space', 'text-overflow',
748
+ 'color', 'background-color', 'background-image', 'background-size', 'background-position',
749
+ 'border-width', 'border-style', 'border-color', 'border-radius',
750
+ 'box-shadow', 'outline', 'opacity', 'transform', 'transition', 'filter', 'backdrop-filter',
751
+ 'cursor', 'overflow', 'overflow-x', 'overflow-y', 'object-fit',
752
+ ];
753
+
754
+ // Values that are just the CSS initial default — skip them in curated mode.
755
+ const SKIP = {
756
+ 'transform': 'none', 'box-shadow': 'none', 'background-image': 'none',
757
+ 'filter': 'none', 'backdrop-filter': 'none', 'opacity': '1', 'z-index': 'auto',
758
+ 'letter-spacing': 'normal', 'text-transform': 'none', 'text-decoration-line': 'none',
759
+ 'outline': 'rgb(0, 0, 0) none 0px', 'transition': 'all 0s ease 0s',
760
+ 'text-overflow': 'clip', 'object-fit': 'fill', 'text-align': 'start',
761
+ 'align-self': 'auto', 'order': '0', 'flex-grow': '0', 'flex-shrink': '1',
762
+ 'top': 'auto', 'right': 'auto', 'bottom': 'auto', 'left': 'auto',
763
+ 'min-width': 'auto', 'min-height': 'auto', 'max-width': 'none', 'max-height': 'none',
764
+ 'flex-basis': 'auto', 'box-sizing': 'border-box', 'font-style': 'normal',
765
+ 'white-space': 'normal', 'cursor': 'auto',
766
+ 'overflow': 'visible', 'overflow-x': 'visible', 'overflow-y': 'visible',
767
+ 'gap': 'normal', 'row-gap': 'normal', 'column-gap': 'normal',
768
+ 'background-size': 'auto', 'background-position': '0% 0%',
769
+ 'grid-template-columns': 'none', 'grid-template-rows': 'none',
770
+ 'grid-template-areas': 'none', 'grid-auto-flow': 'row',
771
+ 'grid-auto-columns': 'auto', 'grid-auto-rows': 'auto',
772
+ 'grid-column': 'auto', 'grid-row': 'auto',
773
+ };
774
+
775
+ const computed = {};
776
+ const list = wantAll ? Array.from(cs).sort() : PROPS;
777
+ for (const p of list) {
778
+ const v = cs.getPropertyValue(p).trim();
779
+ if (!v) continue;
780
+ if (!wantAll && SKIP[p] === v) continue;
781
+ computed[p] = v;
782
+ }
783
+
784
+ // Authored rules from the stylesheets that match this element.
785
+ const matched = [];
786
+ const testMatch = (sel) => {
787
+ // strip pseudo-classes/elements that el.matches() can't evaluate
788
+ const clean = sel.replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim();
789
+ if (!clean) return false;
790
+ try { return el.matches(clean); } catch { return false; }
791
+ };
792
+ const walk = (rules, condition) => {
793
+ for (const rule of rules) {
794
+ if (rule.type === 1 && rule.selectorText) { // CSSStyleRule
795
+ const sels = rule.selectorText.split(',').map(s => s.trim());
796
+ if (sels.some(testMatch)) {
797
+ matched.push(condition ? `@media ${condition} {\n ${rule.cssText}\n}` : rule.cssText);
798
+ }
799
+ } else if (rule.cssRules && (rule.type === 4 || rule.type === 12)) { // media / supports
800
+ walk(rule.cssRules, rule.conditionText || condition);
801
+ }
802
+ }
803
+ };
804
+ for (const sheet of document.styleSheets) {
805
+ let rules;
806
+ try { rules = sheet.cssRules; } catch { continue; } // cross-origin sheet
807
+ if (rules) walk(rules, null);
808
+ }
809
+
810
+ const cls = (typeof el.className === 'string' && el.className.trim())
811
+ ? '.' + el.className.trim().split(/\s+/).join('.') : '';
812
+ const desc = el.tagName.toLowerCase() + (el.id ? `#${el.id}` : '') + cls;
813
+
814
+ return { desc, computed, matched };
815
+ }, wantAll);
816
+
817
+ await handle.dispose();
818
+
819
+ const computedBlock = Object.entries(data.computed)
820
+ .map(([k, v]) => ` ${k}: ${v};`).join('\n');
821
+ const matchedBlock = data.matched.length
822
+ ? data.matched.join('\n\n')
823
+ : '/* No authored rules matched — this element is styled via inline style or inherited rules only. */';
824
+
825
+ return {
826
+ content: [{
827
+ type: 'text',
828
+ text: [
829
+ `CSS for ${label}`,
830
+ `Matched element: ${data.desc}`,
831
+ '',
832
+ `### Authored rules that apply (${data.matched.length})`,
833
+ '```css',
834
+ matchedBlock,
835
+ '```',
836
+ '',
837
+ `### Key computed styles — resolved values (${Object.keys(data.computed).length})`,
838
+ '```css',
839
+ `${data.desc} {`,
840
+ computedBlock,
841
+ '}',
842
+ '```',
843
+ wantAll ? '' : '\n_Tip: pass `all: true` for the full computed style dump._',
844
+ ].join('\n'),
845
+ }],
846
+ };
847
+ }
848
+
435
849
  // ── reload ──────────────────────────────────────────────────────────
436
850
  case 'reload': {
437
851
  const page = await getPage();
438
852
  await page.reload({ waitUntil: 'networkidle', timeout: 30_000 });
439
- await page.waitForSelector('.app', { timeout: 20_000 });
853
+ // The SPA mounts into `.app`; standalone CRM pages do not — wait only
854
+ // for the shell when we are on the SPA so reload works on both.
855
+ const isSpa = await page.evaluate(() => typeof window.__setScreen === 'function');
856
+ if (isSpa) await page.waitForSelector('.app', { timeout: 20_000 });
440
857
  await page.waitForTimeout(500);
441
858
 
442
859
  const png = await snap();
@@ -451,6 +868,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
451
868
  // ── reset ───────────────────────────────────────────────────────────
452
869
  case 'reset': {
453
870
  const page = await getPage();
871
+ // May currently be on the CRM SPA — reload the Taskia shell first.
872
+ const onTaskia = await page.evaluate(() => typeof window.__setScreen === 'function' && window.__APP !== 'crm' && window.__APP !== 'pointer');
873
+ if (!onTaskia) {
874
+ await page.goto(PAGES_URL, { waitUntil: 'networkidle', timeout: 30_000 });
875
+ await page.waitForSelector('.app', { timeout: 20_000 });
876
+ }
454
877
  await page.evaluate(() => {
455
878
  window.__setSignedIn?.(true);
456
879
  window.__setScreen?.('tasks');
@@ -470,10 +893,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
470
893
  case 'list_screens': {
471
894
  const source = await fetchAsset('screens.jsx');
472
895
  const screens = [...source.matchAll(/^function\s+(Screen[A-Za-z]+)\s*\(/gm)].map(m => m[1]);
896
+ const crm = Object.keys(CRM_SCREENS);
897
+ const pointer = Object.keys(POINTER_SCREENS);
473
898
  return {
474
899
  content: [{
475
900
  type: 'text',
476
- text: `Screens (${screens.length}):\n${screens.map(s => ` • ${s}`).join('\n')}\n\nUse go_to_screen with any of these names.`,
901
+ text:
902
+ `Taskia app — SPA screens (${screens.length}):\n` +
903
+ `${screens.map(s => ` • ${s}`).join('\n')}\n\n` +
904
+ `Pointer CRM — standalone SPA at ${CRM_APP_URL} (${crm.length}):\n` +
905
+ `${crm.map(s => ` • ${s} → screen "${CRM_SCREENS[s]}"`).join('\n')}\n\n` +
906
+ `Pointer ERP — standalone SPA at ${POINTER_APP_URL} (${pointer.length}):\n` +
907
+ `${pointer.map(s => ` • ${s} → screen "${POINTER_SCREENS[s]}"`).join('\n')}\n\n` +
908
+ `Use go_to_screen with any of these names.`,
477
909
  }],
478
910
  };
479
911
  }