kaloko 0.5.2 → 0.7.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.
@@ -259,7 +259,7 @@ export async function releaseProduct ({ root, config, product, version = null, d
259
259
  const tag = tagFor(src.pattern, v)
260
260
  const commit = (await tagExists(root, tag)) ? ((await out('git', ['rev-list', '-n', '1', tag], root)) || '').trim() : ((await out('git', ['rev-parse', 'HEAD'], root)) || '').trim()
261
261
  const payload = {
262
- product: { slug: product.slug, name: product.name, projects: product.projects, modules: product.modules, reference_env: product.reference_env, default_scenario: product.default_scenario, implicit: product.implicit, version_source: { source: src.source, ...(src.path ? { path: src.path } : {}), pattern: src.pattern, ...(src.tool ? { tool: src.tool } : {}) }, ...(product.publish ? { publish: product.publish } : {}), ...(await repoUrl(root)) },
262
+ product: await productPayload(root, product, src),
263
263
  release: { product: product.slug, version: v, date: section?.date || day, changelog: md, source_hash: sha256(md), tag: (await tagExists(root, tag)) ? tag : null, commit: /^[0-9a-f]{40}$/.test(commit) ? commit : null, ...(runs ? { runs } : {}) }
264
264
  }
265
265
  if (dryRun) return { version: v, date: payload.release.date, file, section: !!section, finalized, source: src, notes, payload, items: parseEntry(md).groups.reduce((n, g) => n + g.items.length, 0), refs: refs.length }
@@ -268,6 +268,81 @@ export async function releaseProduct ({ root, config, product, version = null, d
268
268
  const rec = await client.request('POST', `/orgs/${config.org}/releases`, payload.release)
269
269
  return { version: v, date: payload.release.date, file, section: !!section, finalized, source: src, notes, release: rec, product: saved.product, items: parseEntry(md).groups.reduce((n, g) => n + g.items.length, 0), refs: refs.length }
270
270
  }
271
+ /** The product as the service records it (POST /products): from the config, with the version source found. */
272
+ export async function productPayload (root, product, src) {
273
+ return { slug: product.slug, name: product.name, projects: product.projects, modules: product.modules, reference_env: product.reference_env, default_scenario: product.default_scenario, implicit: product.implicit, version_source: { source: src.source, ...(src.path ? { path: src.path } : {}), pattern: src.pattern, ...(src.tool ? { tool: src.tool } : {}) }, ...(product.publish ? { publish: product.publish } : {}), ...(await repoUrl(root)) }
274
+ }
275
+
276
+ // ---------- products on the service ----------
277
+
278
+ /**
279
+ * `kaloko products sync`: every product of the config (or the one asked for) on Kaloko with its name, projects,
280
+ * modules, version source, reference environment, default scenario and who should read it (publish:, which the
281
+ * service applies only for someone who manages the product), without recording a release. A product the service
282
+ * refuses (a project it does not know or the person may not map) is reported and the others still go.
283
+ */
284
+ export async function syncProducts ({ root, config, client = null, only = null, dryRun = false }) {
285
+ const all = productsOf(config, root)
286
+ if (typeof only === 'string' && only && !all.some((p) => p.slug === only)) throw new UserError(`No product ${only} in the config (${all.map((x) => x.slug).join(', ')}).`)
287
+ const out = []
288
+ for (const product of all.filter((p) => !only || p.slug === only)) {
289
+ const src = await detectVersionSource(root, product)
290
+ const payload = await productPayload(root, product, src)
291
+ const item = { slug: product.slug, implicit: product.implicit, name: product.name, projects: product.projects, modules: product.modules, version_source: payload.version_source, version: src.version || null, payload }
292
+ if (dryRun) { out.push(item); continue }
293
+ try {
294
+ const saved = await client.request('POST', `/orgs/${config.org}/products`, payload)
295
+ out.push({ ...item, ok: true, created: saved.created ?? null, publish: saved.product?.publish || null, url: saved.page || saved.url || null, changelog: saved.url || null, notes: saved.notes || [] })
296
+ } catch (e) { out.push({ ...item, ok: false, error: String(e.message || e).split('\n')[0] }) }
297
+ }
298
+ return { dry_run: dryRun, from_config: !!Object.keys(config.products || {}).length, products: out }
299
+ }
300
+
301
+ /** What `kaloko products sync` did, for people. */
302
+ export function formatSync (r) {
303
+ const lines = []
304
+ const n = r.products.length
305
+ lines.push(r.dry_run ? `${n} product(s) would go to Kaloko; dry run, nothing sent.` : `${r.products.filter((p) => p.ok).length} of ${n} product(s) on Kaloko; no release recorded.`)
306
+ if (!r.from_config) lines.push(' No products: in kaloko.config.yml, so the project is the product (its own changelog and docs).')
307
+ for (const p of r.products) {
308
+ const what = [`${p.projects.length ? `projects ${p.projects.join(', ')}` : 'the whole organization'}`, p.modules.length ? `modules ${p.modules.join(', ')}` : null, `version from ${p.version_source.source}${p.version ? ` (${p.version})` : ''}`].filter(Boolean).join(' · ')
309
+ const name = typeof p.name === 'object' && p.name ? Object.values(p.name)[0] : p.name
310
+ lines.push(` ${p.ok === false ? '✗' : r.dry_run ? '·' : p.created ? '+' : '✓'} ${p.slug}${name && name !== p.slug ? ` (${name})` : ''}: ${what}`)
311
+ if (p.url) lines.push(` ${p.url}`)
312
+ if (p.error) lines.push(` ! ${p.error}`)
313
+ for (const x of p.notes || []) lines.push(` ! ${x}`)
314
+ }
315
+ return lines.join('\n')
316
+ }
317
+
318
+ /**
319
+ * `kaloko products list`: the products the service has that the person reads, and the products of the config the
320
+ * service does not have yet (kaloko products sync puts them there).
321
+ */
322
+ export async function listProducts ({ root, config, client }) {
323
+ const service = await client.request('GET', `/orgs/${config.org}/products`)
324
+ const local = Object.keys(config.products || {}).length ? productsOf(config, root) : []
325
+ const known = new Set(service.map((p) => p.slug))
326
+ return { products: service, missing: local.filter((p) => !known.has(p.slug)).map((p) => p.slug) }
327
+ }
328
+
329
+ /** The list for people. */
330
+ export function formatList (r) {
331
+ const lines = []
332
+ if (!r.products.length) lines.push('No product on Kaloko that you read yet. kaloko products sync puts the products of kaloko.config.yml there; kaloko release and kaloko docs publish do it too.')
333
+ for (const p of r.products) {
334
+ const name = typeof p.name === 'object' && p.name ? (p.name.en || Object.values(p.name)[0]) : p.name
335
+ const docs = p.docs ? `docs ${p.docs.versions.join(', ')}${p.docs.languages?.length ? ` (${p.docs.languages.join(', ')})` : ''}` : 'no docs yet'
336
+ const rel = p.latest_release ? `${p.latest_release.version} on ${p.latest_release.released_on}` : 'no release yet'
337
+ lines.push(`${p.slug}${name && name !== p.slug ? ` (${name})` : ''}${p.implicit ? ' · the project\'s own' : ''}: ${rel} · ${docs} · read by ${p.readers}${p.public_url ? ` (${p.public_url})` : ''}`)
338
+ if (p.modules?.length) lines.push(` modules: ${p.modules.join(', ')}`)
339
+ if (p.projects?.length) lines.push(` projects: ${p.projects.join(', ')}`)
340
+ if (p.url) lines.push(` ${p.url}`)
341
+ }
342
+ if (r.missing.length) lines.push(`In kaloko.config.yml but not on Kaloko yet: ${r.missing.join(', ')}. Run kaloko products sync.`)
343
+ return lines.join('\n')
344
+ }
345
+
271
346
  async function repoUrl (root) {
272
347
  const r = await repoSlug(root)
273
348
  return r?.host === 'github' ? { repo: `https://github.com/${r.slug}` } : r?.host === 'gitlab' ? { repo: `https://gitlab.com/${r.slug}` } : {}
@@ -276,8 +351,9 @@ async function repoUrl (root) {
276
351
  // ---------- init and doctor ----------
277
352
 
278
353
  /** The config block `kaloko init --docs=changelog|full` adds (comments say what each line does). */
279
- export function docsConfigBlock ({ products = null } = {}) {
354
+ export function docsConfigBlock ({ products = null, full = false } = {}) {
280
355
  const lines = ['', '# Visual changelog: kaloko changelog draft writes the Unreleased entry with step references,', '# kaloko release <version> records the release and its pictures (docs/DOCS-CHANGELOG.md on kaloko.app)', 'docs:', ' changelog: CHANGELOG.md', ' version_source: auto # git-tag | package.json | release-please | changesets | semantic-release']
356
+ if (full) lines.push(' dir: docs # guides with step references; kaloko docs publish sends them, kaloko docs check checks them', ' # languages: [en, cs] # the first is the originals\' language; translations sit next to them (guide.cs.md)')
281
357
  if (products?.length) {
282
358
  lines.push('# one product per app in this repository; scenarios say which one they show (product: <slug>)', 'products:')
283
359
  for (const p of products) lines.push(` ${p.slug}:`, ` name: ${JSON.stringify(p.name)}`, ` version: { source: package.json, path: ${p.path} }`, ` docs: { changelog: ${p.dir}/CHANGELOG.md }`)
@@ -298,13 +374,73 @@ export async function detectProducts (root) {
298
374
  return out.length > 1 ? out : []
299
375
  }
300
376
 
301
- /** Diátaxis folders and an index for `kaloko init --docs=full` (only what is missing). */
377
+ /**
378
+ * Diátaxis folders and an index for `kaloko init --docs=full` (only what is missing). Each folder's README says what
379
+ * belongs there and carries a template in a comment (comments never show on Kaloko or in exports).
380
+ */
302
381
  export const DOCS_SKELETON = {
303
- 'docs/index.md': '# Documentation\n\nGuides for the people who use the product. Pictures are step references\n(`![](kaloko:<scenario>/<step>)`) that always show the newest accepted screen.\n\n- [Tutorials](tutorials/) — learn by doing, start to finish\n- [How-to guides](how-to/) — one task each\n- [Reference](reference/) — the facts to look up\n- [Explanation](explanation/) — why it works this way\n',
304
- 'docs/tutorials/README.md': '# Tutorials\n',
305
- 'docs/how-to/README.md': '# How-to guides\n',
306
- 'docs/reference/README.md': '# Reference\n',
307
- 'docs/explanation/README.md': '# Explanation\n'
382
+ 'docs/index.md': '# Documentation\n\nGuides for the people who use the product. Pictures are step references\n(`![](kaloko:<scenario>/<step>)`) that always show the newest accepted screen.\n\n- [Tutorials](tutorials/README.md): learn by doing, start to finish\n- [How-to guides](how-to/README.md): one task each\n- [Reference](reference/README.md): the facts to look up\n- [Explanation](explanation/README.md): why it works this way\n',
383
+ 'docs/tutorials/README.md': `# Tutorials
384
+
385
+ A tutorial takes a newcomer through a first success, start to finish, on a path that always works.
386
+
387
+ <!-- Template (copy into a new file, e.g. first-order.md):
388
+ ---
389
+ title: Your first order
390
+ order: 1
391
+ ---
392
+ # Your first order
393
+
394
+ In ten minutes you will have placed an order and seen it in your account.
395
+
396
+ 1. Open the shop and put a tea in the cart.
397
+ ![Cart](kaloko:checkout/cart)
398
+ 2. Choose **Checkout** and pay with a test card.
399
+ ![Payment](kaloko:checkout/payment)
400
+
401
+ You have placed your first order. Next: [track it](../how-to/track-an-order.md).
402
+ -->
403
+ `,
404
+ 'docs/how-to/README.md': `# How-to guides
405
+
406
+ One task per page, for someone who already knows what they want to do.
407
+
408
+ <!-- Template (copy into a new file, e.g. pay-with-apple-pay.md):
409
+ # Pay with Apple Pay
410
+
411
+ 1. Open the cart and choose **Checkout**.
412
+ ![Cart](kaloko:checkout/cart)
413
+ 2. Choose **Apple Pay** above the card form and confirm on your phone or Mac.
414
+ ![Payment](kaloko:checkout/payment)
415
+
416
+ If the button is missing, your browser has no Apple Pay set up.
417
+ -->
418
+ `,
419
+ 'docs/reference/README.md': `# Reference
420
+
421
+ Facts to look up: fields, limits, settings, keyboard shortcuts. Tables and lists, no story.
422
+
423
+ <!-- Template (copy into a new file, e.g. order-states.md):
424
+ # Order states
425
+
426
+ | State | What it means |
427
+ |---|---|
428
+ | Paid | The payment went through; the order waits for packing. |
429
+ | Shipped | The parcel left the warehouse. |
430
+
431
+ ![Order detail](kaloko:account/order-detail)
432
+ -->
433
+ `,
434
+ 'docs/explanation/README.md': `# Explanation
435
+
436
+ Why things work the way they do: background, choices, trade-offs.
437
+
438
+ <!-- Template (copy into a new file, e.g. why-we-hold-payments.md):
439
+ # Why a payment waits a day
440
+
441
+ Card payments are confirmed the next morning, so a cancelled order costs the customer nothing.
442
+ -->
443
+ `
308
444
  }
309
445
 
310
446
  /**
package/src/lib/ci.mjs CHANGED
@@ -209,3 +209,74 @@ export function addPreviewEnvironment (text, name = 'preview') {
209
209
  const at = m.index + m[0].length
210
210
  return text.slice(0, at) + '\n' + block + text.slice(at)
211
211
  }
212
+
213
+ // ---------- releases (docs/DOCS-CHANGELOG.md → release-tool integrations) ----------
214
+
215
+ const TOOL_SNIPPETS = {
216
+ 'release-please': { after: 'googleapis/release-please-action (id: release)', when: '${{ steps.release.outputs.release_created }}', version: '${{ steps.release.outputs.version }}' },
217
+ changesets: { after: 'changesets/action (id: changesets)', when: "${{ steps.changesets.outputs.published == 'true' }}", version: '' },
218
+ 'semantic-release': { after: 'npx semantic-release', when: null, version: '' }
219
+ }
220
+
221
+ /**
222
+ * .github/workflows/kaloko-release.yml: when a GitHub release is published (release-please, semantic-release with
223
+ * @semantic-release/github and changesets all publish one) or by hand, `kaloko release <version> --no-write` records
224
+ * it on Kaloko: the entry the release tool wrote, the accepted runs that are its pictures, and the docs of that
225
+ * version. `products`: [{ slug, pattern }] (tag patterns such as v* or shop-v*); several products get one step each.
226
+ */
227
+ export function releaseWorkflow ({ products = [{ slug: null, pattern: 'v*' }], pm = 'npm', dependency = true, version = 'latest', node = 22, tool = null }) {
228
+ const { install, kaloko: k } = runner({ pm, dependency, version })
229
+ const several = products.length > 1
230
+ const steps = products.map((p) => {
231
+ const prefix = String(p.pattern || 'v*').split('*')[0]
232
+ const flag = several || p.slug ? ` --product ${q(p.slug)}` : ''
233
+ return ` - name: Record ${p.slug || 'the release'} on Kaloko
234
+ ${several ? ` if: startsWith(env.TAG, '${prefix}')\n` : ''} run: ${k} release "\${TAG#${prefix}}"${flag} --no-write`
235
+ }).join('\n')
236
+ return `# Kaloko: record every release on Kaloko once your release tool has published it: its changelog entry with
237
+ # before/after pictures, the accepted runs that are its reference, and the docs of that version.
238
+ # Written by kaloko ci github --release${tool ? ` (${tool} found)` : ''}. Secret: KALOKO_TOKEN (tester role, upload scope).
239
+ # kaloko release --no-write only reads CHANGELOG.md; the release tool keeps writing it.
240
+ name: Kaloko release
241
+ on:
242
+ release:
243
+ types: [published]
244
+ workflow_dispatch:
245
+ inputs:
246
+ tag:
247
+ description: Tag of the release to record (e.g. v2.4.0)
248
+ required: true
249
+ permissions:
250
+ contents: read
251
+ jobs:
252
+ record:
253
+ runs-on: ubuntu-latest
254
+ timeout-minutes: 10
255
+ env:
256
+ KALOKO_TOKEN: \${{ secrets.KALOKO_TOKEN }}
257
+ GH_TOKEN: \${{ github.token }}
258
+ KALOKO_AGENT: github-actions
259
+ TAG: \${{ github.event.release.tag_name || inputs.tag }}
260
+ steps:
261
+ - uses: actions/checkout@v4
262
+ with:
263
+ ref: \${{ github.event.release.tag_name || inputs.tag }}
264
+ fetch-depth: 0 # the tags and the history since the previous release
265
+ - uses: actions/setup-node@v4
266
+ with:
267
+ node-version: ${node}
268
+ ${install ? `${['pnpm', 'yarn'].includes(pm) ? ' - run: corepack enable\n' : ''} - run: ${install}\n` : ''}${steps}
269
+ `
270
+ }
271
+
272
+ /** The step to paste into an existing release workflow, right after the release tool's own step. */
273
+ export function releaseSnippet ({ tool = null, pm = 'npm', dependency = true, version = 'latest', product = null }) {
274
+ const { kaloko: k } = runner({ pm, dependency, version })
275
+ const s = TOOL_SNIPPETS[tool] || { after: 'the step that publishes the release', when: null, version: '' }
276
+ return `# after ${s.after}:
277
+ - name: Record the release on Kaloko
278
+ ${s.when ? ` if: ${s.when}\n` : ''} run: ${k} release${s.version ? ` ${s.version}` : ''}${product ? ` --product ${q(product)}` : ''} --no-write
279
+ env:
280
+ KALOKO_TOKEN: \${{ secrets.KALOKO_TOKEN }}
281
+ `
282
+ }
@@ -297,11 +297,31 @@ export async function syncDesign ({ config, root, run, scenario, env, client, ar
297
297
  return { pushed: toPush.length, pulled: toPull.length, conflicts: conflicts.length ? conflicts : null, seq: after.seq }
298
298
  }
299
299
 
300
- /** Publishes the draft head as a version (a run of the design environment). */
300
+ /** The notes an agent or person wrote for a version: --notes <file> or --notes - (stdin); null without the option. */
301
+ export async function readNotesArg (value, stdin = process.stdin) {
302
+ if (value == null || value === false) return null
303
+ if (value === true) throw new UserError('--notes takes a file (Markdown, text or JSON) or - for standard input.')
304
+ if (value === '-') { const chunks = []; for await (const c of stdin) chunks.push(c); return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8') }
305
+ try { return await readFile(value, 'utf8') } catch { throw new UserError(`--notes: cannot read ${value}.`) }
306
+ }
307
+
308
+ /** What publishing the draft now would write as "what changed" (kaloko share --notes-preview). */
309
+ export async function previewNotes ({ config, scenario, client, args, say }) {
310
+ const branch = typeof args.branch === 'string' ? args.branch : 'main'
311
+ const r = await client.request('GET', `/orgs/${config.org}/scenarios/${enc(scenario.id)}/versions/draft/notes?branch=${enc(branch)}`)
312
+ if (args.json) { say(JSON.stringify(r, null, 2)); return r }
313
+ const { notesText } = await import('./design-notes.mjs')
314
+ say(`What publishing now would say (${r.changed} changed, ${r.unchanged} unchanged${r.notes?.from ? ` since v${r.notes.from.version}` : ''}):\n\n${notesText(r.notes, { lang: args.lang === 'cs' ? 'cs' : 'en' })}\n\nWrite your own in a file (a summary, then "step-id: what changed" lines) and publish with kaloko share --notes <file>.`)
315
+ return r
316
+ }
317
+
318
+ /** Publishes the draft head as a version (a run of the design environment), with "what changed" notes. */
301
319
  export async function publishVersion ({ config, scenario, client, args, say }) {
302
320
  const branch = typeof args.branch === 'string' ? args.branch : 'main'
303
- const r = await client.request('POST', `/orgs/${config.org}/scenarios/${enc(scenario.id)}/versions`, { branch, ...(typeof args.name === 'string' ? { name: args.name } : {}) })
321
+ const notes = await readNotesArg(args.notes)
322
+ const r = await client.request('POST', `/orgs/${config.org}/scenarios/${enc(scenario.id)}/versions`, { branch, ...(typeof args.name === 'string' ? { name: args.name } : {}), ...(notes ? { notes } : {}) })
304
323
  say(`Version v${r.version}${r.name ? ` "${r.name}"` : ''} · ${r.changed} changed, ${r.unchanged} unchanged${r.changed_steps.length ? ` (${r.changed_steps.join(', ')})` : ''}\n${r.url}`)
324
+ if (r.notes) { const { notesText } = await import('./design-notes.mjs'); say(`\nWhat changed${notes ? ' (your notes over the generated ones)' : ''}:\n${notesText(r.notes)}`) }
305
325
  return r
306
326
  }
307
327
 
@@ -0,0 +1,191 @@
1
+ // "What changed" notes of a design version (batch G, #3): a short plain-language changelog per changed step against
2
+ // the previous version, built from what the service already knows (revision ids, screenshot hashes per viewport,
3
+ // the text the step spec captured from the DOM, the token set of each revision). Pure and dependency-free apart from
4
+ // the token module, so the CLI (preview, --notes) and the Worker (publishing) share it.
5
+ import { flattenTokens } from './tokens.mjs'
6
+
7
+ export const NOTES_SCHEMA = 'qawalk.version-notes.v1'
8
+ export const NOTE_LIMITS = { summary: 2000, step: 1000, texts: 6, tokens: 40 }
9
+
10
+ /** Text in the reader's language: an edited note is one string for everyone, a generated one has en and cs. */
11
+ export const noteText = (t, lang = 'en') => (t == null ? '' : typeof t === 'string' ? t : t[lang] || t.en || '')
12
+
13
+ const clean = (s) => String(s ?? '').replace(/\s+/g, ' ').trim()
14
+ const quote = (s, lang) => (lang === 'cs' ? `„${s}“` : `“${s}”`)
15
+ const short = (s, n = 60) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
16
+ const list = (items, lang) => {
17
+ if (items.length < 2) return items.join('')
18
+ return `${items.slice(0, -1).join(', ')} ${lang === 'cs' ? 'a' : 'and'} ${items[items.length - 1]}`
19
+ }
20
+
21
+ /**
22
+ * The texts a capture shows: accessible names of the spec's elements (from the DOM), the H1 and the page title.
23
+ * `names` may be the spec's elements or a plain list of names (the Worker extracts them in SQL).
24
+ */
25
+ export function textsOf ({ names = [], h1 = [], title = null } = {}) {
26
+ const out = new Set()
27
+ for (const n of [...[].concat(h1 || []), ...names.map((x) => (x && typeof x === 'object' ? x.name : x))]) {
28
+ const t = clean(n)
29
+ if (t && t.length > 1 && !/^[\d\s.,:/-]+$/.test(t)) out.add(t)
30
+ }
31
+ if (title && clean(title)) out.add(clean(title))
32
+ return out
33
+ }
34
+
35
+ /** Texts that appeared and disappeared; one of each becomes "from → to" when that reads naturally. */
36
+ export function textChanges (before, after) {
37
+ const removed = [...before].filter((t) => !after.has(t))
38
+ const added = [...after].filter((t) => !before.has(t))
39
+ if (!removed.length && !added.length) return null
40
+ const out = { kind: 'text', added: added.slice(0, NOTE_LIMITS.texts), removed: removed.slice(0, NOTE_LIMITS.texts) }
41
+ if (added.length > NOTE_LIMITS.texts) out.more_added = added.length - NOTE_LIMITS.texts
42
+ if (removed.length > NOTE_LIMITS.texts) out.more_removed = removed.length - NOTE_LIMITS.texts
43
+ return out
44
+ }
45
+
46
+ /** Token changes between two DTCG sets (resolved values): changed, added and removed names. */
47
+ export function tokenChanges (before, after) {
48
+ const flat = (set) => { try { return new Map(set ? flattenTokens(set).map((t) => [t.name, JSON.stringify(t.value)]) : []) } catch { return new Map() } }
49
+ const a = flat(before), b = flat(after)
50
+ const show = (v) => { try { const x = JSON.parse(v); return typeof x === 'string' || typeof x === 'number' ? String(x) : v } catch { return v } }
51
+ const changed = [], added = [], removed = []
52
+ for (const [name, v] of b) { if (!a.has(name)) added.push(name); else if (a.get(name) !== v) changed.push({ name, from: show(a.get(name)), to: show(v) }) }
53
+ for (const name of a.keys()) if (!b.has(name)) removed.push(name)
54
+ if (!changed.length && !added.length && !removed.length) return null
55
+ return { changed: changed.slice(0, NOTE_LIMITS.tokens), added: added.slice(0, NOTE_LIMITS.tokens), removed: removed.slice(0, NOTE_LIMITS.tokens), count: changed.length + added.length + removed.length }
56
+ }
57
+
58
+ /** One change as a sentence. */
59
+ function changeText (c, lang) {
60
+ const cs = lang === 'cs'
61
+ switch (c.kind) {
62
+ case 'new': return cs ? 'Nový krok.' : 'New step.'
63
+ case 'removed': return cs ? 'Krok z flow zmizel.' : 'Removed from the flow.'
64
+ case 'locales': return cs ? `Nově i v jazycích ${c.added.join(', ')}.` : `Now also in ${c.added.join(', ')}.`
65
+ case 'text': {
66
+ const parts = []
67
+ const [a] = c.added, [r] = c.removed
68
+ if (c.added.length === 1 && c.removed.length === 1 && !c.more_added && !c.more_removed) parts.push(`${quote(short(r), lang)} → ${quote(short(a), lang)}`)
69
+ else {
70
+ if (c.added.length) parts.push(`${cs ? 'nově' : 'new'} ${c.added.map((t) => quote(short(t, 40), lang)).join(', ')}${c.more_added ? (cs ? ` a ${c.more_added} dalších` : ` and ${c.more_added} more`) : ''}`)
71
+ if (c.removed.length) parts.push(`${cs ? 'zmizelo' : 'gone'} ${c.removed.map((t) => quote(short(t, 40), lang)).join(', ')}${c.more_removed ? (cs ? ` a ${c.more_removed} dalších` : ` and ${c.more_removed} more`) : ''}`)
72
+ }
73
+ return `${cs ? 'Text' : 'Text'}: ${parts.join('; ')}.`
74
+ }
75
+ case 'look': return c.viewports?.length ? (cs ? `Vypadá jinak na ${list(c.viewports, lang)}.` : `Looks different on ${list(c.viewports, lang)}.`) : (cs ? 'Vypadá jinak.' : 'Looks different.')
76
+ case 'same_look': return cs ? 'Vypadá stejně, změnil se jen kód stránky.' : 'Looks the same; only the page code changed.'
77
+ case 'tokens': return cs ? `Používá novou sadu tokenů (${c.count} ${c.count === 1 ? 'změna' : c.count < 5 ? 'změny' : 'změn'}).` : `Uses the new token set (${c.count} change${c.count === 1 ? '' : 's'}).`
78
+ case 'not_rendered': return cs ? 'Zatím bez nového snímku.' : 'Not rendered yet.'
79
+ default: return ''
80
+ }
81
+ }
82
+
83
+ export const stepText = (changes, lang) => changes.map((c) => changeText(c, lang)).filter(Boolean).join(' ')
84
+
85
+ /**
86
+ * Builds the notes from the facts of each changed step. `steps`: [{ step, title, locales, reason: new|changed|removed,
87
+ * look: [viewports whose screenshot differs] | null (no captures to compare), texts: { before: Set, after: Set } | null,
88
+ * tokens: boolean (the revision's token set changed), files: boolean }], `tokens`: tokenChanges() of the version or
89
+ * null, `unchanged`: count of unchanged step×locale, `from`: the previous version { version, id } or null.
90
+ */
91
+ export function buildNotes ({ steps = [], tokens = null, unchanged = 0, from = null }) {
92
+ const out = []
93
+ for (const s of steps) {
94
+ const changes = []
95
+ if (s.reason === 'new') changes.push({ kind: 'new' })
96
+ else if (s.reason === 'removed') changes.push({ kind: 'removed' })
97
+ else {
98
+ if (s.new_locales?.length) changes.push({ kind: 'locales', added: s.new_locales })
99
+ if (s.texts) { const t = textChanges(s.texts.before, s.texts.after); if (t) changes.push(t) }
100
+ if (s.look == null) changes.push({ kind: 'not_rendered' })
101
+ else if (s.look.length) changes.push({ kind: 'look', viewports: s.look })
102
+ if (s.tokens && tokens) changes.push({ kind: 'tokens', count: tokens.count })
103
+ if (s.look && !s.look.length && !changes.some((c) => c.kind === 'text' || c.kind === 'tokens' || c.kind === 'locales')) changes.push({ kind: 'same_look' })
104
+ }
105
+ out.push({ step: s.step, title: s.title || s.step, locales: s.locales || [], reason: s.reason, changes, text: { en: stepText(changes, 'en'), cs: stepText(changes, 'cs') } })
106
+ }
107
+ return { schema: NOTES_SCHEMA, from, summary: summaryOf(out, tokens, unchanged), steps: out, tokens, unchanged }
108
+ }
109
+
110
+ function summaryOf (steps, tokens, unchanged) {
111
+ const mk = (lang) => {
112
+ const cs = lang === 'cs'
113
+ const changed = steps.filter((s) => s.reason !== 'removed')
114
+ const removed = steps.filter((s) => s.reason === 'removed')
115
+ const parts = []
116
+ if (!steps.length) parts.push(cs ? 'Beze změny proti předchozí verzi.' : 'Nothing changed since the previous version.')
117
+ else {
118
+ const names = (xs) => xs.slice(0, 6).map((s) => s.title).join(', ') + (xs.length > 6 ? (cs ? ` a ${xs.length - 6} dalších` : ` and ${xs.length - 6} more`) : '')
119
+ if (changed.length) parts.push(cs ? `Změněno: ${names(changed)}.` : `Changed: ${names(changed)}.`)
120
+ if (removed.length) parts.push(cs ? `Odebráno: ${names(removed)}.` : `Removed: ${names(removed)}.`)
121
+ if (unchanged) parts.push(cs ? `Beze změny: ${unchanged} (počítáno po jazycích).` : `Unchanged: ${unchanged} (counted per locale).`)
122
+ }
123
+ if (tokens?.changed?.length) {
124
+ const shown = tokens.changed.slice(0, 3)
125
+ const t = shown.map((c) => `${c.name} ${c.from} → ${c.to}`).join(', ')
126
+ const more = tokens.count - shown.length
127
+ parts.push(cs ? `Tokeny: ${t}${more ? ` a ${more} ${more === 1 ? 'další' : 'dalších'}` : ''}.` : `Tokens: ${t}${more ? ` and ${more} more` : ''}.`)
128
+ } else if (tokens?.count) parts.push(cs ? `Sada tokenů: ${tokens.count} ${tokens.count === 1 ? 'změna' : tokens.count < 5 ? 'změny' : 'změn'}.` : `Token set: ${tokens.count} change${tokens.count === 1 ? '' : 's'}.`)
129
+ return parts.join(' ')
130
+ }
131
+ return { en: mk('en'), cs: mk('cs') }
132
+ }
133
+
134
+ /**
135
+ * Notes someone wrote (an agent with --notes, a person in the canvas): JSON { summary, steps: { id: text } } or text
136
+ * where lines "step-id: text" (optionally as a list item) belong to that step and the rest is the summary.
137
+ */
138
+ export function parseNotes (input, stepIds = []) {
139
+ if (input == null || input === '') return null
140
+ let obj = input
141
+ if (typeof input === 'string') {
142
+ const s = input.trim()
143
+ if (s.startsWith('{')) { try { obj = JSON.parse(s) } catch { obj = s } } else obj = s
144
+ }
145
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
146
+ const steps = {}
147
+ const src = obj.steps && typeof obj.steps === 'object' ? obj.steps : {}
148
+ for (const [k, v] of Array.isArray(src) ? src.map((x) => [x?.step, x?.text]) : Object.entries(src)) { const t = clean(typeof v === 'string' ? v : v?.text ?? v?.en ?? ''); if (k && t) steps[String(k)] = t.slice(0, NOTE_LIMITS.step) }
149
+ const summary = typeof obj.summary === 'string' ? obj.summary.trim().slice(0, NOTE_LIMITS.summary) : null
150
+ return summary || Object.keys(steps).length ? { summary, steps } : null
151
+ }
152
+ const ids = new Set(stepIds)
153
+ const steps = {}
154
+ const rest = []
155
+ let current = null
156
+ for (const line of String(obj).split(/\r?\n/)) {
157
+ const m = line.match(/^\s*(?:[-*]\s+)?(?:\*\*|`)?([A-Za-z0-9][A-Za-z0-9._-]*)(?:\*\*|`)?\s*:\s+(.*)$/)
158
+ if (m && ids.has(m[1])) { current = m[1]; steps[current] = clean(m[2]); continue }
159
+ if (current && /^\s{2,}\S/.test(line)) { steps[current] = clean(`${steps[current]} ${line}`); continue }
160
+ current = null
161
+ rest.push(line)
162
+ }
163
+ for (const k of Object.keys(steps)) steps[k] = steps[k].slice(0, NOTE_LIMITS.step)
164
+ const summary = rest.join('\n').replace(/^\s*#+\s.*$/gm, '').trim().slice(0, NOTE_LIMITS.summary) || null
165
+ return summary || Object.keys(steps).length ? { summary, steps } : null
166
+ }
167
+
168
+ /** Generated notes with the edits applied: an edited summary or step text replaces the generated one. */
169
+ export function mergeNotes (auto, edited, { by = null, at = null, titles = {} } = {}) {
170
+ const base = auto ? JSON.parse(JSON.stringify(auto)) : { schema: NOTES_SCHEMA, from: null, summary: null, steps: [], tokens: null, unchanged: 0 }
171
+ if (!edited) return base
172
+ if (edited.summary) { base.summary = edited.summary; base.summary_edited = true }
173
+ for (const [id, text] of Object.entries(edited.steps || {})) {
174
+ const s = base.steps.find((x) => x.step === id)
175
+ if (s) { s.text = text; s.edited = true } else base.steps.push({ step: id, title: titles[id] || id, locales: [], reason: 'note', changes: [], text, edited: true })
176
+ }
177
+ if (by) base.edited_by = by
178
+ if (at) base.edited_at = at
179
+ return base
180
+ }
181
+
182
+ /** The notes as plain text for the terminal and MCP. */
183
+ export function notesText (notes, { lang = 'en', title = '' } = {}) {
184
+ if (!notes) return 'No notes.'
185
+ const lines = []
186
+ if (title) lines.push(title)
187
+ const sum = noteText(notes.summary, lang)
188
+ if (sum) lines.push(sum)
189
+ for (const s of notes.steps || []) lines.push(`- ${s.step}${s.title && s.title !== s.step ? ` (${s.title})` : ''}${s.locales?.length ? ` [${s.locales.join(', ')}]` : ''}: ${noteText(s.text, lang)}${s.edited ? '' : ''}`)
190
+ return lines.join('\n')
191
+ }