sh3-core 0.10.2 → 0.10.4

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 (49) hide show
  1. package/dist/api.d.ts +2 -0
  2. package/dist/api.js +1 -0
  3. package/dist/conflicts/ConflictModal.svelte +131 -0
  4. package/dist/conflicts/ConflictModal.svelte.d.ts +19 -0
  5. package/dist/conflicts/DetailView.svelte +198 -0
  6. package/dist/conflicts/DetailView.svelte.d.ts +17 -0
  7. package/dist/conflicts/PromptView.svelte +55 -0
  8. package/dist/conflicts/PromptView.svelte.d.ts +9 -0
  9. package/dist/conflicts/adapter-documents.d.ts +3 -0
  10. package/dist/conflicts/adapter-documents.js +119 -0
  11. package/dist/conflicts/api.d.ts +108 -0
  12. package/dist/conflicts/api.js +33 -0
  13. package/dist/conflicts/most-recent.d.ts +3 -0
  14. package/dist/conflicts/most-recent.js +23 -0
  15. package/dist/conflicts/most-recent.test.d.ts +1 -0
  16. package/dist/conflicts/most-recent.test.js +45 -0
  17. package/dist/conflicts/renderer-registry.d.ts +7 -0
  18. package/dist/conflicts/renderer-registry.js +59 -0
  19. package/dist/conflicts/renderer-registry.test.d.ts +1 -0
  20. package/dist/conflicts/renderer-registry.test.js +124 -0
  21. package/dist/conflicts/renderers/MetaOnlyRenderer.svelte +73 -0
  22. package/dist/conflicts/renderers/MetaOnlyRenderer.svelte.d.ts +9 -0
  23. package/dist/conflicts/renderers/TextDiffRenderer.svelte +154 -0
  24. package/dist/conflicts/renderers/TextDiffRenderer.svelte.d.ts +9 -0
  25. package/dist/conflicts/renderers/index.d.ts +8 -0
  26. package/dist/conflicts/renderers/index.js +63 -0
  27. package/dist/conflicts/resolve-primitive.d.ts +2 -0
  28. package/dist/conflicts/resolve-primitive.js +55 -0
  29. package/dist/conflicts/shell-api.d.ts +2 -0
  30. package/dist/conflicts/shell-api.js +13 -0
  31. package/dist/documents/browse.d.ts +26 -0
  32. package/dist/documents/browse.js +16 -0
  33. package/dist/documents/browse.test.js +97 -0
  34. package/dist/documents/handle.js +5 -0
  35. package/dist/documents/handle.test.js +37 -0
  36. package/dist/documents/http-backend.d.ts +1 -0
  37. package/dist/documents/http-backend.js +9 -0
  38. package/dist/documents/http-backend.test.d.ts +1 -0
  39. package/dist/documents/http-backend.test.js +39 -0
  40. package/dist/documents/types.d.ts +14 -0
  41. package/dist/keys/ConsentDialog.svelte +1 -0
  42. package/dist/primitives/base.css +140 -1
  43. package/dist/shell-shard/InputLine.svelte +1 -0
  44. package/dist/shellRuntime.svelte.d.ts +3 -0
  45. package/dist/shellRuntime.svelte.js +2 -0
  46. package/dist/tokens.css +3 -1
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/package.json +1 -1
@@ -0,0 +1,63 @@
1
+ /*
2
+ * Built-in renderer registration for the conflict manager view.
3
+ *
4
+ * Two renderers ship with sh3-core:
5
+ * - TextDiffRenderer (priority 1) — text docs within OVERSIZED_TEXT_LIMIT_BYTES
6
+ * - MetaOnlyRenderer (priority 0) — universal fallback (binary, oversized, unknown)
7
+ *
8
+ * Both use Svelte's `mount` / `unmount` to attach to the container the
9
+ * parent provides. They read props reactively; the parent owns
10
+ * selectedOrigin state and updates via the onSelect callback.
11
+ *
12
+ * ensureBuiltInRenderersRegistered() is idempotent; it's called from
13
+ * shell-api.ts on the first shell.conflicts use.
14
+ */
15
+ import { mount, unmount } from 'svelte';
16
+ import { inferKind, registerBuiltInRenderer } from '../renderer-registry';
17
+ import TextDiffRenderer from './TextDiffRenderer.svelte';
18
+ import MetaOnlyRenderer from './MetaOnlyRenderer.svelte';
19
+ /** Max size per branch before we degrade to MetaOnlyRenderer. */
20
+ export const OVERSIZED_TEXT_LIMIT_BYTES = 1048576; // 1 MiB
21
+ function isOversized(sizeBytes) {
22
+ return typeof sizeBytes === 'number' && sizeBytes > OVERSIZED_TEXT_LIMIT_BYTES;
23
+ }
24
+ export const textDiffRenderer = {
25
+ id: 'sh3-core.text',
26
+ priority: 1,
27
+ appliesTo: (item) => {
28
+ if (inferKind(item) !== 'text')
29
+ return false;
30
+ return !item.branches.some((b) => isOversized(b.sizeBytes));
31
+ },
32
+ mount(container, props) {
33
+ const instance = mount(TextDiffRenderer, {
34
+ target: container,
35
+ props: props,
36
+ });
37
+ return () => { void unmount(instance); };
38
+ },
39
+ };
40
+ export const metaOnlyRenderer = {
41
+ id: 'sh3-core.meta',
42
+ priority: 0,
43
+ appliesTo: () => true,
44
+ mount(container, props) {
45
+ const instance = mount(MetaOnlyRenderer, {
46
+ target: container,
47
+ props: props,
48
+ });
49
+ return () => { void unmount(instance); };
50
+ },
51
+ };
52
+ let registered = false;
53
+ export function ensureBuiltInRenderersRegistered() {
54
+ if (registered)
55
+ return;
56
+ registered = true;
57
+ registerBuiltInRenderer(textDiffRenderer);
58
+ registerBuiltInRenderer(metaOnlyRenderer);
59
+ }
60
+ /** Test-only: re-enable registration after _resetForTests(). */
61
+ export function _resetBuiltInRegistrationFlag() {
62
+ registered = false;
63
+ }
@@ -0,0 +1,2 @@
1
+ import { type ConflictItem, type ResolveOptions, type ResolveOutcome } from './api';
2
+ export declare function resolvePrimitive(items: ConflictItem[], opts?: ResolveOptions): Promise<ResolveOutcome>;
@@ -0,0 +1,55 @@
1
+ /*
2
+ * Generic conflict-resolve primitive — the modal machinery without the
3
+ * doc-zone adapter. Lives in its own file so `adapter-documents.ts` can
4
+ * import it statically without creating a cycle with the shell runtime.
5
+ */
6
+ import { modalManager } from '../overlays/modal';
7
+ import { list as listContributions } from '../contributions/registry';
8
+ import { ensureBuiltInRenderersRegistered } from './renderers';
9
+ import ConflictModal from './ConflictModal.svelte';
10
+ import { defaultMostRecentBy } from './most-recent';
11
+ import { CONFLICT_RENDERER_POINT, } from './api';
12
+ function getContributedRenderers() {
13
+ try {
14
+ return listContributions(CONFLICT_RENDERER_POINT);
15
+ }
16
+ catch (_a) {
17
+ return [];
18
+ }
19
+ }
20
+ export function resolvePrimitive(items, opts = {}) {
21
+ ensureBuiltInRenderersRegistered();
22
+ if (items.length === 0) {
23
+ return Promise.resolve({ status: 'resolved', choices: [], skipped: [] });
24
+ }
25
+ return new Promise((settle) => {
26
+ var _a, _b, _c;
27
+ const title = (_a = opts.title) !== null && _a !== void 0 ? _a : (items.length === 1 ? 'Resolve conflict' : 'Resolve conflicts');
28
+ const mostRecentBy = (_b = opts.mostRecentBy) !== null && _b !== void 0 ? _b : defaultMostRecentBy;
29
+ const requirePrompt = (_c = opts.requirePrompt) !== null && _c !== void 0 ? _c : false;
30
+ const contributed = getContributedRenderers();
31
+ let settled = false;
32
+ const onResolve = (choices, skipped) => {
33
+ if (settled)
34
+ return;
35
+ settled = true;
36
+ settle({ status: 'resolved', choices, skipped });
37
+ };
38
+ const onCancel = () => {
39
+ if (settled)
40
+ return;
41
+ settled = true;
42
+ settle({ status: 'cancelled' });
43
+ };
44
+ const props = {
45
+ items,
46
+ title,
47
+ mostRecentBy,
48
+ requirePrompt,
49
+ contributed,
50
+ onResolve,
51
+ onCancel,
52
+ };
53
+ modalManager.open(ConflictModal, props, { boxStyle: 'max-width: none; min-width: 360px; padding: 0;' });
54
+ });
55
+ }
@@ -0,0 +1,2 @@
1
+ import type { ConflictsApi } from './api';
2
+ export declare const conflictsApi: ConflictsApi;
@@ -0,0 +1,13 @@
1
+ /*
2
+ * shell.conflicts assembled API.
3
+ *
4
+ * The generic primitive lives in resolve-primitive.ts and the doc-zone
5
+ * adapter in adapter-documents.ts; this file just binds them together
6
+ * as the object exposed on the shell singleton.
7
+ */
8
+ import { resolvePrimitive } from './resolve-primitive';
9
+ import { resolveDocuments } from './adapter-documents';
10
+ export const conflictsApi = {
11
+ resolve: resolvePrimitive,
12
+ resolveDocuments,
13
+ };
@@ -1,4 +1,5 @@
1
1
  import type { DocumentBackend, DocumentChange, DocumentMeta } from './types';
2
+ import type { DocStatus } from './sync-types';
2
3
  export interface BrowseCapability {
3
4
  /** Every document in the tenant across all shards, each tagged with its owning shardId. */
4
5
  listDocuments(): Promise<Array<DocumentMeta & {
@@ -32,6 +33,31 @@ export interface BrowseCapability {
32
33
  * `typeof ctx.browse.writeTo === 'function'`.
33
34
  */
34
35
  writeTo?(shardId: string, path: string, content: string | ArrayBuffer): Promise<void>;
36
+ /**
37
+ * Read sync-state metadata for any shard's document in the active
38
+ * tenant. Available only when the caller declares both
39
+ * `documents:browse` and `documents:read`. Returns null when the doc
40
+ * does not exist or the backend does not support status reads.
41
+ *
42
+ * Absent (undefined) on the capability object when `documents:read`
43
+ * is not declared.
44
+ */
45
+ statusFrom?(shardId: string, path: string): Promise<DocStatus | null>;
46
+ /**
47
+ * Read a single conflict branch's content for any shard's document in
48
+ * the active tenant. Gated identically to `statusFrom`. Returns null
49
+ * when the doc isn't in conflict, the origin isn't among branches, or
50
+ * the backend does not support branch reads.
51
+ */
52
+ readBranchFrom?(shardId: string, path: string, origin: string): Promise<string | null>;
53
+ /**
54
+ * Resolve a conflict on any shard's document in the active tenant.
55
+ * Available only when the caller declares both `documents:browse` and
56
+ * `documents:write`. Emits a `DocumentChange` update event on success.
57
+ */
58
+ resolveConflictFrom?(shardId: string, path: string, choice: 'local' | 'remote' | {
59
+ origin: string;
60
+ } | string): Promise<void>;
35
61
  }
36
62
  export interface BrowseCapabilityOptions {
37
63
  /** When true, the returned capability exposes `readFrom`. */
@@ -18,6 +18,16 @@ export function createBrowseCapability(tenantId, backend, options = { canRead: f
18
18
  };
19
19
  if (options.canRead) {
20
20
  capability.readFrom = (shardId, path) => backend.read(tenantId, shardId, path);
21
+ capability.statusFrom = async (shardId, path) => {
22
+ if (!backend.readMeta)
23
+ return null;
24
+ return backend.readMeta(tenantId, shardId, path);
25
+ };
26
+ capability.readBranchFrom = async (shardId, path, origin) => {
27
+ if (!backend.readBranch)
28
+ return null;
29
+ return backend.readBranch(tenantId, shardId, path, origin);
30
+ };
21
31
  }
22
32
  if (options.canWrite) {
23
33
  capability.writeTo = async (shardId, path, content) => {
@@ -30,6 +40,12 @@ export function createBrowseCapability(tenantId, backend, options = { canRead: f
30
40
  shardId,
31
41
  });
32
42
  };
43
+ capability.resolveConflictFrom = async (shardId, path, choice) => {
44
+ if (!backend.resolve)
45
+ throw new Error('Backend does not support resolveConflict');
46
+ await backend.resolve(tenantId, shardId, path, choice);
47
+ documentChanges.emit({ type: 'update', path, tenantId, shardId });
48
+ };
33
49
  }
34
50
  return capability;
35
51
  }
@@ -119,4 +119,101 @@ describe('BrowseCapability', () => {
119
119
  expect(await browse.readFrom('shard-x', 'doc.txt')).toBe('roundtrip');
120
120
  });
121
121
  });
122
+ describe('statusFrom / readBranchFrom (documents:read gate)', () => {
123
+ it('absent when canRead is false', () => {
124
+ const be = new MemoryDocumentBackend();
125
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: false });
126
+ expect(browse.statusFrom).toBeUndefined();
127
+ expect(browse.readBranchFrom).toBeUndefined();
128
+ });
129
+ it('present when canRead is true', () => {
130
+ const be = new MemoryDocumentBackend();
131
+ const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
132
+ expect(typeof browse.statusFrom).toBe('function');
133
+ expect(typeof browse.readBranchFrom).toBe('function');
134
+ });
135
+ it('statusFrom delegates to backend.readMeta with tenant baked in', async () => {
136
+ const calls = [];
137
+ const be = {
138
+ read: vi.fn(),
139
+ write: vi.fn(),
140
+ delete: vi.fn(),
141
+ list: vi.fn(async () => []),
142
+ exists: vi.fn(async () => false),
143
+ listAllShards: vi.fn(async () => []),
144
+ listAllDocuments: vi.fn(async () => []),
145
+ readMeta: vi.fn(async (...args) => { calls.push(args); return null; }),
146
+ };
147
+ const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
148
+ await browse.statusFrom('other', 'a.txt');
149
+ expect(calls[0]).toEqual(['t1', 'other', 'a.txt']);
150
+ });
151
+ it('statusFrom returns null when backend has no readMeta', async () => {
152
+ const be = new MemoryDocumentBackend();
153
+ const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
154
+ expect(await browse.statusFrom('other', 'a.txt')).toBeNull();
155
+ });
156
+ it('readBranchFrom delegates to backend.readBranch with tenant baked in', async () => {
157
+ const calls = [];
158
+ const be = {
159
+ read: vi.fn(),
160
+ write: vi.fn(),
161
+ delete: vi.fn(),
162
+ list: vi.fn(async () => []),
163
+ exists: vi.fn(async () => false),
164
+ listAllShards: vi.fn(async () => []),
165
+ listAllDocuments: vi.fn(async () => []),
166
+ readBranch: vi.fn(async (...args) => { calls.push(args); return 'x'; }),
167
+ };
168
+ const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
169
+ const out = await browse.readBranchFrom('other', 'a.txt', 'peer-1');
170
+ expect(out).toBe('x');
171
+ expect(calls[0]).toEqual(['t1', 'other', 'a.txt', 'peer-1']);
172
+ });
173
+ it('readBranchFrom returns null when backend has no readBranch', async () => {
174
+ const be = new MemoryDocumentBackend();
175
+ const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
176
+ expect(await browse.readBranchFrom('other', 'a.txt', 'peer-1')).toBeNull();
177
+ });
178
+ });
179
+ describe('resolveConflictFrom (documents:write gate)', () => {
180
+ it('absent when canWrite is false', () => {
181
+ const be = new MemoryDocumentBackend();
182
+ const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
183
+ expect(browse.resolveConflictFrom).toBeUndefined();
184
+ });
185
+ it('present when canWrite is true', () => {
186
+ const be = new MemoryDocumentBackend();
187
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
188
+ expect(typeof browse.resolveConflictFrom).toBe('function');
189
+ });
190
+ it('delegates to backend.resolve with tenant baked in and emits an update', async () => {
191
+ const calls = [];
192
+ const be = {
193
+ read: vi.fn(),
194
+ write: vi.fn(),
195
+ delete: vi.fn(),
196
+ list: vi.fn(async () => []),
197
+ exists: vi.fn(async () => false),
198
+ listAllShards: vi.fn(async () => []),
199
+ listAllDocuments: vi.fn(async () => []),
200
+ resolve: vi.fn(async (...args) => { calls.push(args); }),
201
+ };
202
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
203
+ const events = [];
204
+ const unsub = documentChanges.subscribe((e) => events.push(e));
205
+ await browse.resolveConflictFrom('other', 'a.txt', { origin: 'peer-1' });
206
+ expect(calls[0]).toEqual(['t1', 'other', 'a.txt', { origin: 'peer-1' }]);
207
+ expect(events).toEqual([
208
+ { type: 'update', path: 'a.txt', tenantId: 't1', shardId: 'other' },
209
+ ]);
210
+ unsub();
211
+ });
212
+ it('throws if backend does not support resolve', async () => {
213
+ const be = new MemoryDocumentBackend();
214
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
215
+ await expect(browse.resolveConflictFrom('other', 'a.txt', { origin: 'peer-1' }))
216
+ .rejects.toThrow(/does not support resolveConflict/);
217
+ });
218
+ });
122
219
  });
@@ -74,6 +74,11 @@ export function createDocumentHandle(tenantId, shardId, backend, options) {
74
74
  }
75
75
  return backend.resolve(tenantId, shardId, path, choice);
76
76
  },
77
+ async readBranch(path, origin) {
78
+ if (!backend.readBranch)
79
+ throw new Error('Backend does not support readBranch()');
80
+ return backend.readBranch(tenantId, shardId, path, origin);
81
+ },
77
82
  watch(callback) {
78
83
  // Subscribe to global emitter, filtered to this handle's scope.
79
84
  const unsub = documentChanges.subscribe((change) => {
@@ -53,3 +53,40 @@ describe('DocumentHandle.resolveConflict()', () => {
53
53
  await expect(handle.resolveConflict('a.txt', 'local')).rejects.toThrow(/resolveConflict/);
54
54
  });
55
55
  });
56
+ describe('DocumentHandle.readBranch()', () => {
57
+ it('delegates to backend.readBranch with tenant + shard scope', async () => {
58
+ const calls = [];
59
+ const backend = {
60
+ async read() { return null; },
61
+ async write() { },
62
+ async delete() { },
63
+ async list() { return []; },
64
+ async exists() { return false; },
65
+ async listAllShards() { return []; },
66
+ async listAllDocuments() { return []; },
67
+ async readBranch(...args) { calls.push(args); return 'remote-content'; },
68
+ };
69
+ const handle = createDocumentHandle('tenant1', 'shard1', backend, { format: 'text' });
70
+ const out = await handle.readBranch('a.txt', 'peer-1');
71
+ expect(out).toBe('remote-content');
72
+ expect(calls[0]).toEqual(['tenant1', 'shard1', 'a.txt', 'peer-1']);
73
+ });
74
+ it('returns null when the backend returns null', async () => {
75
+ const backend = {
76
+ async read() { return null; },
77
+ async write() { },
78
+ async delete() { },
79
+ async list() { return []; },
80
+ async exists() { return false; },
81
+ async listAllShards() { return []; },
82
+ async listAllDocuments() { return []; },
83
+ async readBranch() { return null; },
84
+ };
85
+ const handle = createDocumentHandle('t', 's', backend, { format: 'text' });
86
+ expect(await handle.readBranch('a.txt', 'peer-1')).toBeNull();
87
+ });
88
+ it('throws if the backend does not implement readBranch', async () => {
89
+ const { handle } = harness();
90
+ await expect(handle.readBranch('a.txt', 'peer-1')).rejects.toThrow(/readBranch/);
91
+ });
92
+ });
@@ -30,4 +30,5 @@ export declare class HttpDocumentBackend implements DocumentBackend {
30
30
  resolve(tenantId: string, shardId: string, path: string, choice: 'local' | 'remote' | {
31
31
  origin: string;
32
32
  } | string): Promise<void>;
33
+ readBranch(tenantId: string, shardId: string, path: string, origin: string): Promise<string | null>;
33
34
  }
@@ -110,6 +110,15 @@ export class HttpDocumentBackend {
110
110
  if (!res.ok)
111
111
  throw new Error(`resolve failed: ${res.status}`);
112
112
  }
113
+ async readBranch(tenantId, shardId, path, origin) {
114
+ const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${path}/branch?origin=${encodeURIComponent(origin)}`;
115
+ const res = await fetch(url, { credentials: 'include' });
116
+ if (res.status === 404)
117
+ return null;
118
+ if (!res.ok)
119
+ throw new Error(`readBranch failed: ${res.status}`);
120
+ return res.text();
121
+ }
113
122
  }
114
123
  _HttpDocumentBackend_baseUrl = new WeakMap(), _HttpDocumentBackend_apiKey = new WeakMap(), _HttpDocumentBackend_instances = new WeakSet(), _HttpDocumentBackend_authHeaders = function _HttpDocumentBackend_authHeaders() {
115
124
  if (!__classPrivateFieldGet(this, _HttpDocumentBackend_apiKey, "f"))
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { HttpDocumentBackend } from './http-backend';
3
+ const originalFetch = globalThis.fetch;
4
+ describe('HttpDocumentBackend.readBranch', () => {
5
+ afterEach(() => {
6
+ globalThis.fetch = originalFetch;
7
+ });
8
+ it('GETs /branch?origin=... and returns text', async () => {
9
+ const calls = [];
10
+ globalThis.fetch = (async (url) => {
11
+ calls.push(String(url));
12
+ return new Response('remote', { status: 200 });
13
+ });
14
+ const be = new HttpDocumentBackend('http://x');
15
+ const out = await be.readBranch('t1', 'shard', 'a.txt', 'peer-1');
16
+ expect(out).toBe('remote');
17
+ expect(calls[0]).toContain('/api/docs/t1/shard/a.txt/branch?origin=peer-1');
18
+ });
19
+ it('returns null on 404', async () => {
20
+ globalThis.fetch = (async () => new Response(null, { status: 404 }));
21
+ const be = new HttpDocumentBackend('http://x');
22
+ expect(await be.readBranch('t1', 'shard', 'a.txt', 'peer-1')).toBeNull();
23
+ });
24
+ it('throws on non-404 error status', async () => {
25
+ globalThis.fetch = (async () => new Response('x', { status: 500 }));
26
+ const be = new HttpDocumentBackend('http://x');
27
+ await expect(be.readBranch('t1', 'shard', 'a.txt', 'peer-1')).rejects.toThrow();
28
+ });
29
+ it('URL-encodes the origin query parameter', async () => {
30
+ const calls = [];
31
+ globalThis.fetch = (async (url) => {
32
+ calls.push(String(url));
33
+ return new Response('x', { status: 200 });
34
+ });
35
+ const be = new HttpDocumentBackend('http://x');
36
+ await be.readBranch('t1', 'shard', 'a.txt', 'peer with space');
37
+ expect(calls[0]).toContain('origin=peer%20with%20space');
38
+ });
39
+ });
@@ -126,6 +126,13 @@ export interface DocumentBackend {
126
126
  resolve?(tenantId: string, shardId: string, path: string, choice: 'local' | 'remote' | {
127
127
  origin: string;
128
128
  } | string): Promise<void>;
129
+ /**
130
+ * Read the content of a specific conflict branch without committing it.
131
+ * Returns null if the doc is not in conflict state or the origin is not
132
+ * among the branches. Phase-1 text-only (matches `read` for text docs).
133
+ * Optional; only supported by HttpDocumentBackend in v1.
134
+ */
135
+ readBranch?(tenantId: string, shardId: string, path: string, origin: string): Promise<string | null>;
129
136
  }
130
137
  /**
131
138
  * Shard-facing document handle returned by `ctx.documents()`. Binds
@@ -151,6 +158,13 @@ export interface DocumentHandle {
151
158
  resolveConflict(path: string, choice: 'local' | 'remote' | {
152
159
  origin: string;
153
160
  } | string): Promise<void>;
161
+ /**
162
+ * Read the content of a specific conflict branch without committing it.
163
+ * Used by the conflict manager view to populate per-branch preview.
164
+ * Returns null if not in conflict or origin not among branches.
165
+ * Throws if the backend does not support branch reads.
166
+ */
167
+ readBranch(path: string, origin: string): Promise<string | null>;
154
168
  /**
155
169
  * Subscribe to change notifications within this handle's scope.
156
170
  * Returns an unsubscribe function.
@@ -56,6 +56,7 @@
56
56
  </dl>
57
57
  <div class="sh3-consent-actions">
58
58
  <!-- Deny has autofocus — safe default per spec -->
59
+ <!-- svelte-ignore a11y_autofocus -->
59
60
  <button type="button" class="sh3-consent-deny" onclick={deny} autofocus>Deny</button>
60
61
  <button type="button" class="sh3-consent-approve" onclick={approve}>Approve</button>
61
62
  </div>
@@ -16,7 +16,7 @@ input[type="reset"],
16
16
  .shell-base-button {
17
17
  padding: 6px 14px;
18
18
  background: var(--shell-accent, #6ea8fe);
19
- color: #fff;
19
+ color: var(--shell-fg, #fff);
20
20
  border: none;
21
21
  border-radius: var(--shell-radius);
22
22
  cursor: pointer;
@@ -40,3 +40,142 @@ input[type="reset"]:active,
40
40
  .shell-base-button:active {
41
41
  filter: brightness(0.92);
42
42
  }
43
+
44
+ /* ── Text inputs ─────────────────────────────────────────────────────── */
45
+
46
+ input[type="text"],
47
+ input[type="email"],
48
+ input[type="password"],
49
+ input[type="search"],
50
+ input[type="url"],
51
+ input[type="tel"],
52
+ input[type="number"],
53
+ textarea,
54
+ .shell-base-input {
55
+ padding: var(--shell-pad-md) var(--shell-pad-lg);
56
+ background: var(--shell-input-bg);
57
+ color: var(--shell-fg);
58
+ border: 1px solid var(--shell-border);
59
+ border-radius: var(--shell-radius);
60
+ font-family: inherit;
61
+ font-size: 0.8125rem;
62
+ line-height: var(--shell-line);
63
+ }
64
+
65
+ ::placeholder { color: var(--shell-fg-muted); }
66
+
67
+ input:focus-visible,
68
+ textarea:focus-visible,
69
+ .shell-base-input:focus-visible {
70
+ border-color: var(--shell-input-border-focus);
71
+ box-shadow: var(--shell-focus-ring);
72
+ outline: none;
73
+ }
74
+
75
+ input:disabled,
76
+ textarea:disabled,
77
+ .shell-base-input[aria-disabled="true"] {
78
+ opacity: 0.55;
79
+ cursor: not-allowed;
80
+ }
81
+
82
+ textarea {
83
+ resize: vertical;
84
+ min-height: calc(var(--shell-line) * 3em);
85
+ }
86
+
87
+ /* ── Checkbox & radio ────────────────────────────────────────────────── */
88
+
89
+ input[type="checkbox"].shell-base-check,
90
+ input[type="radio"].shell-base-radio {
91
+ appearance: none;
92
+ width: 14px;
93
+ height: 14px;
94
+ margin: 0;
95
+ background: var(--shell-input-bg);
96
+ border: 1px solid var(--shell-border);
97
+ cursor: pointer;
98
+ display: inline-grid;
99
+ place-content: center;
100
+ flex-shrink: 0;
101
+ }
102
+
103
+ .shell-base-check { border-radius: var(--shell-radius-sm); }
104
+ .shell-base-radio { border-radius: 50%; }
105
+
106
+ .shell-base-check:checked,
107
+ .shell-base-radio:checked {
108
+ background: var(--shell-accent);
109
+ border-color: var(--shell-accent);
110
+ }
111
+
112
+ .shell-base-check:checked::before {
113
+ content: "";
114
+ width: 8px;
115
+ height: 8px;
116
+ background: #fff;
117
+ clip-path: polygon(14% 44%, 0 60%, 40% 100%, 100% 20%, 85% 8%, 38% 70%);
118
+ }
119
+
120
+ .shell-base-radio:checked::before {
121
+ content: "";
122
+ width: 6px;
123
+ height: 6px;
124
+ border-radius: 50%;
125
+ background: #fff;
126
+ }
127
+
128
+ .shell-base-check:focus-visible,
129
+ .shell-base-radio:focus-visible {
130
+ box-shadow: var(--shell-focus-ring);
131
+ outline: none;
132
+ }
133
+
134
+ .shell-base-check:disabled,
135
+ .shell-base-radio:disabled {
136
+ opacity: 0.55;
137
+ cursor: not-allowed;
138
+ }
139
+
140
+ /* ── Switch ──────────────────────────────────────────────────────────── */
141
+
142
+ input[type="checkbox"].shell-base-switch {
143
+ appearance: none;
144
+ position: relative;
145
+ width: 28px;
146
+ height: 16px;
147
+ margin: 0;
148
+ background: var(--shell-border-strong);
149
+ border-radius: 999px;
150
+ cursor: pointer;
151
+ transition: background 120ms ease;
152
+ flex-shrink: 0;
153
+ }
154
+
155
+ .shell-base-switch::before {
156
+ content: "";
157
+ position: absolute;
158
+ top: 2px;
159
+ left: 2px;
160
+ width: 12px;
161
+ height: 12px;
162
+ background: var(--shell-fg);
163
+ border-radius: 50%;
164
+ transition: transform 120ms ease;
165
+ }
166
+
167
+ .shell-base-switch:checked { background: var(--shell-accent); }
168
+ .shell-base-switch:checked::before {
169
+ transform: translateX(12px);
170
+ background: #fff;
171
+ }
172
+
173
+ .shell-base-switch:focus-visible {
174
+ box-shadow: var(--shell-focus-ring);
175
+ outline: none;
176
+ }
177
+
178
+ .shell-base-switch:disabled {
179
+ opacity: 0.55;
180
+ cursor: not-allowed;
181
+ }
@@ -119,6 +119,7 @@
119
119
  .shell-input-cwd { color: var(--shell-fg-muted, #888); }
120
120
  .shell-input-arrow { color: var(--shell-accent, #6cf); }
121
121
  .shell-input-field {
122
+ padding: 0;
122
123
  flex: 1 1 auto;
123
124
  background: transparent;
124
125
  border: 0;
@@ -5,6 +5,7 @@ import { type PopupManager } from './overlays/popup';
5
5
  import { type ToastManager } from './overlays/toast';
6
6
  import { type FloatManager } from './overlays/float';
7
7
  import { type PresetManager } from './overlays/presets';
8
+ import type { ConflictsApi } from './conflicts/api';
8
9
  /**
9
10
  * The process-wide shell singleton exposed to shards and the shell's own
10
11
  * internal code. Provides state zone creation and overlay managers.
@@ -28,6 +29,8 @@ export interface Shell {
28
29
  float: FloatManager;
29
30
  /** Named layout presets per app. See overlays/presets.ts. */
30
31
  presets: PresetManager;
32
+ /** Conflict manager view. Shell-owned modal for conflict arbitration. */
33
+ conflicts: ConflictsApi;
31
34
  }
32
35
  /** The process-wide shell instance. Framework-internal code uses this directly; shards receive a scoped view via `ShardContext`. */
33
36
  export declare const shell: Shell;
@@ -20,6 +20,7 @@ import { popupManager } from './overlays/popup';
20
20
  import { toastManager } from './overlays/toast';
21
21
  import { floatManager } from './overlays/float';
22
22
  import { presetManager } from './overlays/presets';
23
+ import { conflictsApi } from './conflicts/shell-api';
23
24
  /** The process-wide shell instance. Framework-internal code uses this directly; shards receive a scoped view via `ShardContext`. */
24
25
  export const shell = {
25
26
  state: createStateZones,
@@ -28,4 +29,5 @@ export const shell = {
28
29
  toast: toastManager,
29
30
  float: floatManager,
30
31
  presets: presetManager,
32
+ conflicts: conflictsApi,
31
33
  };