sbuilder-mcp 0.1.0 → 0.1.2
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.
- package/CHANGELOG.md +49 -0
- package/README.md +3 -1
- package/README.vi.md +3 -1
- package/dist/server.js +10 -0
- package/dist/tools/live.js +53 -2
- package/dist/transport/http.js +16 -1
- package/dist/transport/identity.js +58 -0
- package/dist/transport/media.js +74 -0
- package/dist/vision/measure.js +109 -0
- package/dist/vision/shoot.js +4 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.2 — 2026-08-29
|
|
4
|
+
|
|
5
|
+
244625
|
|
6
|
+
|
|
7
|
+
## 0.1.1 — 2026-08-28
|
|
8
|
+
|
|
9
|
+
- fix(release): ask for the one-time password instead of dying on it
|
|
10
|
+
- feat: sb_media_upload — the agent can add images
|
|
11
|
+
- feat(vision): sb_look frames one element
|
|
12
|
+
- feat: sb_review — the defects a visitor sees, not the ones a save catches
|
|
13
|
+
- fix: base style is the cascade's fallback layer, not a trap
|
|
14
|
+
- docs: one install section per README, not two
|
|
15
|
+
- feat: sbuilder-mcp install — one command, six clients
|
|
16
|
+
- feat: close the gap with a human designer — 22 tools
|
|
17
|
+
- docs: phase-6 plan — close the gap with a human designer
|
|
18
|
+
- fix: three defects a live run found that no unit test could
|
|
19
|
+
- docs: point the setup at the store's Agent app, which hands over the config
|
|
20
|
+
- feat(transport): one credential — an API key now opens the private surface too
|
|
21
|
+
- docs: phase 3 tools, the yield rule, and the wire-protocol facts
|
|
22
|
+
- feat(tools): sb_live_join, sb_look and sb_bind; writes publish to the room
|
|
23
|
+
- feat(catalog): generate the renderer's 22 binding source keys
|
|
24
|
+
- feat(vision): preview links and Chrome screenshots with real node bounding boxes
|
|
25
|
+
- feat(live): the live-edit session, with the yield rule as its organising decision
|
|
26
|
+
- feat(transport): the live-edit socket, with the editor's two reconnect bugs designed out
|
|
27
|
+
- docs: phase-3 plan (live editing and sight)
|
|
28
|
+
- docs: phase 2 tools, traps, and an end-to-end smoke check
|
|
29
|
+
- feat(tools): the page tools - open, outline, catalog search, add, set, move, remove
|
|
30
|
+
- feat(transport): load and save a page's draft document
|
|
31
|
+
- feat(site): pre-save validation mirroring the platform's own refusals
|
|
32
|
+
- feat(site): the builder - nested subtrees, per-breakpoint writes, containment rules
|
|
33
|
+
- feat(site): the in-memory page document with a compressed outline
|
|
34
|
+
- feat(site): node ids and catalog-seeded node construction
|
|
35
|
+
- feat(site): encode the four silent-failure traps as tested code
|
|
36
|
+
- feat(core): overlay-aware tree walking, with pageChildren as the safe default
|
|
37
|
+
- feat(core): the document patch primitive and its three admission rules
|
|
38
|
+
- feat(catalog): generate the 85-element catalog with its AI hints
|
|
39
|
+
- docs: phase-2 plan (the page document) and corrected element count
|
|
40
|
+
- docs: mark phase-1 plan steps complete
|
|
41
|
+
- docs: repo kit, bilingual docs, and the tool-authoring skill
|
|
42
|
+
- feat(tools): sb_connect and sb_site_list; wire the server end to end
|
|
43
|
+
- feat(tools): sb_api_find and sb_api_call - full 310-operation reach in two tools
|
|
44
|
+
- feat(catalog): intent search over the API index, with three honest body verdicts
|
|
45
|
+
- feat(catalog): generate the 310-operation API index from the platform OpenAPI doc
|
|
46
|
+
- feat(transport): route credentials by path prefix, which the OpenAPI doc cannot
|
|
47
|
+
- feat(transport): session login/refresh with a per-use token getter
|
|
48
|
+
- feat(transport): shared HTTP client with the platform error envelope and redaction
|
|
49
|
+
- feat: repo skeleton, response helpers, and a green build/test/smoke gate
|
|
50
|
+
- docs: design spec and phase-1 implementation plan for @sbuilder/mcp
|
|
51
|
+
|
|
3
52
|
## 0.1.0
|
|
4
53
|
|
|
5
54
|
First release.
|
package/README.md
CHANGED
|
@@ -81,11 +81,13 @@ make, because those mean "this person's account".
|
|
|
81
81
|
| `sb_page_create` | Create a page |
|
|
82
82
|
| `sb_publish` | Compile the draft into the live page (cascades to shared globals) |
|
|
83
83
|
| `sb_review` | Every defect a visitor would see, each with the command that fixes it |
|
|
84
|
+
| `sb_media_list` | The site's media library |
|
|
85
|
+
| `sb_media_upload` | Add an image and get its URL — the only route, the upload is multipart |
|
|
84
86
|
| `sb_live_join` | Join the editor's live-edit room as a visible peer — edits then appear live |
|
|
85
87
|
| `sb_look` | Save, render, and return screenshots plus measured node boxes |
|
|
86
88
|
| `sb_bind` | Bind a node's content to real store data |
|
|
87
89
|
|
|
88
|
-
Twenty-
|
|
90
|
+
Twenty-five tools, **310 API operations**. `sb_api_find` is an index rather than a tool per endpoint,
|
|
89
91
|
so the tool list stays short while everything the platform can do stays reachable — and
|
|
90
92
|
operations added to the platform arrive with the next `npm run codegen`.
|
|
91
93
|
|
package/README.vi.md
CHANGED
|
@@ -78,11 +78,13 @@ là "tài khoản của người này".
|
|
|
78
78
|
| `sb_page_create` | Tạo một trang |
|
|
79
79
|
| `sb_publish` | Biên dịch bản nháp thành trang live (lan sang global dùng chung) |
|
|
80
80
|
| `sb_review` | Mọi khiếm khuyết người xem sẽ thấy, kèm lệnh sửa từng cái |
|
|
81
|
+
| `sb_media_list` | Thư viện ảnh của site |
|
|
82
|
+
| `sb_media_upload` | Thêm ảnh và lấy URL — đường duy nhất, vì upload là multipart |
|
|
81
83
|
| `sb_live_join` | Vào phòng live-edit của editor như một peer nhìn thấy được — sửa gì hiện ngay |
|
|
82
84
|
| `sb_look` | Lưu, render, trả về ảnh chụp kèm box đo được của từng node |
|
|
83
85
|
| `sb_bind` | Gắn nội dung một node vào dữ liệu cửa hàng thật |
|
|
84
86
|
|
|
85
|
-
Hai mươi
|
|
87
|
+
Hai mươi lăm tool, **310 operation API**. `sb_api_find` là một chỉ mục chứ không phải mỗi endpoint một
|
|
86
88
|
tool, nên danh sách tool vẫn ngắn trong khi mọi thứ nền tảng làm được vẫn với tới — và
|
|
87
89
|
operation mới thêm bên nền tảng sẽ tự có sau lần `npm run codegen` kế tiếp.
|
|
88
90
|
|
package/dist/server.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { setAgentClient } from './transport/identity.js';
|
|
1
2
|
import { readFileSync } from 'node:fs';
|
|
2
3
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
4
|
import { Session } from './transport/auth.js';
|
|
@@ -51,6 +52,15 @@ export function buildContext() {
|
|
|
51
52
|
}
|
|
52
53
|
export function createServer(ctx = buildContext()) {
|
|
53
54
|
const server = new McpServer({ name: 'sbuilder', version: pkgVersion(), title: 'Store Builder' }, { instructions: INSTRUCTIONS });
|
|
55
|
+
// LEARN WHO LAUNCHED US, at the handshake, before any tool runs.
|
|
56
|
+
//
|
|
57
|
+
// Set here rather than after connect() because the handshake happens DURING
|
|
58
|
+
// connect: a hook attached afterwards is attached to an event that has already
|
|
59
|
+
// fired, and every call would then report an anonymous machine — the exact
|
|
60
|
+
// blindness this exists to remove.
|
|
61
|
+
server.server.oninitialized = () => {
|
|
62
|
+
setAgentClient(server.server.getClientVersion(), pkgVersion());
|
|
63
|
+
};
|
|
54
64
|
registerSessionTools(server, ctx);
|
|
55
65
|
registerApiTools(server, ctx);
|
|
56
66
|
const pageSession = registerPageTools(server, ctx);
|
package/dist/tools/live.js
CHANGED
|
@@ -3,7 +3,10 @@ import { z } from 'zod';
|
|
|
3
3
|
import { text, images } from '../mcp/response.js';
|
|
4
4
|
import { BINDING_SOURCES } from '../catalog/elements.generated.js';
|
|
5
5
|
import { previewUrl } from '../vision/preview.js';
|
|
6
|
+
import { uploadMedia } from '../transport/media.js';
|
|
7
|
+
import { request } from '../transport/http.js';
|
|
6
8
|
import { shoot, DEFAULT_WIDTHS } from '../vision/shoot.js';
|
|
9
|
+
import { measure, MEASURE_NOTICE } from '../vision/measure.js';
|
|
7
10
|
import { RealtimeSocket } from '../transport/socket.js';
|
|
8
11
|
import { LiveSession } from '../live/session.js';
|
|
9
12
|
import { siteToken } from './credentialpick.js';
|
|
@@ -60,8 +63,9 @@ export function registerLiveTools(server, ctx, session) {
|
|
|
60
63
|
});
|
|
61
64
|
server.tool('sb_look', "Save the open page, render it through the platform's own renderer, and return " +
|
|
62
65
|
'screenshots at desktop, tablet and mobile widths — plus the measured bounding box of ' +
|
|
63
|
-
'every node
|
|
64
|
-
'
|
|
66
|
+
'every node — plus any LAYOUT defect measured on the render: content past the ' +
|
|
67
|
+
'viewport, elements overlapping, text too small to read. Pass node_id to frame ONE ' +
|
|
68
|
+
'element instead of the whole page. Judge your own work from these rather than guessing.', {
|
|
65
69
|
widths: z.array(z.number().int().min(320).max(2560)).optional(),
|
|
66
70
|
with_boxes: z.boolean().optional(),
|
|
67
71
|
node_id: z
|
|
@@ -79,11 +83,58 @@ export function registerLiveTools(server, ctx, session) {
|
|
|
79
83
|
// by rule are the same act, and separating them is how the second one gets
|
|
80
84
|
// skipped.
|
|
81
85
|
const findings = reviewDesign(session.current());
|
|
86
|
+
// Measured on the render, not read off the document — a card that spills
|
|
87
|
+
// at 390px is invisible to every check that only reads the tree.
|
|
88
|
+
const visual = node_id ? [] : measure(shots);
|
|
82
89
|
return images(shots.map((s) => ({ dataBase64: s.pngBase64 })), {
|
|
83
90
|
widths: shots.map((s) => s.width),
|
|
84
91
|
...(node_id ? { framed: node_id } : {}),
|
|
85
92
|
...(with_boxes === false ? {} : { boxes: shots[0]?.boxes ?? [] }),
|
|
86
93
|
...(findings.length > 0 ? { findings, findings_notice: REVIEW_NOTICE } : {}),
|
|
94
|
+
...(visual.length > 0 ? { layout: visual, layout_notice: MEASURE_NOTICE } : {}),
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
server.tool('sb_media_list', "The site's media library — reuse an image that is already there before adding another. " +
|
|
98
|
+
'Search by name, filter by type, page with limit/offset.', {
|
|
99
|
+
site_id: z.string(),
|
|
100
|
+
search: z.string().optional(),
|
|
101
|
+
media_type: z.string().optional().describe('e.g. "image"'),
|
|
102
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
103
|
+
offset: z.number().int().min(0).optional(),
|
|
104
|
+
}, async ({ site_id, search, media_type, limit, offset }) => text(await request({
|
|
105
|
+
base: ctx.base,
|
|
106
|
+
method: 'GET',
|
|
107
|
+
path: `/api/sites/${encodeURIComponent(site_id)}/media`,
|
|
108
|
+
token: siteToken(ctx),
|
|
109
|
+
query: { search, mediaType: media_type, limit, offset },
|
|
110
|
+
fetchImpl: ctx.fetchImpl,
|
|
111
|
+
})));
|
|
112
|
+
server.tool('sb_media_upload', 'Put an image into the media library and get its URL back, ready for sb_set. Takes a ' +
|
|
113
|
+
'local file path or a URL to fetch. This is the ONLY way to add an image: the upload ' +
|
|
114
|
+
'is multipart, which sb_api_call cannot send.', {
|
|
115
|
+
site_id: z.string(),
|
|
116
|
+
path: z.string().optional().describe('A file on this machine'),
|
|
117
|
+
url: z.string().optional().describe('Fetched, then uploaded'),
|
|
118
|
+
name: z.string().optional(),
|
|
119
|
+
folder_id: z.string().optional(),
|
|
120
|
+
dry_run: z.boolean().optional(),
|
|
121
|
+
}, async ({ site_id, path, url, name, folder_id, dry_run }) => {
|
|
122
|
+
if (!path && !url)
|
|
123
|
+
throw new Error('sbuilder: give sb_media_upload either a path or a url');
|
|
124
|
+
if (dry_run !== false) {
|
|
125
|
+
return text({
|
|
126
|
+
dry_run: true,
|
|
127
|
+
would_upload: path ?? url,
|
|
128
|
+
into: site_id,
|
|
129
|
+
note: 'Nothing was sent. Re-call with dry_run:false to upload.',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const asset = await uploadMedia(ctx, site_id, { path, url, name, folderId: folder_id });
|
|
133
|
+
return text({
|
|
134
|
+
asset,
|
|
135
|
+
next: asset.url
|
|
136
|
+
? `Use it: sb_set id "<node>", namespace specials, keys { "src": ${JSON.stringify(asset.url)} }`
|
|
137
|
+
: 'Uploaded, but the server returned no url — read it back with sb_media_list.',
|
|
87
138
|
});
|
|
88
139
|
});
|
|
89
140
|
server.tool('sb_bind', "Bind a node's content to real store data, so the page shows actual products rather than " +
|
package/dist/transport/http.js
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one HTTP path to the platform.
|
|
3
|
+
*
|
|
4
|
+
* The platform writes exactly ONE error shape — {"error": "...", "code": "..."} —
|
|
5
|
+
* through httpx.WriteError, and never plain text. So an error is parsed, not
|
|
6
|
+
* stringified: `code` is the branchable half, and reading `res.statusText`
|
|
7
|
+
* instead throws it away. A bare "Conflict" reaching the model is the failure
|
|
8
|
+
* this file exists to prevent.
|
|
9
|
+
*/
|
|
10
|
+
import { identityHeaders } from './identity.js';
|
|
1
11
|
export class ApiError extends Error {
|
|
2
12
|
status;
|
|
3
13
|
code;
|
|
@@ -44,7 +54,12 @@ export function buildUrl(base, path, query) {
|
|
|
44
54
|
}
|
|
45
55
|
export async function request(opts) {
|
|
46
56
|
const doFetch = opts.fetchImpl ?? fetch;
|
|
47
|
-
|
|
57
|
+
// Identity rides on EVERY call rather than on a handshake of its own. There is
|
|
58
|
+
// no "connect" request to hang it off — the first thing this server does is
|
|
59
|
+
// whatever the agent asked for — and a separate announcement call would be one
|
|
60
|
+
// more thing that can fail while the real work succeeds, leaving a working
|
|
61
|
+
// install invisible on the operator's screen.
|
|
62
|
+
const headers = { Accept: 'application/json', ...identityHeaders() };
|
|
48
63
|
if (opts.token)
|
|
49
64
|
headers.Authorization = `Bearer ${opts.token}`;
|
|
50
65
|
if (opts.body !== undefined)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { hostname } from 'node:os';
|
|
2
|
+
let current = { client: '', clientVersion: '', host: '', server: '' };
|
|
3
|
+
/**
|
|
4
|
+
* Record who launched this server.
|
|
5
|
+
*
|
|
6
|
+
* Called once, after the MCP handshake, with the `clientInfo` the client sent.
|
|
7
|
+
* Before that — and if a client sends nothing, which is legal — the identity
|
|
8
|
+
* stays empty and the platform records an anonymous connection rather than
|
|
9
|
+
* inventing a name. An invented name would be worse than no name: it would look
|
|
10
|
+
* like a machine somebody could go and check.
|
|
11
|
+
*/
|
|
12
|
+
export function setAgentClient(info, serverVersion) {
|
|
13
|
+
current = {
|
|
14
|
+
client: info?.name ?? '',
|
|
15
|
+
clientVersion: info?.version ?? '',
|
|
16
|
+
host: machineName(),
|
|
17
|
+
server: serverVersion ? `sbuilder-mcp/${serverVersion}` : '',
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** The current identity, for tests and for the smoke check. */
|
|
21
|
+
export function agentIdentity() {
|
|
22
|
+
return current;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The machine's name.
|
|
26
|
+
*
|
|
27
|
+
* `os.hostname()` throws on some locked-down sandboxes rather than returning
|
|
28
|
+
* something useless, and a telemetry label is never worth failing a request
|
|
29
|
+
* over.
|
|
30
|
+
*/
|
|
31
|
+
function machineName() {
|
|
32
|
+
try {
|
|
33
|
+
return hostname();
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The headers that carry it.
|
|
41
|
+
*
|
|
42
|
+
* Empty fields are OMITTED rather than sent blank, so the platform's
|
|
43
|
+
* "identified itself / did not" distinction survives the wire. A header set to
|
|
44
|
+
* the empty string and a header that is absent must not mean different things
|
|
45
|
+
* here and there.
|
|
46
|
+
*/
|
|
47
|
+
export function identityHeaders(id = current) {
|
|
48
|
+
const out = {};
|
|
49
|
+
if (id.client)
|
|
50
|
+
out['X-Agent-Client'] = id.client;
|
|
51
|
+
if (id.clientVersion)
|
|
52
|
+
out['X-Agent-Client-Version'] = id.clientVersion;
|
|
53
|
+
if (id.host)
|
|
54
|
+
out['X-Agent-Host'] = id.host;
|
|
55
|
+
if (id.server)
|
|
56
|
+
out['X-Agent-Server'] = id.server;
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { identityHeaders } from './identity.js';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
import { ApiError } from './http.js';
|
|
5
|
+
import { siteToken } from '../tools/credentialpick.js';
|
|
6
|
+
/**
|
|
7
|
+
* Put an image into the site's media library.
|
|
8
|
+
*
|
|
9
|
+
* MULTIPART, which is why this does not go through `request()`. That helper
|
|
10
|
+
* JSON-encodes every body, and `POST /api/media/{siteId}` takes
|
|
11
|
+
* `file:formData/file` — so an agent reaching this endpoint through sb_api_call
|
|
12
|
+
* sent JSON to a multipart handler and got a rejection it could not act on. A
|
|
13
|
+
* page with no images is not a designed page, so this was the gap between
|
|
14
|
+
* "the agent can lay out a page" and "the agent can finish one".
|
|
15
|
+
*
|
|
16
|
+
* Node ≥22 has FormData, Blob and fetch as globals, so this needs no dependency.
|
|
17
|
+
*/
|
|
18
|
+
export async function uploadMedia(ctx, siteId, source) {
|
|
19
|
+
const doFetch = ctx.fetchImpl ?? fetch;
|
|
20
|
+
let bytes;
|
|
21
|
+
let filename;
|
|
22
|
+
if (source.path) {
|
|
23
|
+
bytes = await readFile(source.path);
|
|
24
|
+
filename = source.name ?? basename(source.path);
|
|
25
|
+
}
|
|
26
|
+
else if (source.url) {
|
|
27
|
+
const res = await doFetch(source.url);
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
throw new ApiError(res.status, 'source_unreachable', `could not fetch ${source.url}`);
|
|
30
|
+
}
|
|
31
|
+
bytes = new Uint8Array(await res.arrayBuffer());
|
|
32
|
+
// A URL's last segment is usually the filename; when it is not (a query-only
|
|
33
|
+
// CDN link), name it rather than uploading something called "".
|
|
34
|
+
filename = source.name ?? (new URL(source.url).pathname.split('/').pop() || 'image');
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
throw new Error('sbuilder: give sb_media_upload either a local path or a url');
|
|
38
|
+
}
|
|
39
|
+
const form = new FormData();
|
|
40
|
+
form.set('file', new Blob([bytes]), filename);
|
|
41
|
+
if (source.name)
|
|
42
|
+
form.set('name', source.name);
|
|
43
|
+
if (source.folderId)
|
|
44
|
+
form.set('folderId', source.folderId);
|
|
45
|
+
// No Content-Type header: fetch must set it itself so the multipart boundary
|
|
46
|
+
// matches the body it just built. Setting it by hand is the classic way to
|
|
47
|
+
// make a valid upload unparseable at the other end.
|
|
48
|
+
const res = await doFetch(`${ctx.base.replace(/\/$/, '')}/api/media/${encodeURIComponent(siteId)}`, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
// Content-Type stays ABSENT — fetch writes it with the boundary it just
|
|
51
|
+
// built — but the identity headers belong here as much as on any other call:
|
|
52
|
+
// an install whose only traffic is image uploads is still an install.
|
|
53
|
+
headers: {
|
|
54
|
+
Authorization: `Bearer ${siteToken(ctx)}`,
|
|
55
|
+
Accept: 'application/json',
|
|
56
|
+
...identityHeaders(),
|
|
57
|
+
},
|
|
58
|
+
body: form,
|
|
59
|
+
});
|
|
60
|
+
const raw = await res.text();
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = raw ? JSON.parse(raw) : {};
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new ApiError(res.status, 'non_json_response', raw.slice(0, 400));
|
|
67
|
+
}
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
const env = (parsed ?? {});
|
|
70
|
+
throw new ApiError(res.status, env.code ?? 'unknown', env.error ?? `HTTP ${res.status}`);
|
|
71
|
+
}
|
|
72
|
+
const body = parsed;
|
|
73
|
+
return body.asset ?? parsed;
|
|
74
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** Below this, body text is uncomfortable on a phone. */
|
|
2
|
+
const MIN_BODY_PX = 12;
|
|
3
|
+
/** A few pixels of overlap is a rounding artefact, not a defect. */
|
|
4
|
+
const SLOP = 2;
|
|
5
|
+
function overlaps(a, b) {
|
|
6
|
+
return (a.x < b.x + b.w - SLOP &&
|
|
7
|
+
b.x < a.x + a.w - SLOP &&
|
|
8
|
+
a.y < b.y + b.h - SLOP &&
|
|
9
|
+
b.y < a.y + a.h - SLOP);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Everything measurably wrong with how one shot laid out.
|
|
13
|
+
*
|
|
14
|
+
* Reported per width, because these defects are per width: a card that fits at
|
|
15
|
+
* 1440 and spills at 390 is the ordinary responsive failure, and saying which
|
|
16
|
+
* width it happened at is most of the fix.
|
|
17
|
+
*/
|
|
18
|
+
export function measureShot(shot) {
|
|
19
|
+
const out = [];
|
|
20
|
+
const byId = new Map(shot.boxes.map((b) => [b.id, b]));
|
|
21
|
+
const seen = new Set();
|
|
22
|
+
for (const b of shot.boxes) {
|
|
23
|
+
if (b.id === 'ROOT')
|
|
24
|
+
continue;
|
|
25
|
+
// OFF-CANVAS. A negative x, or a right edge past the viewport, is content
|
|
26
|
+
// the visitor cannot reach — and on a phone it also drags a horizontal
|
|
27
|
+
// scrollbar across the whole page.
|
|
28
|
+
if (b.x < -SLOP || b.x + b.w > shot.width + SLOP) {
|
|
29
|
+
out.push({
|
|
30
|
+
code: 'off_canvas',
|
|
31
|
+
nodeId: b.id,
|
|
32
|
+
width: shot.width,
|
|
33
|
+
problem: `Extends past the ${shot.width}px viewport (${b.x} → ${b.x + b.w}).`,
|
|
34
|
+
fix: `Give it a width that can shrink — sb_set id "${b.id}", namespace style, keys { "maxWidth": "100%" } at this breakpoint.`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
// TEXT TOO SMALL. Only where text actually renders: a container inherits a
|
|
38
|
+
// font size it never shows, and reporting that would be noise.
|
|
39
|
+
if (b.hasText && b.fontPx && b.fontPx < MIN_BODY_PX) {
|
|
40
|
+
out.push({
|
|
41
|
+
code: 'text_too_small',
|
|
42
|
+
nodeId: b.id,
|
|
43
|
+
width: shot.width,
|
|
44
|
+
problem: `Renders at ${b.fontPx}px at ${shot.width}px wide — below the ${MIN_BODY_PX}px a phone can read comfortably.`,
|
|
45
|
+
fix: `Raise it for this breakpoint: sb_set id "${b.id}", namespace style, keys { "fontSize": "16px" }.`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// OVERLAP, between siblings only. Any parent overlaps its children by
|
|
50
|
+
// definition, and an absolutely-positioned decoration over a band is a design
|
|
51
|
+
// choice — but two elements in the same flow row sitting on top of each other
|
|
52
|
+
// is one of them being unreadable.
|
|
53
|
+
for (const a of shot.boxes) {
|
|
54
|
+
for (const b of shot.boxes) {
|
|
55
|
+
if (a.id >= b.id || a.id === 'ROOT' || b.id === 'ROOT')
|
|
56
|
+
continue;
|
|
57
|
+
if (a.w === 0 || a.h === 0 || b.w === 0 || b.h === 0)
|
|
58
|
+
continue;
|
|
59
|
+
const pairKey = `${a.id}|${b.id}`;
|
|
60
|
+
if (seen.has(pairKey))
|
|
61
|
+
continue;
|
|
62
|
+
// Skip ancestry: a box containing another is nesting, not collision.
|
|
63
|
+
if (contains(a, b) || contains(b, a))
|
|
64
|
+
continue;
|
|
65
|
+
if (!overlaps(a, b))
|
|
66
|
+
continue;
|
|
67
|
+
seen.add(pairKey);
|
|
68
|
+
out.push({
|
|
69
|
+
code: 'overlap',
|
|
70
|
+
nodeId: a.id,
|
|
71
|
+
width: shot.width,
|
|
72
|
+
problem: `Overlaps ${b.id} at ${shot.width}px wide — one of them is unreadable.`,
|
|
73
|
+
fix: 'Check the two for a fixed height or a negative margin at this breakpoint; sb_look with node_id on each shows which one is out of place.',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
void byId;
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
function contains(outer, inner) {
|
|
81
|
+
return (outer.x - SLOP <= inner.x &&
|
|
82
|
+
outer.y - SLOP <= inner.y &&
|
|
83
|
+
outer.x + outer.w + SLOP >= inner.x + inner.w &&
|
|
84
|
+
outer.y + outer.h + SLOP >= inner.y + inner.h);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Measure every width, and report each defect once.
|
|
88
|
+
*
|
|
89
|
+
* A card that spills at three widths is one problem, not three — but WHICH
|
|
90
|
+
* widths it spills at is the useful part, so the widths are collected onto the
|
|
91
|
+
* single finding rather than repeated as separate ones.
|
|
92
|
+
*/
|
|
93
|
+
export function measure(shots) {
|
|
94
|
+
const merged = new Map();
|
|
95
|
+
for (const shot of shots) {
|
|
96
|
+
for (const f of measureShot(shot)) {
|
|
97
|
+
const key = `${f.code}|${f.nodeId}`;
|
|
98
|
+
const existing = merged.get(key);
|
|
99
|
+
if (existing)
|
|
100
|
+
existing.widths.push(f.width);
|
|
101
|
+
else
|
|
102
|
+
merged.set(key, { ...f, widths: [f.width] });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return [...merged.values()];
|
|
106
|
+
}
|
|
107
|
+
export const MEASURE_NOTICE = 'These were MEASURED on the rendered page, not read off the document — they are what a ' +
|
|
108
|
+
'visitor meets at those widths. Fix them at the breakpoint named; a defect at 390px and ' +
|
|
109
|
+
'not at 1440px is a responsive failure, not a broken element.';
|
package/dist/vision/shoot.js
CHANGED
|
@@ -54,6 +54,8 @@ export async function shoot(url, opts = {}) {
|
|
|
54
54
|
const wb = String(el.className || '')
|
|
55
55
|
.split(/\s+/)
|
|
56
56
|
.find((c) => c.startsWith('wb-'));
|
|
57
|
+
const cs = getComputedStyle(el);
|
|
58
|
+
const own = (el.textContent ?? '').trim();
|
|
57
59
|
return {
|
|
58
60
|
id: el.id,
|
|
59
61
|
type: wb ? wb.slice(3) : '',
|
|
@@ -61,6 +63,8 @@ export async function shoot(url, opts = {}) {
|
|
|
61
63
|
y: Math.round(r.y),
|
|
62
64
|
w: Math.round(r.width),
|
|
63
65
|
h: Math.round(r.height),
|
|
66
|
+
fontPx: Math.round(parseFloat(cs.fontSize) || 0),
|
|
67
|
+
hasText: own.length > 0,
|
|
64
68
|
};
|
|
65
69
|
})));
|
|
66
70
|
// ZOOM. A designer does not judge a card by looking at the whole page, and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sbuilder-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "MCP server that designs and operates a Store Builder site — pages, data, theme and publish — through the platform's own API and live-edit protocol.",
|
|
5
5
|
"mcpName": "io.github.vuluu2k/sbuilder-mcp",
|
|
6
6
|
"type": "module",
|