kaloko 0.15.0 → 0.16.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.
@@ -16,8 +16,8 @@ export function browserVersion (page) {
16
16
  try { const b = page.context().browser(); const v = b?.version?.(); return v ? `${b.browserType?.().name?.() || 'browser'} ${v}` : null } catch { return null }
17
17
  }
18
18
 
19
- export async function encodeImage (page, png, { format = 'webp', quality = 0.82, thumbWidth = 640, thumbQuality = 0.8, marks = [], scale = 1, masks = [] } = {}) {
20
- const result = await page.evaluate(async ({ dataUrl, format, quality, thumbWidth, thumbQuality, marks, scale, masks }) => {
19
+ export async function encodeImage (page, png, { format = 'webp', quality = 0.82, thumbWidth = 640, thumbQuality = 0.8, marks = [], scale = 1, masks = [], outline = null } = {}) {
20
+ const result = await page.evaluate(async ({ dataUrl, format, quality, thumbWidth, thumbQuality, marks, scale, masks, outline }) => {
21
21
  const img = new Image()
22
22
  await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = dataUrl })
23
23
  const full = document.createElement('canvas'); full.width = img.width; full.height = img.height
@@ -30,6 +30,12 @@ export async function encodeImage (page, png, { format = 'webp', quality = 0.82,
30
30
  for (let i = -h; i < w; i += 12 * scale) { g.beginPath(); g.moveTo(x + i, y + h); g.lineTo(x + i + h, y); g.stroke() }
31
31
  g.restore()
32
32
  }
33
+ // a detail's element (details:): a ring around it, a few pixels out so it never covers the element itself
34
+ if (outline) {
35
+ const gap = 5 * scale; const x = outline.x * scale - gap; const y = outline.y * scale - gap; const w = outline.w * scale + gap * 2; const h = outline.h * scale + gap * 2
36
+ const ring = () => { g.beginPath(); if (g.roundRect) g.roundRect(x, y, w, h, 6 * scale); else g.rect(x, y, w, h) }
37
+ g.save(); ring(); g.strokeStyle = '#151514'; g.lineWidth = 5 * scale; g.stroke(); ring(); g.strokeStyle = '#ffe452'; g.lineWidth = 3 * scale; g.stroke(); g.restore()
38
+ }
33
39
  for (const m of marks) {
34
40
  const r = 13 * scale; const x = Math.max(r + 2, m.x * scale); const y = Math.max(r + 2, m.y * scale)
35
41
  g.beginPath(); g.arc(x, y, r, 0, Math.PI * 2); g.fillStyle = '#ffe452'; g.fill(); g.lineWidth = 2 * scale; g.strokeStyle = '#151514'; g.stroke()
@@ -50,7 +56,7 @@ export async function encodeImage (page, png, { format = 'webp', quality = 0.82,
50
56
  const t = document.createElement('canvas'); t.width = tw; t.height = th
51
57
  const tg = t.getContext('2d'); tg.imageSmoothingEnabled = true; tg.imageSmoothingQuality = 'high'; tg.drawImage(src, 0, 0, tw, th)
52
58
  return { image: await blob(full, format === 'png' ? 'image/png' : 'image/webp', quality), thumb: await blob(t, 'image/webp', thumbQuality) }
53
- }, { dataUrl: `data:image/png;base64,${png.toString('base64')}`, format, quality, thumbWidth, thumbQuality, marks, scale, masks })
59
+ }, { dataUrl: `data:image/png;base64,${png.toString('base64')}`, format, quality, thumbWidth, thumbQuality, marks, scale, masks, outline })
54
60
  return { image: Buffer.from(result.image.bytes), thumb: Buffer.from(result.thumb.bytes), imageType: result.image.type, thumbType: result.thumb.type }
55
61
  }
56
62
 
@@ -95,6 +101,7 @@ import { checkLinks, checkHreflang, requester, walkCache, NETWORK_ASSERTS, isola
95
101
  import { keyboardChecks, needsKeyboard, KEYBOARD_ASSERTS } from './keyboard.mjs'
96
102
  import { repeatRegions, resolveIgnore } from './stability.mjs'
97
103
  import { applyVariant, engineOf, markEngineGaps, variantFields } from './dimensions.mjs'
104
+ import { detailsFor, detailFacts, detailBox, detailName, DETAIL_PADDING } from './detail.mjs'
98
105
  import { liveValueChecks } from './assertions.mjs'
99
106
 
100
107
  /** Probe arguments derived from the node's criteria. */
@@ -253,6 +260,8 @@ export async function captureStep ({ page, node, locale, viewports, run, console
253
260
  image = paths.step(stepId, locale, viewport, 'png'); thumbWritten = false
254
261
  await writeFile(image, shot)
255
262
  }
263
+ // named details for guides (details:): from the same masked page as the screenshot, with the same mask boxes
264
+ const details = item ? [] : await captureDetails({ page, node, locale, viewport, run, stepId, maskRects, scale: shotScale, engine, dpr })
256
265
  // network checks (internal links, hreflang pairs) run once per page, outside the page, with a walk-wide cache
257
266
  const net = (node.criteria || []).filter((c) => c.check.type === 'deterministic' && NETWORK_ASSERTS.has(c.check.assert))
258
267
  if (net.length) {
@@ -297,7 +306,7 @@ export async function captureStep ({ page, node, locale, viewports, run, console
297
306
  Object.assign(result.checks, kb.checks)
298
307
  result.boxes = { ...result.boxes, ...kb.boxes }
299
308
  keyboard = kb.keyboard
300
- for (const s of keyboard?.shots || []) await addCapture(run.dir, { type: 'attachment', kind: 'focus', node: node.id, ...(item ? { item: item.id } : {}), locale, viewport, title: scrubText(redact(`Focus ${s.n}: ${s.name}`), mask.auto ? mask : null), file: s.file, at: new Date().toISOString() })
309
+ for (const s of keyboard?.shots || []) await addCapture(run.dir, { type: 'attachment', kind: 'focus', node: node.id, ...(item ? { item: item.id } : {}), locale, viewport, n: s.n, title: scrubText(redact(`Focus ${s.n}: ${s.name}`), mask.auto ? mask : null), file: s.file, at: new Date().toISOString() })
301
310
  }
302
311
  const record = {
303
312
  schema: 'qawalk.capture.v1',
@@ -318,6 +327,7 @@ export async function captureStep ({ page, node, locale, viewports, run, console
318
327
  ...(ignore.length ? { ignore } : {}),
319
328
  ...(unstable ? { unstable } : {}),
320
329
  ...(keyboard ? { keyboard } : {}),
330
+ ...(details.length ? { details } : {}),
321
331
  ...(item ? { item: { id: item.id, path: item.path, ...(item.title ? { title: item.title } : {}), ...(item.purpose ? { purpose: item.purpose } : {}) } } : {}),
322
332
  ...(fields ? { fields: { ...(await page.evaluate(readFields, fields).catch(() => ({}))), ...(fields.some((f) => (f.from || f.id) === 'http') ? Object.fromEntries(fields.filter((f) => (f.from || f.id) === 'http').map((f) => [f.id, extra.http_status ?? result.http_status ?? null])) : {}) } } : {}),
323
333
  files: { png: relative(run.dir, image), image: relative(run.dir, image), thumb: thumbWritten ? relative(run.dir, thumb) : null, html: relative(run.dir, html) }
@@ -341,6 +351,42 @@ export async function captureStep ({ page, node, locale, viewports, run, console
341
351
  return results
342
352
  }
343
353
 
354
+ /**
355
+ * The named details of a step in one capture (details:, detail.mjs): each element in its section or card (detailBox),
356
+ * outlined, as an attachment of kind detail with the element's box inside the picture. The page is masked here as for
357
+ * the screenshot: masked text is already replaced, `maskRects` (document pixels) are drawn as boxes, and a detail whose
358
+ * masks cannot be drawn is not stored. Returns the capture record's `details`: { id, file, box, size, context } or
359
+ * { id, error } for an element that was not found or not visible.
360
+ */
361
+ async function captureDetails ({ page, node, locale, viewport, run, stepId, maskRects = [], scale = 1, engine = 'chromium', dpr = 1 }) {
362
+ const list = detailsFor(node, viewport)
363
+ const out = []
364
+ for (const d of list) {
365
+ const facts = await page.evaluate(detailFacts, { selector: d.selector, context: d.context || null }).catch((e) => ({ error: String(e.message).split('\n')[0].slice(0, 200) }))
366
+ if (!facts || facts.error) { out.push({ id: d.id, error: `${d.selector}: ${facts?.error || 'not found'}` }); continue }
367
+ const { crop, el, by } = detailBox(facts, { context: d.context || null, padding: d.padding ?? DETAIL_PADDING })
368
+ // documentation runs (scale 2) get 2× details, the way the screenshot is taken
369
+ let png; let k = 1
370
+ if (scale === 2 && engine === 'chromium') {
371
+ const cdp = await page.context().newCDPSession(page)
372
+ try { png = Buffer.from((await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, clip: { x: crop.x, y: crop.y, width: crop.w, height: crop.h, scale: 2 / (dpr || 1) } })).data, 'base64'); k = 2 } finally { await cdp.detach().catch(() => {}) }
373
+ } else png = await page.screenshot({ fullPage: true, clip: { x: crop.x, y: crop.y, width: crop.w, height: crop.h }, scale: 'css', animations: 'disabled', caret: 'hide' })
374
+ const masks = maskRects.map((r) => ({ ...r, x: r.x - crop.x, y: r.y - crop.y })).filter((r) => r.x < crop.w && r.y < crop.h && r.x + r.w > 0 && r.y + r.h > 0)
375
+ let image
376
+ try { image = (await encodeImage(page, png, { format: 'png', masks, outline: el, scale: k })).image } catch (error) {
377
+ if (masks.length) { out.push({ id: d.id, error: `the masks could not be drawn (${String(error.message).slice(0, 120)}); not stored` }); continue }
378
+ image = png
379
+ }
380
+ const file = runPaths(run.dir).step(stepId, locale, viewport, `detail-${d.id}.png`)
381
+ await writeFile(file, image)
382
+ const name = detailName(d, locale)
383
+ const entry = { id: d.id, file: relative(run.dir, file), box: el, size: [crop.w, crop.h], context: by }
384
+ out.push(entry)
385
+ await addCapture(run.dir, { type: 'attachment', kind: 'detail', node: node.id, locale, viewport, id: d.id, name, title: name, file: entry.file, box: el, size: entry.size, at: new Date().toISOString() })
386
+ }
387
+ return out
388
+ }
389
+
344
390
  /**
345
391
  * A focus shot of the keyboard walk with the mask on: the page masked for the moment of the shot, boxes drawn over
346
392
  * the masked places in the clip, the page restored (the focus stays where the walk put it).
@@ -14,10 +14,13 @@ const PARAMS = {
14
14
  media: /^(video|image)$/
15
15
  }
16
16
  export const REF_SCHEME = 'kaloko:'
17
+ /** A named detail of a step (details: in the scenario, src/lib/detail.mjs DETAIL_ID): `kaloko:<scenario>/<step>#<id>`. */
18
+ const DETAIL = /^[A-Za-z0-9][A-Za-z0-9_-]{0,59}$/
17
19
 
18
20
  /**
19
- * A step reference `kaloko:<scenario>/<step>[?locale=&viewport=&version=&state=&media=]`, or the short `kaloko:<step>`
20
- * that means a step of the product's default scenario. `version` is a released version, `latest` (default) or `prev`
21
+ * A step reference `kaloko:<scenario>/<step>[?locale=&viewport=&version=&state=&media=][#<detail>]`, or the short
22
+ * `kaloko:<step>` that means a step of the product's default scenario. `#<detail>` asks for a named detail of the step
23
+ * (details: in the scenario) instead of the whole screen. `version` is a released version, `latest` (default) or `prev`
21
24
  * (the release before the one the text belongs to); `state` picks a variant of the capture (dark, webkit, reduce: the
22
25
  * dimension values in the capture key); `media` is `video` (play the step's recording; docs check warns when the
23
26
  * accepted run has none) or `image` (never play it). Without `media` the recording plays when the run has one.
@@ -26,13 +29,16 @@ export const REF_SCHEME = 'kaloko:'
26
29
  export function parseStepRef (ref) {
27
30
  const s = String(ref || '').trim()
28
31
  if (!s.startsWith(REF_SCHEME)) return null
29
- const rest = s.slice(REF_SCHEME.length)
32
+ let rest = s.slice(REF_SCHEME.length)
33
+ const hi = rest.indexOf('#')
34
+ const detail = hi < 0 ? null : rest.slice(hi + 1)
35
+ if (hi >= 0) { rest = rest.slice(0, hi); if (!DETAIL.test(detail)) return null }
30
36
  const qi = rest.indexOf('?')
31
37
  const path = qi < 0 ? rest : rest.slice(0, qi)
32
38
  let parts
33
39
  try { parts = path.split('/').map((p) => decodeURIComponent(p)) } catch { return null }
34
40
  if (!parts.length || parts.length > 2 || parts.some((p) => !ID.test(p))) return null
35
- const out = { scenario: parts.length === 2 ? parts[0] : null, step: parts[parts.length - 1], locale: null, viewport: null, version: null, state: null, media: null }
41
+ const out = { scenario: parts.length === 2 ? parts[0] : null, step: parts[parts.length - 1], locale: null, viewport: null, version: null, state: null, media: null, detail }
36
42
  const q = new URLSearchParams(qi < 0 ? '' : rest.slice(qi + 1))
37
43
  for (const [k, v] of q) {
38
44
  if (!(k in PARAMS)) return null
@@ -44,10 +50,10 @@ export function parseStepRef (ref) {
44
50
  }
45
51
 
46
52
  /** The reference text of a parsed (or hand-built) reference; parameters in a fixed order. */
47
- export function formatStepRef ({ scenario = null, step, locale = null, viewport = null, version = null, state = null, media = null }) {
53
+ export function formatStepRef ({ scenario = null, step, locale = null, viewport = null, version = null, state = null, media = null, detail = null }) {
48
54
  const path = `${scenario ? `${encodeURIComponent(scenario)}/` : ''}${encodeURIComponent(step)}`
49
55
  const q = Object.entries({ locale, viewport, version, state, media }).filter(([, v]) => v != null && v !== '')
50
- return `${REF_SCHEME}${path}${q.length ? `?${q.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')}` : ''}`
56
+ return `${REF_SCHEME}${path}${q.length ? `?${q.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')}` : ''}${detail ? `#${detail}` : ''}`
51
57
  }
52
58
 
53
59
  /** Every image with a step reference in a Markdown text: { alt, ref, parsed, index }; broken ones have parsed null. */
@@ -44,8 +44,11 @@ export const HIDDEN = {
44
44
  ci: ['wait', 'github-env', 'dotenv']
45
45
  }
46
46
 
47
- /** Commands whose unknown options stop the command instead of a warning: they delete, publish or change access. */
48
- export const STRICT = new Set(['prune', 'trash', 'restore', 'keep', 'archive', 'public', 'release', 'comment'])
47
+ /**
48
+ * Commands whose unknown options stop the command instead of a warning: they delete, publish or change access. A
49
+ * "command sub" entry makes only that sub-command strict (kaloko projects lists, kaloko projects create writes).
50
+ */
51
+ export const STRICT = new Set(['prune', 'trash', 'restore', 'keep', 'archive', 'public', 'release', 'comment', 'projects create', 'projects rename', 'projects sync'])
49
52
 
50
53
  /**
51
54
  * argv → { _: positional, <option>: value | true }. `--key=value` splits at the first `=` only; `--key value` takes
@@ -140,6 +143,6 @@ export function checkOptions (help, name, args) {
140
143
  const unknown = Object.keys(args).filter((k) => k !== '_' && k !== '' && !known.has(k))
141
144
  if (!unknown.length) return []
142
145
  const lines = unknown.map((k) => { const guess = closest(k, [...known]); return `kaloko ${name} does not take --${k}.${guess ? ` Did you mean --${guess}?` : ''}` })
143
- if (STRICT.has(name)) throw new UsageError(`${lines.join('\n')}\nNothing was changed. kaloko help ${name} lists its options.`)
146
+ if (STRICT.has(name) || STRICT.has(`${name} ${args._?.[0]}`)) throw new UsageError(`${lines.join('\n')}\nNothing was changed. kaloko help ${name} lists its options.`)
144
147
  return lines.map((l) => `! ${l} It is ignored (kaloko help ${name} lists the options).`)
145
148
  }
@@ -0,0 +1,102 @@
1
+ // Named details of a step (`details:` in the scenario): pictures for user guides and docs that show one element in
2
+ // the part of the page around it (its section or card), outlined, in every locale and viewport. The capture
3
+ // (capture.mjs captureDetails) reads the element and its ancestors in the page (detailFacts, self-contained: it runs
4
+ // in the browser), chooses the box here (detailBox, plain arithmetic, unit-tested) and stores the picture as an
5
+ // attachment of kind `detail` with the element's box inside the picture, so the canvas and docs can point at it.
6
+ import { stateOfKey } from './manifest-core.mjs'
7
+ import { localized } from './util.mjs'
8
+
9
+ /** The smallest context worth showing in a guide (CSS px); narrower viewports cap the width. */
10
+ export const DETAIL_MIN = { w: 480, h: 240 }
11
+ export const DETAIL_PADDING = 16
12
+ export const DETAIL_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,59}$/
13
+ export const CONTEXTS = ['section', 'card', 'page']
14
+
15
+ /** The details of a step that apply to a capture key (a detail with `states:` is taken in those states only). */
16
+ export function detailsFor (node, key) {
17
+ const state = stateOfKey(key)
18
+ return (node?.details || []).filter((d) => d && DETAIL_ID.test(d.id || '') && d.selector && (!Array.isArray(d.states) || !d.states.length || d.states.includes(state)))
19
+ }
20
+
21
+ /** A detail's name in a locale (`name` may be one text or one per locale). */
22
+ export const detailName = (d, locale) => localized(d.name, locale) || d.id
23
+
24
+ /**
25
+ * Runs in the page (keep it self-contained): the element `selector` names and its ancestors up to <body>, as boxes in
26
+ * document pixels, with what kind of container each is. `landmark`: an HTML section element or landmark role;
27
+ * `card`: an article, list item, fieldset, group, an element whose class says card/tile/panel/box, or one with its own
28
+ * frame (a border, a shadow, or rounded corners on a background). A `context` that is a CSS selector is looked up
29
+ * with closest() and returned as `custom` ('missing' when the element is not inside one, 'invalid' for a bad selector).
30
+ */
31
+ export function detailFacts ({ selector, context = null }) {
32
+ let el
33
+ try { el = document.querySelector(selector) } catch { return { error: 'invalid selector' } }
34
+ if (!el) return { error: 'not found' }
35
+ const sx = scrollX; const sy = scrollY
36
+ const box = (e) => { const r = e.getBoundingClientRect(); return { x: r.left + sx, y: r.top + sy, w: r.width, h: r.height } }
37
+ const b = box(el)
38
+ if (!b.w || !b.h) return { error: 'not visible' }
39
+ const LANDMARK = /^(main|section|article|aside|nav|header|footer|form|dialog|fieldset)$/
40
+ const ROLES = /^(main|region|dialog|alertdialog|form|navigation|complementary|banner|contentinfo|search)$/
41
+ const CARDROLES = /^(article|listitem|group)$/
42
+ const CARDCLASS = /(^|[-_\s])(card|tile|panel|box|modal|popover|widget)s?([-_\s]|$)/i
43
+ const clear = (c) => !c || c === 'transparent' || /rgba\([^)]*,\s*0\)$/.test(c)
44
+ let custom = null
45
+ if (context && !['section', 'card', 'page'].includes(context)) {
46
+ try { const c = el.closest(context); custom = c ? box(c) : 'missing' } catch { custom = 'invalid' }
47
+ }
48
+ const ancestors = []
49
+ for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
50
+ const cs = getComputedStyle(a)
51
+ const tag = a.tagName.toLowerCase(); const role = a.getAttribute('role') || ''
52
+ const cls = typeof a.className === 'string' ? a.className : (a.className?.baseVal || '')
53
+ const framed = (parseFloat(cs.borderTopWidth) > 0 && cs.borderTopStyle !== 'none' && parseFloat(cs.borderBottomWidth) > 0) || cs.boxShadow !== 'none' || (parseFloat(cs.borderTopLeftRadius) > 0 && !clear(cs.backgroundColor))
54
+ ancestors.push({ ...box(a), tag, landmark: LANDMARK.test(tag) || ROLES.test(role), card: ['article', 'li', 'fieldset'].includes(tag) || CARDROLES.test(role) || CARDCLASS.test(cls) || framed })
55
+ }
56
+ return { el: b, ancestors, custom, viewport: { w: innerWidth, h: innerHeight }, doc: { w: document.documentElement.scrollWidth, h: document.documentElement.scrollHeight } }
57
+ }
58
+
59
+ /**
60
+ * The picture of a detail from the facts of the page: { crop, el, by } in CSS pixels, `crop` in the document, `el`
61
+ * (the element) inside the crop, `by` what chose the box.
62
+ * - `context` unset: the nearest ancestor that is a section or landmark or a card and at least DETAIL_MIN (on a narrow
63
+ * screen at least 90 % of its width); none: the element with room around it, DETAIL_MIN centred on it.
64
+ * - 'section' or 'card': the nearest ancestor of that kind and at least that big; 'page': one screen (the viewport)
65
+ * around the element; a CSS selector: the nearest ancestor that matches it, whatever its size.
66
+ * Then `padding` around it (16 px by default), at most one screen wide and high around the element (the element
67
+ * itself always fits), inside the document.
68
+ */
69
+ export function detailBox (facts, { context = null, padding = DETAIL_PADDING, min = DETAIL_MIN } = {}) {
70
+ const { el, ancestors = [], viewport, doc } = facts
71
+ const pad = Number.isFinite(padding) && padding >= 0 ? padding : DETAIL_PADDING
72
+ // on a phone a card rarely spans the whole width: 90 % of the screen is wide enough
73
+ const minW = Math.min(min.w, Math.floor(viewport.w * 0.9)); const minH = Math.min(min.h, viewport.h)
74
+ const big = (b) => b.w >= minW && b.h >= minH
75
+ const holds = (b) => b.x <= el.x + 0.5 && b.y <= el.y + 0.5 && b.x + b.w >= el.x + el.w - 0.5 && b.y + b.h >= el.y + el.h - 0.5
76
+ const cx = el.x + el.w / 2; const cy = el.y + el.h / 2
77
+ let ctx = null; let by = null
78
+ if (context === 'page') { ctx = { x: cx - viewport.w / 2, y: cy - viewport.h / 2, w: viewport.w, h: viewport.h }; by = 'page' }
79
+ else if (context && !CONTEXTS.includes(context)) { if (facts.custom && typeof facts.custom === 'object') { ctx = facts.custom; by = 'selector' } }
80
+ else {
81
+ const kind = context === 'section' ? (a) => a.landmark : context === 'card' ? (a) => a.card : (a) => a.landmark || a.card
82
+ const hit = ancestors.find((a) => kind(a) && big(a) && holds(a))
83
+ if (hit) { ctx = hit; by = context || (hit.landmark ? 'section' : 'card') }
84
+ }
85
+ if (!ctx) { const w = Math.max(minW, el.w); const h = Math.max(minH, el.h); ctx = { x: cx - w / 2, y: cy - h / 2, w, h }; by = 'element' }
86
+ let x = ctx.x - pad; let y = ctx.y - pad; let w = ctx.w + pad * 2; let h = ctx.h + pad * 2
87
+ // at most one screen around the element: a long section or a wide page is cut around it, never through it
88
+ const fit = (start, size, cap, center, lo, hi) => {
89
+ if (size <= cap) return [start, size]
90
+ const s = Math.min(Math.max(center - cap / 2, start), start + size - cap)
91
+ return [Math.min(s, lo), Math.max(cap, hi - Math.min(s, lo))]
92
+ }
93
+ ;[x, w] = fit(x, w, viewport.w, cx, el.x - pad, el.x + el.w + pad)
94
+ ;[y, h] = fit(y, h, viewport.h, cy, el.y - pad, el.y + el.h + pad)
95
+ // inside the document: moved in first, cut only when the document is smaller
96
+ const keep = (start, size, total) => { const s = Math.max(0, Math.min(start, total - size)); return [s, Math.min(size, total - s)] }
97
+ ;[x, w] = keep(x, w, doc.w)
98
+ ;[y, h] = keep(y, h, doc.h)
99
+ const crop = { x: Math.floor(x), y: Math.floor(y), w: 0, h: 0 }
100
+ crop.w = Math.max(1, Math.min(Math.ceil(x + w), doc.w) - crop.x); crop.h = Math.max(1, Math.min(Math.ceil(y + h), doc.h) - crop.y)
101
+ return { crop, el: { x: Math.round(el.x - crop.x), y: Math.round(el.y - crop.y), w: Math.round(el.w), h: Math.round(el.h) }, by }
102
+ }
@@ -15,7 +15,8 @@ export const ZIP_FORMATS = ['md', 'docusaurus', 'vitepress', 'mkdocs', 'html']
15
15
  export function assetFile (pic) {
16
16
  const ext = (String(pic.file || pic.path || '').match(/\.(png|jpe?g|webp|gif)$/i) || [, 'png'])[1].toLowerCase()
17
17
  const safe = (s) => String(s || '').replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 60)
18
- return `assets/${safe(pic.scenario)}--${safe(pic.step?.id || pic.step)}--${safe(pic.locale)}--${safe(pic.viewport)}--${safe(String(pic.run?.id || pic.run || '').split('-').pop())}.${ext}`
18
+ // a named detail (#<detail>) is its own picture next to the screen of the same step
19
+ return `assets/${safe(pic.scenario)}--${safe(pic.step?.id || pic.step)}${pic.detail?.id ? `--${safe(pic.detail.id)}` : ''}--${safe(pic.locale)}--${safe(pic.viewport)}--${safe(String(pic.run?.id || pic.run || '').split('-').pop())}.${ext}`
19
20
  }
20
21
 
21
22
  /**
package/src/lib/docs.mjs CHANGED
@@ -81,11 +81,13 @@ export async function collectDocs (root, product) {
81
81
  * What needs attention in the docs, from the repository alone: broken references (not kaloko:<scenario>/<step>),
82
82
  * references to scenarios or steps the scenario files do not have (`scenarios`: Map id → Set of step ids), short
83
83
  * references without a default scenario, missing and outdated translations, and user-visible changelog items without a
84
- * picture. Returns { pages, broken, unknown, translations, changelog_without_pictures }.
84
+ * picture; with `details` (Map '<scenario>/<step>' → Set of detail ids) also references to a detail the step does not
85
+ * declare (a warning: readers see the whole screen). Returns { pages, broken, unknown, missing_details, translations,
86
+ * changelog_without_pictures }.
85
87
  */
86
- export async function localCheck ({ root, product, scenarios = null }) {
88
+ export async function localCheck ({ root, product, scenarios = null, details = null }) {
87
89
  const { dir, pages } = await collectDocs(root, product)
88
- const broken = []; const unknown = []
90
+ const broken = []; const unknown = []; const missingDetails = []
89
91
  for (const p of pages) {
90
92
  for (const r of p.refs) {
91
93
  if (!r.parsed) { broken.push({ file: p.file, ref: r.ref }); continue }
@@ -94,28 +96,29 @@ export async function localCheck ({ root, product, scenarios = null }) {
94
96
  if (!scenarios) continue
95
97
  if (!scenarios.has(sc)) unknown.push({ file: p.file, ref: r.ref, why: `No scenario ${sc} in the scenario files.` })
96
98
  else if (!scenarios.get(sc).has(r.parsed.step)) unknown.push({ file: p.file, ref: r.ref, why: `Scenario ${sc} has no step ${r.parsed.step}.` })
99
+ else if (r.parsed.detail && details && !details.get(`${sc}/${r.parsed.step}`)?.has(r.parsed.detail)) missingDetails.push({ file: p.file, ref: r.ref, why: `Step ${sc}/${r.parsed.step} declares no detail ${r.parsed.detail}; add it under details: and walk the step again.` })
97
100
  }
98
101
  }
99
102
  const languages = docsLanguages(product, pages)
100
103
  const translations = translationReport(pages.map((p) => ({ path: p.path, lang: p.lang, hash: p.hash, front: p.front, updated: p.updated })), languages)
101
104
  let changelog = []
102
105
  try { changelog = itemsWithoutPictures(await readFile(changelogFile(root, product), 'utf8')) } catch {}
103
- return { dir, pages, languages, broken, unknown, translations, changelog_without_pictures: changelog }
106
+ return { dir, pages, languages, broken, unknown, missing_details: missingDetails, translations, changelog_without_pictures: changelog }
104
107
  }
105
108
 
106
109
  /** The check with the service's half (stale sections, references without an accepted picture), when a client is given. */
107
- export async function docsCheck ({ root, config, product, client = null, scenarios = null, version = 'latest' }) {
108
- const local = await localCheck({ root, product, scenarios })
110
+ export async function docsCheck ({ root, config, product, client = null, scenarios = null, details = null, version = 'latest' }) {
111
+ const local = await localCheck({ root, product, scenarios, details })
109
112
  let service = null; let serviceError = null
110
113
  if (client && config.org) {
111
114
  try { service = await client.request('GET', `/orgs/${config.org}/docs/check?${new URLSearchParams({ product: product.slug, version })}`) } catch (e) { serviceError = String(e.message || e).split('\n')[0].slice(0, 200) }
112
115
  }
113
116
  const errors = local.broken.length + local.unknown.length + (service ? service.stale.length + service.unresolved.length : 0)
114
- const warnings = local.translations.missing.length + local.translations.outdated.length + local.changelog_without_pictures.length + (service?.no_recording?.length || 0)
117
+ const warnings = local.translations.missing.length + local.translations.outdated.length + local.changelog_without_pictures.length + local.missing_details.length + (service?.no_recording?.length || 0) + (service?.no_detail?.length || 0)
115
118
  return {
116
119
  product: product.slug, version, dir: relative(root, local.dir) || '.', pages: local.pages.length, languages: local.languages,
117
- broken: local.broken, unknown: local.unknown, translations: local.translations, changelog_without_pictures: local.changelog_without_pictures,
118
- service: service ? { published: service.published, published_at: service.published_at || null, stale: service.stale, unresolved: service.unresolved, no_recording: service.no_recording || [], stale_checked: service.stale_checked !== false, url: service.url } : null,
120
+ broken: local.broken, unknown: local.unknown, missing_details: local.missing_details, translations: local.translations, changelog_without_pictures: local.changelog_without_pictures,
121
+ service: service ? { published: service.published, published_at: service.published_at || null, stale: service.stale, unresolved: service.unresolved, no_recording: service.no_recording || [], no_detail: service.no_detail || [], stale_checked: service.stale_checked !== false, url: service.url } : null,
119
122
  service_error: serviceError, errors, warnings, ok: errors === 0
120
123
  }
121
124
  }
@@ -130,9 +133,11 @@ export function formatCheck (r) {
130
133
  list('✗ stale sections (the screen changed since the text was published)', r.service.stale, (x) => `${x.path || 'index'}${x.lang ? ` (${x.lang})` : ''} § ${x.title || 'intro'}: the screen changed${x.refs[0]?.changed_in ? ` in ${x.refs[0].changed_in}` : ''} — check the text (${x.refs.map((y) => y.ref).join(', ')})`)
131
134
  list('✗ references without an accepted picture', r.service.unresolved, (x) => `${x.path || 'index'}${x.lang ? ` (${x.lang})` : ''}: ${x.ref}`)
132
135
  list('! media=video, but the accepted run has no recording of the step (readers see the picture; walk it with --record)', r.service.no_recording || [], (x) => `${x.release ? `changelog ${x.release}` : `${x.path || 'index'}${x.lang ? ` (${x.lang})` : ''}`}: ${x.ref}${x.run ? ` (run ${x.run})` : ''}`)
136
+ list('! #detail that no accepted run has yet (readers see the whole screen; walk the step again)', r.service.no_detail || [], (x) => `${x.path || 'index'}${x.lang ? ` (${x.lang})` : ''}: ${x.ref}${x.run ? ` (run ${x.run})` : ''}`)
133
137
  if (!r.service.published) lines.push(' · nothing published yet: kaloko docs publish')
134
138
  else if (!r.service.stale_checked) lines.push(' · stale sections are checked on the Business plan')
135
139
  } else lines.push(` · ${r.service_error ? `service: ${r.service_error}` : 'offline: stale sections and pictures need a token'}`)
140
+ list('! details the scenario does not declare', r.missing_details || [], (x) => `${x.file}: ${x.ref} — ${x.why}`)
136
141
  list('! missing translations', r.translations.missing, (x) => `${x.path || 'index'} → ${x.lang}`)
137
142
  list('! outdated translations (the original changed later)', r.translations.outdated, (x) => `${x.path || 'index'} (${x.lang})${x.why === 'source_hash' ? ': source_hash differs' : ''}`)
138
143
  list('! translations without an original', r.translations.orphans, (x) => `${x.path || 'index'} (${x.lang})`)
@@ -1,4 +1,4 @@
1
- // Evidence for regulated work (qawalk.evidence.v1, schemas/evidence.v1.json; help/explanation/gxp.md): what a
1
+ // Quality evidence (qawalk.evidence.v1, schemas/evidence.v1.json; help/explanation/quality-evidence.md): what a
2
2
  // downstream generator needs from an accepted run, without scraping the canvas or the PDF. Shared by the service
3
3
  // (worker/src/evidence.js) and the CLI (kaloko evidence, kaloko signoff --package / --verify): WebCrypto only, no node:
4
4
  // imports, so it runs in both.
package/src/lib/git.mjs CHANGED
@@ -47,7 +47,7 @@ export async function projectAndIssue ({ cwd, config, scenario, branch, title =
47
47
  const project = scenario.project
48
48
  ? { slug: scenario.project, repo: repo?.slug || null, source: 'scenario' }
49
49
  : config.project
50
- ? { slug: config.project, repo: repo?.slug || null, source: 'config' }
50
+ ? { slug: config.project, ...(config.project_name ? { name: String(config.project_name) } : {}), repo: repo?.slug || null, source: 'config' }
51
51
  : repo ? { slug: repo.slug.split('/').pop(), repo: repo.slug, source: 'git' } : { slug: cwd.split('/').pop(), repo: null, source: 'directory' }
52
52
  let issue = null
53
53
  const given = scenario.issue