@artkit-ai/core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/adapter.d.ts +119 -0
  2. package/dist/adapter.d.ts.map +1 -0
  3. package/dist/adapter.js +45 -0
  4. package/dist/adapter.js.map +1 -0
  5. package/dist/diff.d.ts +1 -1
  6. package/dist/diff.d.ts.map +1 -1
  7. package/dist/engine.d.ts +29 -23
  8. package/dist/engine.d.ts.map +1 -1
  9. package/dist/engine.js +148 -197
  10. package/dist/engine.js.map +1 -1
  11. package/dist/envelope.d.ts +10 -27
  12. package/dist/envelope.d.ts.map +1 -1
  13. package/dist/envelope.js +8 -20
  14. package/dist/envelope.js.map +1 -1
  15. package/dist/errors.d.ts +1 -1
  16. package/dist/errors.d.ts.map +1 -1
  17. package/dist/errors.js +4 -4
  18. package/dist/errors.js.map +1 -1
  19. package/dist/events.d.ts +2 -10
  20. package/dist/events.d.ts.map +1 -1
  21. package/dist/events.js.map +1 -1
  22. package/dist/i18n.d.ts +8 -14
  23. package/dist/i18n.d.ts.map +1 -1
  24. package/dist/i18n.js +12 -18
  25. package/dist/i18n.js.map +1 -1
  26. package/dist/index.d.ts +2 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +2 -2
  29. package/dist/index.js.map +1 -1
  30. package/dist/logger.d.ts +1 -2
  31. package/dist/logger.d.ts.map +1 -1
  32. package/dist/logger.js.map +1 -1
  33. package/dist/memory.d.ts +0 -2
  34. package/dist/memory.d.ts.map +1 -1
  35. package/dist/memory.js +15 -109
  36. package/dist/memory.js.map +1 -1
  37. package/dist/migrations.d.ts +6 -5
  38. package/dist/migrations.d.ts.map +1 -1
  39. package/dist/migrations.js +20 -14
  40. package/dist/migrations.js.map +1 -1
  41. package/dist/projection.d.ts +22 -6
  42. package/dist/projection.d.ts.map +1 -1
  43. package/dist/projection.js +5 -5
  44. package/dist/projection.js.map +1 -1
  45. package/dist/registry.d.ts +17 -16
  46. package/dist/registry.d.ts.map +1 -1
  47. package/dist/registry.js +25 -25
  48. package/dist/registry.js.map +1 -1
  49. package/dist/service.d.ts +19 -51
  50. package/dist/service.d.ts.map +1 -1
  51. package/dist/service.js +119 -404
  52. package/dist/service.js.map +1 -1
  53. package/dist/store.d.ts +12 -61
  54. package/dist/store.d.ts.map +1 -1
  55. package/dist/testing/index.d.ts +10 -9
  56. package/dist/testing/index.d.ts.map +1 -1
  57. package/dist/testing/index.js +75 -136
  58. package/dist/testing/index.js.map +1 -1
  59. package/dist/theme.js +1 -1
  60. package/dist/ui.d.ts +18 -1
  61. package/dist/ui.d.ts.map +1 -1
  62. package/dist/ui.js +5 -3
  63. package/dist/ui.js.map +1 -1
  64. package/package.json +1 -1
  65. package/src/adapter.ts +180 -0
  66. package/src/diff.ts +1 -1
  67. package/src/engine.ts +183 -230
  68. package/src/envelope.ts +11 -29
  69. package/src/errors.ts +4 -4
  70. package/src/events.ts +2 -11
  71. package/src/i18n.ts +14 -20
  72. package/src/index.ts +2 -4
  73. package/src/logger.ts +1 -2
  74. package/src/memory.ts +16 -122
  75. package/src/migrations.ts +24 -18
  76. package/src/projection.ts +25 -7
  77. package/src/registry.ts +45 -41
  78. package/src/service.ts +144 -540
  79. package/src/store.ts +12 -64
  80. package/src/testing/index.ts +89 -164
  81. package/src/theme.ts +1 -1
  82. package/src/ui.ts +24 -4
  83. package/dist/kind.d.ts +0 -156
  84. package/dist/kind.d.ts.map +0 -1
  85. package/dist/kind.js +0 -52
  86. package/dist/kind.js.map +0 -1
  87. package/src/kind.ts +0 -217
package/src/store.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import type { AssetMeta, AssetRef, StoredAssetInfo } from './assets.js';
2
- import type { Actor, Artifact, ArtifactStatus, ArtifactSummary } from './envelope.js';
3
- import type { ChangeSummary } from './kind.js';
2
+ import type { Actor, Artifact, ArtifactSummary } from './envelope.js';
4
3
 
5
4
  /** Workspace and actor. Every store implementation filters by scope; ids alone never grant access. */
6
5
  export interface Scope {
@@ -11,60 +10,23 @@ export interface Scope {
11
10
  export type StoredArtifact = Artifact;
12
11
 
13
12
  export interface NewArtifact {
14
- kind: string;
15
- schemaVersion: number;
13
+ format: string;
14
+ formatVersion: string;
16
15
  title: string;
17
- status: ArtifactStatus;
18
16
  locale: string;
19
17
  themeId: string;
20
18
  content: unknown;
21
19
  tags?: string[];
22
- message?: string;
23
- idempotencyKey?: string;
24
- createdIds?: string[];
25
20
  }
26
21
 
27
22
  export interface Commit {
28
23
  /** Version the change was computed against. The store rejects with VERSION_CONFLICT when it is stale. */
29
24
  baseVersion: number;
30
- schemaVersion: number;
25
+ formatVersion: string;
31
26
  content: unknown;
32
27
  title: string;
33
28
  locale: string;
34
29
  themeId: string;
35
- status?: ArtifactStatus;
36
- operations: unknown[];
37
- diff: ChangeSummary[];
38
- createdIds?: string[];
39
- message?: string;
40
- idempotencyKey?: string;
41
- /** Set when the commit restores an older version (spec §8.5). */
42
- restoredFrom?: number;
43
- }
44
-
45
- export interface VersionEntry {
46
- version: number;
47
- actor: Actor;
48
- createdAt: string;
49
- operations: unknown[];
50
- diff: ChangeSummary[];
51
- createdIds: string[];
52
- status: ArtifactStatus;
53
- message?: string;
54
- idempotencyKey?: string;
55
- restoredFrom?: number;
56
- /** Lifecycle changes made while this was the current version, oldest first (approval trail). */
57
- statusLog?: StatusChange[];
58
- /** Content snapshot, present at least on the first version and periodically after (spec §8.5). */
59
- snapshot?: { schemaVersion: number; content: unknown };
60
- }
61
-
62
- export interface StatusChange {
63
- status: ArtifactStatus;
64
- actor: Actor;
65
- at: string;
66
- /** Submit message, approval note or rejection reason. */
67
- message?: string;
68
30
  }
69
31
 
70
32
  export interface PageRequest {
@@ -78,8 +40,7 @@ export interface Page<T> {
78
40
  }
79
41
 
80
42
  export interface ArtifactFilter {
81
- kind?: string;
82
- status?: ArtifactStatus;
43
+ format?: string;
83
44
  /** Case-insensitive substring of the title. */
84
45
  title?: string;
85
46
  tag?: string;
@@ -93,35 +54,22 @@ export interface Lock {
93
54
  }
94
55
 
95
56
  /**
96
- * Persistence contract (spec §15.1). Implementations must pass the shared contract suite
97
- * exported from `@artkit-ai/core/testing`.
57
+ * Persistence contract (spec §17.1). The store keeps the current content of each artifact and its version counter;
58
+ * it does not keep earlier versions. Implementations must pass the shared contract suite from `@artkit-ai/core/testing`.
98
59
  */
99
60
  export interface ArtifactStore {
100
- get(scope: Scope, id: string, opts?: { version?: number }): Promise<StoredArtifact | null>;
61
+ get(scope: Scope, id: string): Promise<StoredArtifact | null>;
101
62
  create(scope: Scope, input: NewArtifact): Promise<StoredArtifact>;
102
- /** Throws `ArtifactError('VERSION_CONFLICT')` when `change.baseVersion` is not the current version. */
63
+ /**
64
+ * Replaces the content when `change.baseVersion` is the current version and increments the version. Throws
65
+ * `ArtifactError('VERSION_CONFLICT')` otherwise.
66
+ */
103
67
  commit(scope: Scope, id: string, change: Commit): Promise<StoredArtifact>;
104
- history(scope: Scope, id: string, page: PageRequest): Promise<Page<VersionEntry>>;
105
68
  list(scope: Scope, filter: ArtifactFilter, page: PageRequest): Promise<Page<ArtifactSummary>>;
106
- /** Changes status without creating a version; sets `meta.publishedVersion` when publishing. */
107
- setStatus(
108
- scope: Scope,
109
- id: string,
110
- status: ArtifactStatus,
111
- actor: Actor,
112
- message?: string,
113
- ): Promise<StoredArtifact>;
114
69
  /** Throws `ArtifactError('LOCKED')` when another actor holds a valid lock. */
115
70
  lock(scope: Scope, id: string, actor: Actor, ttlMs: number): Promise<Lock>;
116
71
  unlock(scope: Scope, lock: Lock): Promise<void>;
117
72
  getLock(scope: Scope, id: string): Promise<Lock | null>;
118
- findByIdempotencyKey(scope: Scope, id: string, key: string): Promise<VersionEntry | null>;
119
- /**
120
- * Finds an artifact created with this idempotency key by the same actor in the scope's workspace, so a repeated
121
- * `create` tool call returns the first artifact instead of a duplicate. Optional for compatibility;
122
- * without it, creation is not deduplicated across processes.
123
- */
124
- findCreatedByIdempotencyKey?(scope: Scope, key: string): Promise<StoredArtifact | null>;
125
73
  }
126
74
 
127
75
  export interface AssetStore {
@@ -1,9 +1,9 @@
1
+ import type { AnyFormatAdapter, FormatAdapter } from '../adapter.js';
1
2
  import { deepEqual } from '../diff.js';
2
3
  import { applyOperations, prepareContent } from '../engine.js';
3
4
  import { ArtifactError } from '../errors.js';
4
- import type { AnyArtifactKind } from '../kind.js';
5
5
  import { createRegistry } from '../registry.js';
6
- import type { ArtifactStore, NewArtifact, Scope } from '../store.js';
6
+ import type { ArtifactStore, Commit, NewArtifact, Scope } from '../store.js';
7
7
  import { defaultTheme } from '../theme.js';
8
8
 
9
9
  /** Minimal test API so the suites work with vitest, jest or node:test. */
@@ -29,21 +29,30 @@ async function expectCode(promise: Promise<unknown>, code: string): Promise<void
29
29
 
30
30
  const scopeA: Scope = { workspaceId: 'ws_a', actor: { type: 'user', id: 'usr_1' } };
31
31
  const scopeB: Scope = { workspaceId: 'ws_b', actor: { type: 'user', id: 'usr_2' } };
32
- const agent: Scope = { workspaceId: 'ws_a', actor: { type: 'agent', id: 'agt_1' } };
33
32
 
34
33
  const sample: NewArtifact = {
35
- kind: 'note',
36
- schemaVersion: 1,
34
+ format: 'contract-text',
35
+ formatVersion: 'contract@1',
37
36
  title: 'Contract test',
38
- status: 'draft',
39
37
  locale: 'en',
40
38
  themeId: 'default',
41
- content: { text: 'hello' },
39
+ content: 'hello',
42
40
  tags: ['contract'],
43
41
  };
44
42
 
43
+ function commitFrom(baseVersion: number, text = 'v2'): Commit {
44
+ return {
45
+ baseVersion,
46
+ formatVersion: 'contract@1',
47
+ content: text,
48
+ title: 'Contract test',
49
+ locale: 'en',
50
+ themeId: 'default',
51
+ };
52
+ }
53
+
45
54
  /**
46
- * Shared contract suite every `ArtifactStore` adapter must pass (spec §15.1).
55
+ * Shared contract suite every `ArtifactStore` adapter must pass (spec §17.1).
47
56
  * `factory` must return a fresh, empty store for each test.
48
57
  */
49
58
  export function defineStoreContractTests(
@@ -76,159 +85,72 @@ export function defineStoreContractTests(
76
85
  const created = await store.create(scopeA, sample);
77
86
  const v2 = await store.commit(scopeA, created.id, commitFrom(1, 'v2'));
78
87
  assert(v2.version === 2, 'second version');
88
+ assert(v2.content === 'v2', 'the current content is replaced');
79
89
  await expectCode(store.commit(scopeA, created.id, commitFrom(1, 'stale')), 'VERSION_CONFLICT');
80
- const v1 = await store.get(scopeA, created.id, { version: 1 });
81
- assert(deepEqual(v1?.content, sample.content), 'old versions are immutable');
82
- const latest = await store.get(scopeA, created.id);
83
- assert(deepEqual(latest?.content, { text: 'v2' }), 'latest is v2');
84
- });
85
-
86
- test.it('records history newest first with idempotency lookup', async () => {
87
- const store = await factory();
88
- const created = await store.create(scopeA, sample);
89
- await store.commit(scopeA, created.id, {
90
- ...commitFrom(1, 'x'),
91
- idempotencyKey: 'call_1',
92
- message: 'edit',
93
- });
94
- const history = await store.history(scopeA, created.id, {});
95
- assert(history.items[0]?.version === 2 && history.items[1]?.version === 1, 'history order');
96
- assert(history.items[0]?.message === 'edit', 'message kept');
97
- const found = await store.findByIdempotencyKey(scopeA, created.id, 'call_1');
98
- assert(found?.version === 2, 'idempotency key lookup');
99
- assert((await store.findByIdempotencyKey(scopeA, created.id, 'missing')) === null, 'unknown key');
100
- });
101
-
102
- test.it('filters and paginates lists', async () => {
103
- const store = await factory();
104
- for (let i = 0; i < 5; i++)
105
- await store.create(scopeA, { ...sample, title: `Doc ${i}`, kind: i % 2 ? 'sheet' : 'note' });
106
- const sheets = await store.list(scopeA, { kind: 'sheet' }, {});
107
- assert(sheets.items.length === 2, 'kind filter');
108
- const titled = await store.list(scopeA, { title: 'doc 3' }, {});
109
- assert(titled.items.length === 1, 'title filter is case-insensitive');
110
- const page1 = await store.list(scopeA, {}, { limit: 2 });
111
- assert(page1.items.length === 2 && page1.nextCursor !== undefined, 'first page');
112
- const page2 = await store.list(scopeA, {}, { limit: 2, cursor: page1.nextCursor });
113
- assert(
114
- page2.items.length === 2 && !page2.items.some((item) => page1.items.some((p) => p.id === item.id)),
115
- 'second page',
116
- );
90
+ const current = await store.get(scopeA, created.id);
91
+ assert(current?.content === 'v2', 'a rejected commit leaves the current content untouched');
117
92
  });
118
93
 
119
- test.it('changes status and records the published version', async () => {
94
+ test.it('filters the list by format and tag', async () => {
120
95
  const store = await factory();
121
- const created = await store.create(scopeA, sample);
122
- const published = await store.setStatus(scopeA, created.id, 'published', scopeA.actor);
123
- assert(published.status === 'published', 'status');
124
- assert(published.meta.publishedVersion === 1, 'publishedVersion');
125
- assert(published.version === 1, 'status change does not create a version');
126
- await store.setStatus(scopeA, created.id, 'draft', scopeA.actor, 'needs a cost table');
127
- const entry = (await store.history(scopeA, created.id, {})).items[0];
128
- const log = entry?.statusLog ?? [];
129
- assert(log.length === 2, 'status changes are logged on the current version');
130
- assert(log[1]?.status === 'draft' && log[1]?.message === 'needs a cost table', 'reason is kept');
96
+ await store.create(scopeA, sample);
97
+ await store.create(scopeA, { ...sample, format: 'contract-other', title: 'Other', tags: [] });
98
+ const onlyText = await store.list(scopeA, { format: 'contract-text' }, {});
99
+ assert(onlyText.items.length === 1, 'format filter must narrow the list');
100
+ const tagged = await store.list(scopeA, { tag: 'contract' }, {});
101
+ assert(tagged.items.length === 1, 'tag filter must narrow the list');
131
102
  });
132
103
 
133
- test.it('locks against other actors', async () => {
104
+ test.it('locks are exclusive to other actors', async () => {
134
105
  const store = await factory();
135
106
  const created = await store.create(scopeA, sample);
136
- const lock = await store.lock(scopeA, created.id, agent.actor, 60_000);
137
- assert((await store.getLock(scopeA, created.id))?.token === lock.token, 'lock visible');
138
- await expectCode(store.lock(scopeA, created.id, scopeA.actor, 60_000), 'LOCKED');
139
- await store.lock(scopeA, created.id, agent.actor, 60_000);
140
- await store.unlock(scopeA, (await store.getLock(scopeA, created.id))!);
141
- assert((await store.getLock(scopeA, created.id)) === null, 'unlocked');
142
- });
143
-
144
- test.it('expires locks', async () => {
145
- const store = await factory();
146
- const created = await store.create(scopeA, sample);
147
- await store.lock(scopeA, created.id, agent.actor, 1);
148
- await new Promise<void>((resolve) => setTimeout(() => resolve(), 15));
149
- assert((await store.getLock(scopeA, created.id)) === null, 'lock expired');
150
- });
151
-
152
- test.it('finds artifacts by creation idempotency key when supported', async () => {
153
- const store = await factory();
154
- if (!store.findCreatedByIdempotencyKey) return;
155
- const created = await store.create(scopeA, { ...sample, idempotencyKey: 'create_1' });
156
- await store.commit(scopeA, created.id, { ...commitFrom(1, 'later'), idempotencyKey: 'apply_1' });
157
- assert(
158
- (await store.findCreatedByIdempotencyKey(scopeA, 'create_1'))?.id === created.id,
159
- 'found by create key',
160
- );
161
- assert(
162
- (await store.findCreatedByIdempotencyKey(scopeA, 'apply_1')) === null,
163
- 'apply keys are not create keys',
164
- );
165
- assert((await store.findCreatedByIdempotencyKey(scopeB, 'create_1')) === null, 'workspace isolation');
166
- assert((await store.findCreatedByIdempotencyKey(agent, 'create_1')) === null, 'keys are per actor');
107
+ const lock = await store.lock(scopeA, created.id, scopeA.actor, 60_000);
108
+ const other = { type: 'agent' as const, id: 'agt_1' };
109
+ await expectCode(store.lock(scopeA, created.id, other, 60_000), 'LOCKED');
110
+ await store.unlock(scopeA, lock);
111
+ assert((await store.getLock(scopeA, created.id)) === null, 'unlock must release');
167
112
  });
168
113
  });
169
114
  }
170
115
 
171
- function commitFrom(baseVersion: number, text = 'changed') {
172
- return {
173
- baseVersion,
174
- schemaVersion: 1,
175
- content: { text },
176
- title: 'Contract test',
177
- locale: 'en',
178
- themeId: 'default',
179
- operations: [{ op: 'replace_content', content: { text } }],
180
- diff: [],
181
- };
182
- }
183
-
184
- export interface KindFixture {
116
+ export interface FormatFixture {
185
117
  title: string;
186
118
  content: unknown;
187
- /** Batches applied in sequence on the content; each must succeed. */
119
+ /** Batches that must apply deterministically on this fixture. */
188
120
  batches?: unknown[][];
189
- /** Batches that must be rejected with the given code. */
121
+ /** Batches that must be rejected, with the expected error code. */
190
122
  rejects?: { operations: unknown[]; code: string }[];
191
123
  }
192
124
 
193
125
  /**
194
- * Conformance suite for kinds (spec §20): instruction examples validate against the schemas, apply is
195
- * deterministic and atomic, projection exposes ids, diff of identical content is empty, parsePartial
196
- * accepts complete content and migrations chain up to the current version.
126
+ * Shared conformance suite every format adapter must pass (spec §8 and §22): examples in the instructions are valid,
127
+ * fixtures validate and read with ids, `apply` is deterministic, pure and atomic, and migrations form a chain.
197
128
  */
198
- export function defineKindConformanceTests(
199
- kind: AnyArtifactKind,
200
- fixtures: KindFixture[],
129
+ export function defineFormatConformanceTests(
130
+ adapter: AnyFormatAdapter,
131
+ fixtures: FormatFixture[],
201
132
  test: TestApi,
202
133
  ): void {
203
- const registry = createRegistry({ kinds: [kind] });
204
- const lint = { locale: 'en', theme: defaultTheme };
205
-
206
- test.describe(`Kind conformance: ${kind.kind}`, () => {
207
- test.it('empty() is valid and projectable', () => {
208
- const prepared = prepareContent(registry, kind.kind, kind.empty());
209
- kind.project(prepared.content, { mode: 'full', locale: 'en' });
134
+ const registry = createRegistry({ formats: [adapter] });
135
+ const ctx = { locale: 'en', theme: defaultTheme };
136
+
137
+ test.describe(`Format conformance: ${adapter.format}`, () => {
138
+ test.it('empty() is valid and readable', () => {
139
+ const prepared = prepareContent(registry, adapter.format, adapter.empty());
140
+ const read = adapter.read(prepared.content as never, { mode: 'full', locale: 'en' });
141
+ assert(typeof read.text === 'string', 'read must return text');
210
142
  });
211
143
 
212
- for (const example of kind.instructions.examples) {
213
- test.it(`instruction example "${example.title}" is valid`, () => {
214
- if (example.input) {
215
- const input = kind.inputs?.[example.input.format];
216
- assert(input, `example uses unknown input format "${example.input.format}"`);
217
- const raw = input.toContent(input.schema.parse(example.input.source), { locale: 'en' });
218
- const fromInput = prepareContent(registry, kind.kind, raw).content;
219
- if (example.content !== undefined) {
220
- const expected = prepareContent(registry, kind.kind, example.content).content;
221
- assert(deepEqual(fromInput, expected), 'example input must produce the example content');
222
- }
223
- }
144
+ for (const example of adapter.instructions.examples) {
145
+ test.it(`instruction example "${example.title}" is valid`, async () => {
224
146
  const base =
225
147
  example.content === undefined
226
- ? kind.empty()
227
- : prepareContent(registry, kind.kind, example.content).content;
148
+ ? adapter.empty()
149
+ : prepareContent(registry, adapter.format, example.content).content;
228
150
  if (example.operations) {
229
- applyOperations(
151
+ await applyOperations(
230
152
  registry,
231
- { kind: kind.kind, content: base, title: 'Example', themeId: 'default', locale: 'en' },
153
+ { format: adapter.format, content: base, title: 'Example', themeId: 'default', locale: 'en' },
232
154
  example.operations,
233
155
  );
234
156
  }
@@ -236,36 +158,36 @@ export function defineKindConformanceTests(
236
158
  }
237
159
 
238
160
  for (const fixture of fixtures) {
239
- test.it(`fixture "${fixture.title}": validates, projects ids and diffs`, () => {
240
- const { content } = prepareContent(registry, kind.kind, fixture.content);
241
- const full = kind.project(content, { mode: 'full', locale: 'en' });
242
- const outline = kind.project(content, { mode: 'outline', locale: 'en' });
243
- assert(full.text.length > 0 && outline.text.length > 0, 'projection must not be empty');
244
- const someId = [...kind.ids(content)][0];
245
- if (someId) assert(full.text.includes(someId), `full projection must expose ids (missing ${someId})`);
161
+ test.it(`fixture "${fixture.title}": validates, reads with ids and diffs`, () => {
162
+ const { content } = prepareContent(registry, adapter.format, fixture.content);
163
+ const full = adapter.read(content as never, { mode: 'full', locale: 'en' });
164
+ const outline = adapter.read(content as never, { mode: 'outline', locale: 'en' });
165
+ assert(full.text.length > 0 && outline.text.length > 0, 'reading must not be empty');
246
166
  assert(
247
- kind.diff(content, structuredClone(content)).length === 0,
167
+ adapter.diff(content as never, structuredClone(content) as never).length === 0,
248
168
  'diff of identical content must be empty',
249
169
  );
250
- const partial = kind.parsePartial(structuredClone(content));
251
- assert(
252
- partial !== undefined && kind.contentSchema.safeParse(partial).success,
253
- 'parsePartial must accept complete content',
254
- );
255
- kind.lint(content, lint);
256
- assert(deepEqual(JSON.parse(JSON.stringify(content)), content), 'content must be JSON-serializable');
170
+ const partial = adapter.parsePartial(JSON.stringify(content));
171
+ assert(partial !== undefined, 'parsePartial must accept complete content');
172
+ adapter.lint(content as never, ctx);
257
173
  });
258
174
 
259
175
  if (fixture.batches?.length) {
260
- test.it(`fixture "${fixture.title}": apply is deterministic and does not mutate input`, () => {
261
- let { content } = prepareContent(registry, kind.kind, fixture.content);
176
+ test.it(`fixture "${fixture.title}": apply is deterministic and does not mutate input`, async () => {
177
+ let { content } = prepareContent(registry, adapter.format, fixture.content);
262
178
  for (const batch of fixture.batches ?? []) {
263
179
  const frozen = structuredClone(content);
264
- const artifact = { kind: kind.kind, content, title: 'Fixture', themeId: 'default', locale: 'en' };
265
- const a = applyOperations(registry, artifact, batch);
266
- const b = applyOperations(registry, artifact, batch);
180
+ const artifact = {
181
+ format: adapter.format,
182
+ content,
183
+ title: 'Fixture',
184
+ themeId: 'default',
185
+ locale: 'en',
186
+ };
187
+ const a = await applyOperations(registry, artifact, batch);
188
+ const b = await applyOperations(registry, artifact, batch);
267
189
  assert(deepEqual(a.content, b.content), 'same batch must produce the same content');
268
- assert(deepEqual(a.createdIds, b.createdIds), 'generated ids must be deterministic');
190
+ assert(deepEqual(a.createdRefs, b.createdRefs), 'created refs must be deterministic');
269
191
  assert(deepEqual(content, frozen), 'apply must not mutate the input');
270
192
  assert(a.diff.length > 0, 'a successful batch should report changes');
271
193
  content = a.content;
@@ -274,13 +196,13 @@ export function defineKindConformanceTests(
274
196
  }
275
197
 
276
198
  for (const [index, rejected] of (fixture.rejects ?? []).entries()) {
277
- test.it(`fixture "${fixture.title}": rejects batch ${index} with ${rejected.code}`, () => {
278
- const { content } = prepareContent(registry, kind.kind, fixture.content);
199
+ test.it(`fixture "${fixture.title}": rejects batch ${index} with ${rejected.code}`, async () => {
200
+ const { content } = prepareContent(registry, adapter.format, fixture.content);
279
201
  const frozen = structuredClone(content);
280
202
  try {
281
- applyOperations(
203
+ await applyOperations(
282
204
  registry,
283
- { kind: kind.kind, content, title: 'Fixture', themeId: 'default', locale: 'en' },
205
+ { format: adapter.format, content, title: 'Fixture', themeId: 'default', locale: 'en' },
284
206
  rejected.operations,
285
207
  );
286
208
  } catch (error) {
@@ -301,12 +223,15 @@ export function defineKindConformanceTests(
301
223
  }
302
224
  }
303
225
 
304
- test.it('migrations chain to the current version', () => {
305
- const versions = kind.migrations.map((m) => m.to);
306
- assert(
307
- kind.migrations.length === 0 || Math.max(...versions) === kind.version,
308
- 'migrations must end at version',
309
- );
226
+ test.it('migrations chain to the current format version', () => {
227
+ const chain = adapter.migrations;
228
+ for (const migration of chain) {
229
+ assert(migration.to !== migration.from, 'a migration must change the version');
230
+ }
231
+ assert(adapter.readableVersions.includes(adapter.formatVersion), 'current version must be readable');
310
232
  });
311
233
  });
312
234
  }
235
+
236
+ /** Re-export for adapters that want the generic type in their own tests. */
237
+ export type { FormatAdapter };
package/src/theme.ts CHANGED
@@ -130,7 +130,7 @@ export const defaultDarkTheme: Theme = {
130
130
  muted: '#C6CBCE',
131
131
  accent: '#00DD80',
132
132
  background: '#1D2F3C',
133
- surface: '#263744',
133
+ surface: '#1A313D',
134
134
  info: '#FFFFFF',
135
135
  warning: '#F3C11B',
136
136
  success: '#00DD80',
package/src/ui.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { LintIssue } from './kind.js';
1
+ import type { LintIssue } from './adapter.js';
2
2
  import { COLOR_TOKENS, mixHex, safeFontFamily, type Theme, themeBrand, themeShape } from './theme.js';
3
3
 
4
4
  /**
@@ -29,6 +29,22 @@ export interface EmitOptions {
29
29
  * into kind operations through `emit`, never writing whole content (spec §13.6). Batching, optimistic
30
30
  * apply, undo/redo, conflicts and locks are handled by the host (`@artkit-ai/react`).
31
31
  */
32
+ /**
33
+ * Draft actions the host gives every editor: undo, redo and save. The host draws them unless the editor declares that it
34
+ * draws its own toolbar (see `EditorModule.toolbar` in `@artkit-ai/react`).
35
+ */
36
+ export interface EditorActions {
37
+ canUndo: boolean;
38
+ canRedo: boolean;
39
+ undo(): void;
40
+ redo(): void;
41
+ /** The draft differs from the last saved version. */
42
+ dirty: boolean;
43
+ saving: boolean;
44
+ canSave: boolean;
45
+ save(): void;
46
+ }
47
+
32
48
  export interface EditorBindings<Content = unknown, Operation = unknown> {
33
49
  content: Content;
34
50
  ctx: RenderContext;
@@ -36,6 +52,8 @@ export interface EditorBindings<Content = unknown, Operation = unknown> {
36
52
  readOnly: boolean;
37
53
  /** Lint issues of the current version, for inline indicators. */
38
54
  lint?: LintIssue[];
55
+ /** Undo, redo and save of the draft. */
56
+ actions?: EditorActions;
39
57
  }
40
58
 
41
59
  /** Default CSS class prefix for every artkit component (spec §13.5). */
@@ -64,9 +82,11 @@ export function themeCssVariables(theme: Theme): Record<string, string> {
64
82
  const shape = themeShape(theme);
65
83
  const fonts = themeFontStacks(theme);
66
84
  for (const token of COLOR_TOKENS) vars[`--ak-color-${token}`] = colors[token];
67
- // Hairline tones: the design system draws every boundary with a 1px line of the text color.
68
- vars['--ak-color-border'] = mixHex(colors.background, colors.text, 0.15);
69
- vars['--ak-color-border-strong'] = mixHex(colors.background, colors.text, 0.5);
85
+ // Hairlines of the Mouts design system: every card, panel and control is outlined by a 1px line of the text color
86
+ // (border-hairline); dividers inside a surface use the same line at 40% (border-hairline-soft).
87
+ vars['--ak-color-border'] = colors.text;
88
+ vars['--ak-color-border-strong'] = colors.text;
89
+ vars['--ak-color-border-soft'] = mixHex(colors.background, colors.text, 0.4);
70
90
  vars['--ak-color-hover'] = mixHex(colors.background, colors.text, 0.06);
71
91
  vars['--ak-color-active'] = mixHex(colors.background, colors.text, 0.1);
72
92
  vars['--ak-color-selected'] = mixHex(colors.background, brand.primary, 0.12);