@ikenga/contract 0.9.1 → 0.11.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.
@@ -1 +1 @@
1
- {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAeA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,eAAO,MAAM,uBAAuB,EAAG,CAAU,CAAC;AAIlD;;;;GAIG;AACH,eAAO,MAAM,YAAY;IACvB,4DAA4D;;IAE5D,0DAA0D;;;;;;;;EAE1D,CAAC;AACH,MAAM,MAAM,MAAM,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC;AAElD;;;;GAIG;AACH,eAAO,MAAM,sBAAsB;;;;;;;;;EAGjC,CAAC;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAEtE,eAAO,MAAM,gBAAgB;IAC3B,4BAA4B;;IAE5B,iDAAiD;;IAEjD,yEAAyE;;IAEzE,2DAA2D;;IAE3D,mFAAmF;;IAEnF,4DAA4D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IAE5D,gFAAgF;;QA/BhF,4DAA4D;;QAE5D,0DAA0D;;;;;;;;;IA+B1D;;;;;;OAMG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAEH,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAE1D,eAAO,MAAM,eAAe;;IAE1B,4DAA4D;;IAE5D,sDAAsD;;IAEtD,mDAAmD;;QA/BnD,4BAA4B;;QAE5B,iDAAiD;;QAEjD,yEAAyE;;QAEzE,2DAA2D;;QAE3D,mFAAmF;;QAEnF,4DAA4D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAE5D,gFAAgF;;YA/BhF,4DAA4D;;YAE5D,0DAA0D;;;;;;;;;QA+B1D;;;;;;WAMG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAaH,CAAC;AACH,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAIxD,eAAO,MAAM,mBAAmB;IAC9B,qBAAqB;;IAErB,qBAAqB;;IAErB;;;;;OAKG;;IAEH,4EAA4E;;IAE5E,2EAA2E;;IAE3E;;;;OAIG;;IAEH;;;;;;;OAOG;;;;;;;;;;;;;;;;;;EAEH,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEhE,eAAO,MAAM,mBAAmB;;IAE9B,wDAAwD;;;QAnCxD,qBAAqB;;QAErB,qBAAqB;;QAErB;;;;;WAKG;;QAEH,4EAA4E;;QAE5E,2EAA2E;;QAE3E;;;;WAIG;;QAEH;;;;;;;WAOG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAUH,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAIhE;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,8EAA8E;AAC9E,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAErD"}
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAE5D,gFAAgF;;YA/BhF,4DAA4D;;YAE5D,0DAA0D;;;;;;;;;QA+B1D;;;;;;WAMG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAaH,CAAC;AACH,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAIxD,eAAO,MAAM,mBAAmB;IAC9B,qBAAqB;;IAErB,qBAAqB;;IAErB;;;;;OAKG;;IAEH,4EAA4E;;IAE5E,2EAA2E;;IAE3E;;;;OAIG;;IAEH;;;;;;;OAOG;;;;;;;;;;;;;;;;;;EAEH,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEhE,eAAO,MAAM,mBAAmB;;IAE9B,wDAAwD;;;QAnCxD,qBAAqB;;QAErB,qBAAqB;;QAErB;;;;;WAKG;;QAEH,4EAA4E;;QAE5E,2EAA2E;;QAE3E;;;;WAIG;;QAEH;;;;;;;WAOG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAUH,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAIhE;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,8EAA8E;AAC9E,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAErD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikenga/contract",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "Shared contract for the Ikenga pkg system: manifest schema, RPC types, Engine interface, capability scopes.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -51,6 +51,10 @@
51
51
  "./browser": {
52
52
  "types": "./dist/browser.d.ts",
53
53
  "import": "./dist/browser.js"
54
+ },
55
+ "./pa-actions": {
56
+ "types": "./dist/pa-actions.d.ts",
57
+ "import": "./dist/pa-actions.js"
54
58
  }
55
59
  },
56
60
  "files": [
package/src/browser.ts CHANGED
@@ -43,6 +43,21 @@ export const BrowserRefSchema = z
43
43
 
44
44
  export type BrowserRef = z.infer<typeof BrowserRefSchema>;
45
45
 
46
+ // ---------- Engine ----------
47
+ //
48
+ // Which underlying browser engine backs a pane. `webkit` (default) is the
49
+ // in-shell child-webview (WebKitGTK on Linux / WKWebView / WebView2). `chrome`
50
+ // is "Managed mode": the shell launches the user's installed Google Chrome with
51
+ // a dedicated `--user-data-dir` + `--remote-debugging-port` and drives it over
52
+ // CDP. The engine is chosen at `open()`; subsequent actions address the pane by
53
+ // id and inherit its engine. See plans/chrome-pkg/01-plan.md §Architecture.
54
+
55
+ export const BROWSER_ENGINES = ['webkit', 'chrome'] as const;
56
+
57
+ export const BrowserEngineSchema = z.enum(BROWSER_ENGINES);
58
+
59
+ export type BrowserEngine = z.infer<typeof BrowserEngineSchema>;
60
+
46
61
  // ---------- Snapshot ----------
47
62
  //
48
63
  // Mirrors the shape of Iyke's `DomResult` so models can move between the
@@ -119,8 +134,17 @@ export const BrowserOpenInputSchema = z
119
134
  * Optional initial rect in main-window-client coordinates. Omitted when
120
135
  * the FE owns layout (the kernel will refuse without a rect; the MCP
121
136
  * layer is expected to supply a sane default if the caller didn't).
137
+ *
138
+ * Ignored for `engine: "chrome"` — Managed Chrome renders in its own OS
139
+ * window, not a shell pane.
122
140
  */
123
141
  rect: RectSchema.optional(),
142
+ /**
143
+ * Which engine backs this pane. Defaults to `"webkit"` (the in-shell
144
+ * child-webview). `"chrome"` is Managed mode (installed Chrome over CDP).
145
+ * The pkg must declare the engine in `capabilities.webview.engines`.
146
+ */
147
+ engine: BrowserEngineSchema.default('webkit'),
124
148
  })
125
149
  .refine((v) => !(v.partition && v.session), {
126
150
  message: 'pass at most one of `partition` / `session`',
@@ -132,6 +156,8 @@ export const BrowserOpenResultSchema = z.object({
132
156
  id: BrowserPaneIdSchema,
133
157
  url: z.string(),
134
158
  partition: z.string(),
159
+ /** The resolved engine backing this pane (`"webkit"` if absent, for back-compat). */
160
+ engine: BrowserEngineSchema.default('webkit'),
135
161
  });
136
162
 
137
163
  export type BrowserOpenResult = z.infer<typeof BrowserOpenResultSchema>;
@@ -147,6 +173,8 @@ export const BrowserListEntrySchema = z.object({
147
173
  url: z.string(),
148
174
  partition: z.string(),
149
175
  focused: z.boolean(),
176
+ /** Which engine backs this pane (`"webkit"` if absent, for back-compat). */
177
+ engine: BrowserEngineSchema.default('webkit'),
150
178
  });
151
179
 
152
180
  export type BrowserListEntry = z.infer<typeof BrowserListEntrySchema>;
@@ -51,6 +51,8 @@ function CanvasInner<T>(props: CanvasProps<T>, ref: React.Ref<CanvasHandle>) {
51
51
  onEditModeChange,
52
52
  onSelectionChange,
53
53
  autoFitOnResize = true,
54
+ keyboardPan = true,
55
+ ariaLabel = 'Canvas',
54
56
  className,
55
57
  } = props;
56
58
 
@@ -58,6 +60,7 @@ function CanvasInner<T>(props: CanvasProps<T>, ref: React.Ref<CanvasHandle>) {
58
60
  layout,
59
61
  editMode,
60
62
  autoFitOnResize,
63
+ keyboardPan,
61
64
  onViewportChange,
62
65
  onEditModeChange,
63
66
  onSelectionChange,
@@ -83,6 +86,14 @@ function CanvasInner<T>(props: CanvasProps<T>, ref: React.Ref<CanvasHandle>) {
83
86
  const id = itemEl.getAttribute('data-id') as ItemId | null;
84
87
  if (!id) return;
85
88
  onSelectionChange?.(id);
89
+ // Move DOM focus to the newly-selected item so keyboard users land on it
90
+ // and screen readers announce the selection (WCAG 2.4.3 Focus Order). The
91
+ // rAF waits for React to commit the tabIndex={0} / aria-selected update.
92
+ requestAnimationFrame(() =>
93
+ canvasRef.current
94
+ ?.querySelector<HTMLElement>(`[data-id="${CSS.escape(id)}"]`)
95
+ ?.focus()
96
+ );
86
97
  beginDrag(id, e.clientX, e.clientY);
87
98
  e.preventDefault();
88
99
  return;
@@ -107,6 +118,9 @@ function CanvasInner<T>(props: CanvasProps<T>, ref: React.Ref<CanvasHandle>) {
107
118
  ref={canvasRef}
108
119
  className={className ? `ikenga-canvas ${className}` : 'ikenga-canvas'}
109
120
  data-mode-edit={editMode ? 'true' : 'false'}
121
+ role="application"
122
+ aria-label={ariaLabel}
123
+ tabIndex={0}
110
124
  onMouseDown={onMouseDown}
111
125
  onDoubleClick={onDoubleClick}
112
126
  >
@@ -144,7 +158,17 @@ function CanvasInner<T>(props: CanvasProps<T>, ref: React.Ref<CanvasHandle>) {
144
158
  'data-id': id,
145
159
  style: injected,
146
160
  className,
147
- } as Partial<typeof el.props> & { key: string; 'data-id': string });
161
+ // Selected item is programmatically focusable (tabIndex 0) and
162
+ // announces its selected state; unselected items stay out of the
163
+ // tab sequence (tabIndex -1) like a roving tablist (WCAG 2.4.3).
164
+ 'aria-selected': isSelected,
165
+ tabIndex: isSelected ? 0 : -1,
166
+ } as Partial<typeof el.props> & {
167
+ key: string;
168
+ 'data-id': string;
169
+ 'aria-selected': boolean;
170
+ tabIndex: number;
171
+ });
148
172
  })}
149
173
  </div>
150
174
 
@@ -38,6 +38,10 @@ export interface CanvasProps<T> {
38
38
  onEditModeChange?: (editMode: boolean) => void;
39
39
  onSelectionChange?: (selectedId: ItemId | null) => void;
40
40
  autoFitOnResize?: boolean; // default true
41
+ /** Arrow-key pan + +/- zoom when the canvas root is focused. Default true (WCAG 2.5.7). */
42
+ keyboardPan?: boolean;
43
+ /** Accessible name on the role="application" canvas root. Default 'Canvas'. */
44
+ ariaLabel?: string;
41
45
  className?: string;
42
46
  /** Consumer chrome (top bar, palette) rendered inside the canvas surface. */
43
47
  children?: ReactNode;
@@ -18,6 +18,8 @@ export interface UsePanZoomArgs {
18
18
  editMode: boolean;
19
19
  /** Re-fit on window resize. Default true. */
20
20
  autoFitOnResize?: boolean;
21
+ /** Arrow-key pan + +/- zoom when the canvas root is focused. Default true (WCAG 2.5.7). */
22
+ keyboardPan?: boolean;
21
23
  /** Notified whenever the viewport pan/scale changes (controlled mirror). */
22
24
  onViewportChange?: (viewport: Viewport) => void;
23
25
  /** Escape exits edit mode → parent owns the state flip. */
@@ -49,6 +51,7 @@ export function usePanZoom(args: UsePanZoomArgs): UsePanZoom {
49
51
  layout,
50
52
  editMode,
51
53
  autoFitOnResize = true,
54
+ keyboardPan = true,
52
55
  onViewportChange,
53
56
  onEditModeChange,
54
57
  onSelectionChange,
@@ -130,7 +133,9 @@ export function usePanZoom(args: UsePanZoomArgs): UsePanZoom {
130
133
  autoFit(true);
131
134
  }, [editMode, autoFit]);
132
135
 
133
- // Keyboard — Escape exits edit, Space arms the pan-grab cursor.
136
+ // Keyboard — Escape exits edit, Space arms the pan-grab cursor. When the
137
+ // canvas root itself holds focus, arrow keys pan and +/- zoom: the keyboard
138
+ // alternative to space+drag / pinch-zoom (WCAG 2.5.7 Dragging Movements).
134
139
  useEffect(() => {
135
140
  const down = (e: KeyboardEvent) => {
136
141
  if (e.key === 'Escape' && editMode) {
@@ -147,6 +152,42 @@ export function usePanZoom(args: UsePanZoomArgs): UsePanZoom {
147
152
  canvasRef.current.style.cursor = 'grab';
148
153
  e.preventDefault();
149
154
  }
155
+ // Arrow-pan / +/- zoom only when the canvas surface itself is focused,
156
+ // so widget-internal controls keep their own arrow-key semantics.
157
+ if (keyboardPan && canvasRef.current && document.activeElement === canvasRef.current) {
158
+ // 24 canvas units per step, scaled so the on-screen nudge feels
159
+ // consistent at any zoom level.
160
+ const PAN_STEP = 24;
161
+ const ZOOM_STEP = 0.1;
162
+ switch (e.key) {
163
+ case 'ArrowUp':
164
+ setPan((p) => ({ ...p, y: p.y + PAN_STEP * p.scale }));
165
+ e.preventDefault();
166
+ break;
167
+ case 'ArrowDown':
168
+ setPan((p) => ({ ...p, y: p.y - PAN_STEP * p.scale }));
169
+ e.preventDefault();
170
+ break;
171
+ case 'ArrowLeft':
172
+ setPan((p) => ({ ...p, x: p.x + PAN_STEP * p.scale }));
173
+ e.preventDefault();
174
+ break;
175
+ case 'ArrowRight':
176
+ setPan((p) => ({ ...p, x: p.x - PAN_STEP * p.scale }));
177
+ e.preventDefault();
178
+ break;
179
+ default:
180
+ // '=' shares the key with '+'; both Equal and NumpadAdd zoom in.
181
+ if (e.code === 'Equal' || e.code === 'NumpadAdd') {
182
+ setPan((p) => ({ ...p, scale: Math.min(2, p.scale + ZOOM_STEP) }));
183
+ e.preventDefault();
184
+ } else if (e.code === 'Minus' || e.code === 'NumpadSubtract') {
185
+ setPan((p) => ({ ...p, scale: Math.max(0.2, p.scale - ZOOM_STEP) }));
186
+ e.preventDefault();
187
+ }
188
+ break;
189
+ }
190
+ }
150
191
  };
151
192
  const up = (e: KeyboardEvent) => {
152
193
  if (e.code === 'Space') {
@@ -160,7 +201,7 @@ export function usePanZoom(args: UsePanZoomArgs): UsePanZoom {
160
201
  window.removeEventListener('keydown', down);
161
202
  window.removeEventListener('keyup', up);
162
203
  };
163
- }, [editMode, onEditModeChange, onSelectionChange]);
204
+ }, [editMode, keyboardPan, onEditModeChange, onSelectionChange]);
164
205
 
165
206
  const beginPan = useCallback((clientX: number, clientY: number) => {
166
207
  const p = panRef.current;
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from './iyke.js';
7
7
  export * from './artifact.js';
8
8
  export * from './registry.js';
9
9
  export * from './browser.js';
10
+ export * from './pa-actions.js';
10
11
 
11
12
  /** This package's own version. */
12
13
  export const CONTRACT_PACKAGE_VERSION = '0.5.0' as const;
@@ -2,9 +2,14 @@ import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
 
4
4
  import {
5
+ HttpCapabilitySchema,
6
+ IKENGA_API_VERSION,
7
+ InvokeCapabilitySchema,
5
8
  ManifestSchema,
9
+ NamedSecretSchema,
6
10
  RequiresEntrySchema,
7
11
  RequireSourceSchema,
12
+ SecretsCapabilitySchema,
8
13
  } from './manifest.js';
9
14
 
10
15
  // ─── WP-11 — `requires` field (ADR-015 §3) ──────────────────────────────────
@@ -82,6 +87,96 @@ test('Manifest: requires defaults to [] when absent (pre-Phase-4 manifest)', ()
82
87
  assert.deepEqual(m.requires, []);
83
88
  });
84
89
 
90
+ // ─── WP-01 — trusted-cap tier (ADR-017): http / secrets / invoke + signature ─
91
+
92
+ test('IKENGA_API_VERSION is 3 (ADR-017 soft bump)', () => {
93
+ assert.equal(IKENGA_API_VERSION, 3);
94
+ });
95
+
96
+ test('HttpCapability: auth_header defaults to Authorization; auth_secret optional', () => {
97
+ const h = HttpCapabilitySchema.parse({});
98
+ assert.equal(h.auth_header, 'Authorization');
99
+ assert.equal(h.auth_secret, undefined);
100
+ });
101
+
102
+ test('HttpCapability: .strict rejects unknown field (mirrors Rust deny_unknown_fields)', () => {
103
+ assert.throws(() => HttpCapabilitySchema.parse({ bogus: true }));
104
+ });
105
+
106
+ test('NamedSecret: full shape parses; required defaults false', () => {
107
+ const s = NamedSecretSchema.parse({ name: 'twenty', vault_key: 'TWENTY_API_KEY' });
108
+ assert.equal(s.required, false);
109
+ const r = NamedSecretSchema.parse({
110
+ name: 'k',
111
+ vault_key: 'K',
112
+ required: true,
113
+ format: 'bearer',
114
+ });
115
+ assert.equal(r.required, true);
116
+ assert.equal(r.format, 'bearer');
117
+ });
118
+
119
+ test('NamedSecret: .strict rejects unknown field', () => {
120
+ assert.throws(() =>
121
+ NamedSecretSchema.parse({ name: 'k', vault_key: 'K', bogus: true }),
122
+ );
123
+ });
124
+
125
+ test('SecretsCapability: declarations default to []', () => {
126
+ const s = SecretsCapabilitySchema.parse({});
127
+ assert.deepEqual(s.declarations, []);
128
+ });
129
+
130
+ test('InvokeCapability: empty object parses; commands defaults to [] (presence gate)', () => {
131
+ assert.deepEqual(InvokeCapabilitySchema.parse({}), { commands: [] });
132
+ });
133
+
134
+ test('InvokeCapability (D-06): commands allowlist parses + survives', () => {
135
+ const i = InvokeCapabilitySchema.parse({ commands: ['pa_actions_commit', 'pa_actions_reject'] });
136
+ assert.deepEqual(i.commands, ['pa_actions_commit', 'pa_actions_reject']);
137
+ });
138
+
139
+ test('InvokeCapability: .strict rejects unknown field (mirrors Rust deny_unknown_fields)', () => {
140
+ assert.throws(() => InvokeCapabilitySchema.parse({ commands: [], bogus: true }));
141
+ });
142
+
143
+ test('Manifest: fully-populated trusted-cap manifest round-trips (G-MANIFEST DoD)', () => {
144
+ // The contract-side half of the round-trip parse-fixture: a manifest carrying
145
+ // ALL FOUR new fields — top-level `signature`, `capabilities.http` (with
146
+ // auth_secret + custom header), `capabilities.secrets` (with a declaration),
147
+ // and the presence-gate `capabilities.invoke` — parses and the values survive.
148
+ const m = ManifestSchema.parse({
149
+ ...BASE,
150
+ ikenga_api: '3',
151
+ signature: 'ed25519:Zm9vYmFyYmF6',
152
+ permissions: { net: ['https://api.twenty.com/'], 'vault.keys': ['TWENTY_API_KEY'] },
153
+ capabilities: {
154
+ http: { auth_secret: 'twenty', auth_header: 'X-Api-Key' },
155
+ secrets: {
156
+ declarations: [
157
+ { name: 'twenty', vault_key: 'TWENTY_API_KEY', required: true, format: 'bearer' },
158
+ ],
159
+ },
160
+ invoke: { commands: ['pa_actions_commit'] },
161
+ },
162
+ });
163
+ assert.equal(m.signature, 'ed25519:Zm9vYmFyYmF6');
164
+ assert.equal(m.capabilities?.http?.auth_secret, 'twenty');
165
+ assert.equal(m.capabilities?.http?.auth_header, 'X-Api-Key');
166
+ assert.equal(m.capabilities?.secrets?.declarations.length, 1);
167
+ assert.equal(m.capabilities?.secrets?.declarations[0].vault_key, 'TWENTY_API_KEY');
168
+ assert.equal(m.capabilities?.secrets?.declarations[0].required, true);
169
+ assert.ok(m.capabilities?.invoke !== undefined);
170
+ // D-06: the invoke command allowlist survives the round-trip.
171
+ assert.deepEqual(m.capabilities?.invoke?.commands, ['pa_actions_commit']);
172
+ });
173
+
174
+ test('Manifest: api=1 manifest without new fields parses (back-compat)', () => {
175
+ const m = ManifestSchema.parse({ ...BASE });
176
+ assert.equal(m.signature, undefined);
177
+ assert.equal(m.capabilities, undefined);
178
+ });
179
+
85
180
  test('Manifest: retired bundling fields are no longer part of the type (WP-17)', () => {
86
181
  // ADR-015 decision 4: `skills`/`commands`/`agents` were hard-retired from the
87
182
  // schema (lockstep with the Rust `deny_unknown_fields` Manifest, which REJECTS
package/src/manifest.ts CHANGED
@@ -10,10 +10,14 @@
10
10
 
11
11
  import { z } from 'zod';
12
12
  import { EngineProvidesSchema } from './engine/index.js';
13
+ import { BrowserEngineSchema } from './browser.js';
13
14
 
14
15
  // v2 (WP-05): added capabilities.sqlite + permissions["sqlite.tables"];
15
16
  // permissions["supabase.tables"] kept as a compat alias for api=1 manifests.
16
- export const IKENGA_API_VERSION = 2 as const;
17
+ // v3 (ADR-017): added capabilities.http / .secrets / .invoke (trusted-cap tier)
18
+ // + top-level optional `signature`. All additive; api=1/2 manifests parse
19
+ // unchanged. Elevated caps are inert unless the pkg is trusted.
20
+ export const IKENGA_API_VERSION = 3 as const;
17
21
  export const IKENGA_API_MIN_SUPPORTED = 1 as const;
18
22
 
19
23
  // ---------- Sub-schemas ----------
@@ -170,6 +174,11 @@ export const WebviewCapabilitySchema = z.object({
170
174
  child_webviews: z.boolean().default(false),
171
175
  /** Named cookie/data partitions; empty = the implicit "default" partition. */
172
176
  partitions: z.array(z.string()).default([]),
177
+ /** Browser engines this pkg may open panes with. `"webkit"` is the in-shell
178
+ * child-webview; `"chrome"` is Managed mode (installed Chrome over CDP, its
179
+ * own OS window). Defaults to `["webkit"]` so existing manifests are
180
+ * unchanged. Mirrors `engines` in `WebviewCapability` (manifest.rs). */
181
+ engines: z.array(BrowserEngineSchema).default(['webkit']),
173
182
  });
174
183
  export type WebviewCapability = z.infer<typeof WebviewCapabilitySchema>;
175
184
 
@@ -183,6 +192,65 @@ export type WebviewCapability = z.infer<typeof WebviewCapabilitySchema>;
183
192
  export const AgentOpsCapabilitySchema = z.object({});
184
193
  export type AgentOpsCapability = z.infer<typeof AgentOpsCapabilitySchema>;
185
194
 
195
+ /** host.fetch capability (ADR-017, TRUSTED-only). Host-mediated HTTP proxy;
196
+ * the shell makes the request and attaches auth from Stronghold — the key
197
+ * never enters the iframe. URL allowlist = `permissions.net`. Mirrors
198
+ * `HttpCapability` in `shell/src-tauri/src/pkg/manifest.rs`. */
199
+ export const HttpCapabilitySchema = z
200
+ .object({
201
+ /** Name of a `capabilities.secrets` declaration whose resolved value the
202
+ * shell attaches as the auth header. Omit = unauthenticated proxy. */
203
+ auth_secret: z.string().optional(),
204
+ /** Header name for the auth secret. Default "Authorization". */
205
+ auth_header: z.string().default('Authorization'),
206
+ })
207
+ .strict();
208
+ export type HttpCapability = z.infer<typeof HttpCapabilitySchema>;
209
+
210
+ /** One named-secret declaration. `vault_key` is resolved host-side and never
211
+ * reaches the iframe; the iframe sees only `hostContext.secrets[name]`.
212
+ * Mirrors `NamedSecret` in `shell/src-tauri/src/pkg/manifest.rs`. */
213
+ export const NamedSecretSchema = z
214
+ .object({
215
+ name: z.string(),
216
+ /** Vault key (must be within permissions["vault.keys"]). Host-only. */
217
+ vault_key: z.string(),
218
+ /** When true, mount fails if the key is missing (Supabase `required`). */
219
+ required: z.boolean().default(false),
220
+ /** Optional value-format hint: "jwt" | "bearer" | "raw". String, not enum. */
221
+ format: z.string().optional(),
222
+ })
223
+ .strict();
224
+ export type NamedSecret = z.infer<typeof NamedSecretSchema>;
225
+
226
+ /** Named-secret injection capability (ADR-017, TRUSTED-only). Generalizes the
227
+ * Supabase hostContext handshake. Mirrors `SecretsCapability` in
228
+ * `shell/src-tauri/src/pkg/manifest.rs`. */
229
+ export const SecretsCapabilitySchema = z
230
+ .object({
231
+ declarations: z.array(NamedSecretSchema).default([]),
232
+ })
233
+ .strict();
234
+ export type SecretsCapability = z.infer<typeof SecretsCapabilitySchema>;
235
+
236
+ /** Scoped Tauri invoke passthrough (ADR-017, TRUSTED-only). Presence gates
237
+ * `host.invoke`; `commands` is the named-command allowlist matched (glob) by
238
+ * `permissions_check::check_shell_execute` against the `host.invoke` command.
239
+ *
240
+ * D-06: the allowlist is `invoke`'s OWN field, NOT `permissions["shell.execute"]`.
241
+ * Reusing `shell.execute` would trip `requires_trust` → the pkg only ever reaches
242
+ * user-`Granted`, never `AutoTrusted`, so `is_trusted_for_elevated()` is false and
243
+ * `host.invoke` would always deny. Keeping the allowlist here lets a signed/builtin
244
+ * pkg declare invokable commands while leaving `shell.execute` empty → AutoTrusted →
245
+ * elevated. POLICY: named commands only, never `*` (not a general shell).
246
+ * Mirrors `InvokeCapability` in `shell/src-tauri/src/pkg/manifest.rs`. */
247
+ export const InvokeCapabilitySchema = z
248
+ .object({
249
+ commands: z.array(z.string()).default([]),
250
+ })
251
+ .strict();
252
+ export type InvokeCapability = z.infer<typeof InvokeCapabilitySchema>;
253
+
186
254
  export const WindowBlockSchema = z.object({
187
255
  label: z.string(),
188
256
  url: z.string(),
@@ -283,6 +351,12 @@ export const ManifestSchema = z.object({
283
351
  sqlite: SqliteCapabilitySchema.optional(),
284
352
  webview: WebviewCapabilitySchema.optional(),
285
353
  agentOps: AgentOpsCapabilitySchema.optional(),
354
+ /** host.fetch proxy (ADR-017). Inert unless the pkg is trusted. */
355
+ http: HttpCapabilitySchema.optional(),
356
+ /** Named-secret injection (ADR-017). Inert unless trusted. */
357
+ secrets: SecretsCapabilitySchema.optional(),
358
+ /** host.invoke passthrough (ADR-017). Inert unless trusted. */
359
+ invoke: InvokeCapabilitySchema.optional(),
286
360
  }).optional(),
287
361
 
288
362
  /**
@@ -310,6 +384,13 @@ export const ManifestSchema = z.object({
310
384
  * (`pkg/manifest.rs`) — keep in lockstep (`deny_unknown_fields`).
311
385
  */
312
386
  requires: z.array(RequiresEntrySchema).default([]),
387
+
388
+ /** Optional ed25519 signature over the normalized manifest JSON (sort keys,
389
+ * strip `signature` before signing). Format `"ed25519:<base64>"`. Present
390
+ * on notarized registry pkgs; verified at install against the publisher key
391
+ * the signed registry index named. Absent → pkg isn't trusted (no elevated
392
+ * caps). Mirrors `signature: Option<String>` on the Rust `Manifest`. */
393
+ signature: z.string().optional(),
313
394
  });
314
395
 
315
396
  export type Manifest = z.infer<typeof ManifestSchema>;
@@ -0,0 +1,92 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { type ApproveGateMeta, type DraftItem, draftPreview, fromDraftItem } from './pa-actions.js';
4
+
5
+ const META: ApproveGateMeta = {
6
+ actionId: 'com.ikenga.skill-mail/reply',
7
+ actionName: 'Reply',
8
+ agent: 'PA',
9
+ model: 'Opus 4.7',
10
+ };
11
+
12
+ function item(over: Partial<DraftItem> = {}): DraftItem {
13
+ return {
14
+ id: 'd1',
15
+ recipient: 'Valentim de Carvalho',
16
+ recipientEmail: 'valentim@valentimdc.pt',
17
+ subject: 'Re: Catalog import',
18
+ body: 'Olá Valentim,\n\nRecebido. Vou processar agora.',
19
+ channel: 'smtp',
20
+ senderAddress: 'chinedum@royalti.io',
21
+ fromProvider: 'SMTP · Fastmail',
22
+ scheduledIso: null,
23
+ scheduledLabel: 'today',
24
+ ...over,
25
+ };
26
+ }
27
+
28
+ // Anchor everything to LOCAL time so the day-bucketing assertions are
29
+ // timezone-independent (new Date(y, m, d, h) is local-time construction).
30
+ const NOW = new Date(2026, 5, 7, 11, 0, 0).getTime(); // 11:00 local, 2026-06-07
31
+ const TODAY_ISO = new Date(2026, 5, 7, 17, 0, 0).toISOString(); // same local day, future
32
+ const OVERDUE_ISO = new Date(2026, 5, 5, 9, 0, 0).toISOString(); // two days earlier
33
+
34
+ test('fromDraftItem maps producer fields + meta', () => {
35
+ const d = fromDraftItem(item(), META, NOW);
36
+ assert.equal(d.id, 'd1');
37
+ assert.equal(d.agent, 'PA');
38
+ assert.equal(d.model, 'Opus 4.7');
39
+ assert.equal(d.channel, 'smtp');
40
+ assert.equal(d.consequence.channel, 'SMTP');
41
+ assert.equal(d.consequence.target, 'Valentim de Carvalho');
42
+ assert.equal(d.consequence.recipients, 1);
43
+ assert.equal(d.consequence.undoMs, 10_000);
44
+ assert.equal(d.everEdited, false);
45
+ assert.equal(d.cold, false);
46
+ });
47
+
48
+ test('fromDraftItem buckets an overdue draft', () => {
49
+ const d = fromDraftItem(item({ scheduledIso: OVERDUE_ISO }), META, NOW);
50
+ assert.equal(d.overdue, true);
51
+ assert.equal(d.timeVariant, 'is-overdue');
52
+ assert.equal(d.section, 'Overdue');
53
+ assert.equal(d.status, 'overdue');
54
+ });
55
+
56
+ test('fromDraftItem buckets a same-day draft as today', () => {
57
+ const d = fromDraftItem(item({ scheduledIso: TODAY_ISO }), META, NOW);
58
+ assert.equal(d.overdue, false);
59
+ assert.equal(d.timeVariant, 'is-today');
60
+ assert.equal(d.section, 'Today');
61
+ assert.equal(d.status, 'awaiting');
62
+ });
63
+
64
+ test('an explicit section overrides time bucketing', () => {
65
+ const d = fromDraftItem(item({ scheduledIso: OVERDUE_ISO, section: 'This week' }), META, NOW);
66
+ assert.equal(d.section, 'This week');
67
+ assert.equal(d.overdue, true); // overdue flag still reflects the clock
68
+ });
69
+
70
+ test('sequence recipient count flows into the consequence', () => {
71
+ const d = fromDraftItem(
72
+ item({
73
+ recipients: 388,
74
+ sequence: { name: 'L5 Winback', step: 3, total: 5, recipients: 388 },
75
+ }),
76
+ META,
77
+ NOW
78
+ );
79
+ assert.equal(d.consequence.recipients, 388);
80
+ assert.equal(d.sequence?.step, 3);
81
+ });
82
+
83
+ test('undoMs falls back to 10s when meta omits it', () => {
84
+ const d = fromDraftItem(item(), { ...META, undoMs: undefined }, NOW);
85
+ assert.equal(d.consequence.undoMs, 10_000);
86
+ });
87
+
88
+ test('draftPreview collapses whitespace and truncates', () => {
89
+ assert.equal(draftPreview('a b\n\nc'), 'a b c');
90
+ assert.ok(draftPreview('x'.repeat(300)).length <= 160);
91
+ assert.ok(draftPreview('x'.repeat(300)).endsWith('…'));
92
+ });