@divlet/web 0.0.1 → 0.0.3

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.
@@ -47,27 +47,29 @@ const clearRecovery = () => { failure = ''; recovery.replaceChildren(); };
47
47
  let input;
48
48
  let status;
49
49
  let primary;
50
+ let primaryLabel;
51
+ const actionLabels = { open: 'Open', save: 'Save here', project: 'Open project', collection: 'Open collection', create: 'Create project here', working: 'Working…' };
50
52
 
51
53
  export const isOpen = () => !scrim.hidden;
52
54
  export const canShowCopy = () => !!store.state?.project?.capabilities?.saveAs;
53
55
 
54
56
  function label() {
55
57
  const info = picker?.state.info;
56
- if (!info) return { text: mode === 'open' ? 'Open' : 'Save here', ok: false, note: '' };
58
+ if (!info) return { text: mode === 'open' ? actionLabels.open : actionLabels.save, ok: false, note: '' };
57
59
  if (mode === 'open') {
58
- if (info.kind === 'project') return { text: 'Open project', ok: true, note: 'A project is here.' };
59
- if (info.kind === 'collection') return { text: 'Open collection', ok: true, note: `${info.projects} project${info.projects === 1 ? '' : 's'} here — pick one on the next page.` };
60
- if (info.kind === 'new') return { text: 'Create project here', ok: true, note: 'No project in this folder yet.' };
61
- return { text: 'Open project', ok: false, note: 'This folder does not exist.' };
60
+ if (info.kind === 'project') return { text: actionLabels.project, ok: true, note: 'A project is here.' };
61
+ if (info.kind === 'collection') return { text: actionLabels.collection, ok: true, note: `${info.projects} project${info.projects === 1 ? '' : 's'} here — pick one on the next page.` };
62
+ if (info.kind === 'new') return { text: actionLabels.create, ok: true, note: 'No project in this folder yet.' };
63
+ return { text: actionLabels.project, ok: false, note: 'This folder does not exist.' };
62
64
  }
63
- if (info.kind === 'project' || info.kind === 'collection') return { text: 'Save here', ok: false, note: 'This folder already holds projects. Choose a new or empty folder.' };
64
- if (info.kind === 'missing') return { text: 'Save here', ok: true, note: `Will create ${info.missing}.` };
65
- return { text: 'Save here', ok: true, note: 'The folder must be empty.' };
65
+ if (info.kind === 'project' || info.kind === 'collection') return { text: actionLabels.save, ok: false, note: 'This folder already holds projects. Choose a new or empty folder.' };
66
+ if (info.kind === 'missing') return { text: actionLabels.save, ok: true, note: `Will create ${info.missing}.` };
67
+ return { text: actionLabels.save, ok: true, note: 'The folder must be empty.' };
66
68
  }
67
69
 
68
70
  function paintControls() {
69
71
  const state = label();
70
- primary.textContent = busy ? 'Working…' : state.text;
72
+ primaryLabel.textContent = busy ? actionLabels.working : state.text;
71
73
  // Keep keyboard focus inside the modal when pending work disables a focused control.
72
74
  const focused = document.activeElement;
73
75
  if (focused === primary && (busy || !state.ok)) input.focus({ preventScroll: true });
@@ -127,6 +129,14 @@ function render() {
127
129
  status = el('span', 'caption project-status');
128
130
  status.setAttribute('role', 'status');
129
131
  primary = el('button', 'cta project-go');
132
+ primaryLabel = el('span', 'project-go-label');
133
+ primary.append(primaryLabel);
134
+ // All state labels share one grid cell, reserving real font-dependent width without another accessible control.
135
+ for (const text of Object.values(actionLabels)) {
136
+ const sizer = el('span', 'project-go-sizer', text);
137
+ sizer.setAttribute('aria-hidden', 'true');
138
+ primary.append(sizer);
139
+ }
130
140
  primary.type = 'button';
131
141
  primary.addEventListener('click', submit);
132
142
  const foot = el('div', 'dialog-foot');
package/dist/lib/store.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { clamp } from './util.js';
2
- import { post, readSession, shareSelection } from './host.js';
2
+ import { readSession, shareSelection, writer } from './host.js';
3
3
  export { base, apiUrl, request, post } from './host.js';
4
4
 
5
5
  const listeners = new Map();
@@ -20,6 +20,9 @@ export const store = {
20
20
  leaving: new Set(),
21
21
  };
22
22
 
23
+ /** A capability is unavailable only when the host says `false`; native hosts predate the newer flags and omit them. */
24
+ export const capable = name => store.state?.project?.capabilities?.[name] !== false;
25
+
23
26
  export const documents = () => (store.state?.documents || []).filter(doc => !store.leaving.has(doc.id));
24
27
  export const descriptor = id => documents().find(doc => doc.id === id);
25
28
 
@@ -117,10 +120,10 @@ function enqueue(documentId, task) {
117
120
  return result;
118
121
  }
119
122
 
120
- async function send(route, documentId, build, { version = Math.max(known.get(documentId) || 0, descriptor(documentId)?.version || 0), retry = true } = {}) {
123
+ async function send(write, documentId, build, { version = Math.max(known.get(documentId) || 0, descriptor(documentId)?.version || 0), retry = true } = {}) {
121
124
  for (let attempt = 0; ; attempt++) {
122
125
  try {
123
- const result = await post(route, build(version));
126
+ const result = await write(build(version));
124
127
  known.set(documentId, result.version);
125
128
  return result;
126
129
  } catch (error) {
@@ -136,32 +139,32 @@ export function commit(documentId, patch, { label, group, expectedVersion, expec
136
139
  const guard = expectedVersion === undefined ? {} : { version: expectedVersion, retry: false };
137
140
  const format = store.state?.project?.format;
138
141
  return enqueue(documentId, async () => {
139
- if (typeof patch !== 'function') return send('api/patch', documentId, version => ({ documentId, expectedVersion: version, expectedRender, patch, label, group }), guard);
142
+ if (typeof patch !== 'function') return send(writer.patch, documentId, version => ({ documentId, expectedVersion: version, expectedRender, patch, label, group }), guard);
140
143
  if (store.state?.project?.format !== format) throw Object.assign(new Error('The source format changed. Read it again before retrying.'), { code: 'REVISION_CONFLICT' });
141
144
  const model = await freshModel(documentId);
142
145
  if (store.state?.project?.format !== format || model !== cachedModel(documentId) || model.version < (known.get(documentId) || 0)) throw Object.assign(new Error('The frame model is not current. Read it again before retrying.'), { code: 'REVISION_CONFLICT' });
143
146
  const derived = await patch(model);
144
147
  if (store.state?.project?.format !== format || model !== cachedModel(documentId)) throw Object.assign(new Error('The frame changed while preparing this edit. Read it again before retrying.'), { code: 'REVISION_CONFLICT' });
145
148
  if (!derived) return null;
146
- return send('api/patch', documentId, version => ({ documentId, expectedVersion: version, expectedRender, patch: derived, label, group }), { version: expectedVersion ?? model.version, retry: false });
149
+ return send(writer.patch, documentId, version => ({ documentId, expectedVersion: version, expectedRender, patch: derived, label, group }), { version: expectedVersion ?? model.version, retry: false });
147
150
  }).catch(error => { toast(error.message); throw error; });
148
151
  }
149
152
  export function edit(documentId, op, { label, group, expectedRender } = {}) {
150
153
  const format = store.state?.project?.format;
151
154
  return enqueue(documentId, async () => {
152
- if (typeof op !== 'function') return send('api/edit', documentId, expectedVersion => ({ documentId, expectedVersion, expectedRender, op, label, group }));
155
+ if (typeof op !== 'function') return send(writer.edit, documentId, expectedVersion => ({ documentId, expectedVersion, expectedRender, op, label, group }));
153
156
  // Derive relative legacy intent after local commits; native operations keep their shown-view guards.
154
157
  if (store.state?.project?.format !== format) throw Object.assign(new Error('The source format changed. Read it again before retrying.'), { code: 'REVISION_CONFLICT' });
155
158
  const model = await freshModel(documentId);
156
159
  if (store.state?.project?.format !== format || model !== cachedModel(documentId) || model.version < (known.get(documentId) || 0)) throw Object.assign(new Error('The frame model is not current. Read it again before retrying.'), { code: 'REVISION_CONFLICT' });
157
160
  const derived = op(model);
158
161
  if (!derived) return null;
159
- return send('api/edit', documentId, expectedVersion => ({ documentId, expectedVersion, expectedRender, op: derived, label, group }), { version: model.version, retry: false });
162
+ return send(writer.edit, documentId, expectedVersion => ({ documentId, expectedVersion, expectedRender, op: derived, label, group }), { version: model.version, retry: false });
160
163
  }).catch(error => { toast(error.message); throw error; });
161
164
  }
162
165
 
163
- export const undo = () => post('api/undo', {}).catch(error => { toast(error.message); return null; });
164
- export const redo = () => post('api/redo', {}).catch(error => { toast(error.message); return null; });
166
+ export const undo = () => writer.undo({}).catch(error => { toast(error.message); return null; });
167
+ export const redo = () => writer.redo({}).catch(error => { toast(error.message); return null; });
165
168
 
166
169
  /* ---- selection ------------------------------------------------------------ */
167
170
  let selectionSequence = 0;
@@ -0,0 +1,34 @@
1
+ import type { ToolRegistry } from './mcp-tools.ts';
2
+ interface WebMcpToolInit {
3
+ name: string;
4
+ title?: string;
5
+ description: string;
6
+ inputSchema?: object;
7
+ annotations?: {
8
+ readOnlyHint?: boolean;
9
+ consequentialHint?: boolean;
10
+ };
11
+ execute(input: object, options: {
12
+ signal: AbortSignal;
13
+ }): Promise<unknown>;
14
+ }
15
+ /** The part of the draft's `ModelContext` this adapter uses. */
16
+ export interface WebMcpModelContext {
17
+ registerTool(tool: WebMcpToolInit, options?: {
18
+ signal?: AbortSignal;
19
+ }): Promise<unknown> | unknown;
20
+ }
21
+ /**
22
+ * Register every tool of `registry` with WebMCP. Resolves to the names that registered (an empty list when the browser has no
23
+ * `document.modelContext`, for example outside a secure context) and a function that unregisters them. Failures of individual
24
+ * registrations are reported, never thrown: WebMCP is a compatibility surface, not a requirement.
25
+ */
26
+ export declare function registerWebMcpTools(registry: ToolRegistry, context?: unknown): Promise<{
27
+ registered: string[];
28
+ failed: Array<{
29
+ name: string;
30
+ message: string;
31
+ }>;
32
+ unregister: () => void;
33
+ }>;
34
+ export {};
@@ -0,0 +1,37 @@
1
+ const isModelContext = (value) => !!value && typeof value.registerTool === 'function';
2
+ /**
3
+ * Register every tool of `registry` with WebMCP. Resolves to the names that registered (an empty list when the browser has no
4
+ * `document.modelContext`, for example outside a secure context) and a function that unregisters them. Failures of individual
5
+ * registrations are reported, never thrown: WebMCP is a compatibility surface, not a requirement.
6
+ */
7
+ export async function registerWebMcpTools(registry, context = globalThis.document?.modelContext) {
8
+ const controller = new AbortController();
9
+ const unregister = () => controller.abort();
10
+ if (!isModelContext(context))
11
+ return { registered: [], failed: [], unregister };
12
+ const registered = [], failed = [];
13
+ for (const descriptor of registry.descriptors) {
14
+ const readOnly = descriptor.annotations?.readOnlyHint === true;
15
+ const init = {
16
+ name: descriptor.name,
17
+ ...(descriptor.title ? { title: descriptor.title } : {}),
18
+ description: descriptor.description,
19
+ inputSchema: descriptor.inputSchema,
20
+ annotations: { readOnlyHint: readOnly, consequentialHint: !readOnly && descriptor.annotations?.destructiveHint !== false },
21
+ async execute(input, options) {
22
+ const outcome = await registry.run(descriptor.name, input, { signal: options?.signal });
23
+ if (outcome.ok)
24
+ return outcome.value;
25
+ throw Object.assign(new Error(`${outcome.error.code}: ${outcome.error.message}`), { code: outcome.error.code });
26
+ },
27
+ };
28
+ try {
29
+ await context.registerTool(init, { signal: controller.signal });
30
+ registered.push(descriptor.name);
31
+ }
32
+ catch (error) {
33
+ failed.push({ name: descriptor.name, message: String(error?.message || error) });
34
+ }
35
+ }
36
+ return { registered, failed, unregister };
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@divlet/web",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "The divlet browser board, static client assets and transport-free host ports",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,6 +24,10 @@
24
24
  "./host": {
25
25
  "divlet-source": "./lib/board-host.ts",
26
26
  "default": "./dist/lib/board-host.js"
27
+ },
28
+ "./mcp": {
29
+ "divlet-source": "./lib/mcp-provider.ts",
30
+ "default": "./dist/lib/mcp-provider.js"
27
31
  }
28
32
  },
29
33
  "files": [