atris 3.74.9 → 3.74.11

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.
package/lib/gm-card.js CHANGED
@@ -97,7 +97,7 @@ function idea({ id, title, body, line = null, unstarted = null, concern = null }
97
97
  line: line || unstarted || null,
98
98
  options: [
99
99
  answer(id, { id: 'do', label: 'do it', action: 'do', style: 'primary', confirm: 'tap again to start' }),
100
- answer(id, { id: 'sharper', label: 'sharper idea', action: 'sharper' }),
100
+ answer(id, { id: 'sharper', label: 'sharpen it', action: 'sharper' }),
101
101
  answer(id, { id: 'skip', label: 'not now', action: 'skip' }),
102
102
  ],
103
103
  input: sayInput(),
@@ -175,6 +175,11 @@ function missed({ id }) {
175
175
  };
176
176
  }
177
177
 
178
+ // the way back from a reply that asks its own question names the card it returns to
179
+ const IDEA_KINDS = new Set(['idea', 'started']);
180
+ const ASK_KINDS = new Set(['question', 'yes', 'report', 'choice']);
181
+ const backToBase = base => (IDEA_KINDS.has(base.kind) ? 'back to the idea' : ASK_KINDS.has(base.kind) ? 'back to the question' : 'back');
182
+
178
183
  // A reply to words said on a card. When it asks its own question (it ends
179
184
  // in a question mark) only its answers show, with the box and a quiet way
180
185
  // back to the first question. When it only explains, the first card's own
@@ -200,7 +205,7 @@ function reply({ id, base, text, thread = [], suggestions = [], confirm = null }
200
205
  ...card, asks: id,
201
206
  options: [
202
207
  ...suggestions.map((s, i) => answer(id, { id: `r${i + 1}`, label: s, action: 'say', style: 'choice', binds: { text: s } })),
203
- nav({ id: 'back', label: base.kind === 'deck' ? 'back to the job' : base.kind === 'member' ? `back to ${base.title}` : 'back to the first question', action: 'back' }),
208
+ nav({ id: 'back', label: base.kind === 'deck' ? 'back to the job' : base.kind === 'member' ? `back to ${base.title}` : backToBase(base), action: 'back' }),
204
209
  ],
205
210
  input: base.input || sayInput(),
206
211
  };
@@ -235,10 +240,10 @@ function team({ id, rows, line = null, deck = false }) {
235
240
  options.push(nav({ id: `talk:${r.member}`, label: r.member, action: 'member', binds: { member: r.member }, cell: { row: i, column: 'member' } }));
236
241
  if (r.engine !== 'person') options.push(nav({ id: `swap:${r.member}`, label: r.engine, action: 'swap', binds: { member: r.member }, cell: { row: i, column: 'engine' } }));
237
242
  });
238
- if (deck) options.push(nav({ id: 'deck', label: 'the jobs, one at a time', action: 'deck' }));
243
+ if (deck) options.push(nav({ id: 'deck', label: 'go through the jobs', action: 'deck' }));
239
244
  options.push(nav({ id: 'back', label: 'close', action: 'back' }));
240
245
  return {
241
- id, kind: 'team', asks: id, title: 'the team', body: rows.length ? 'tap a name to talk to them, or what they run on to change it.' : 'nobody is on the team yet.', line,
246
+ id, kind: 'team', asks: id, title: 'the team', body: rows.length ? 'tap a name to talk to them, or their engine to change it.' : 'nobody is on the team yet.', line,
242
247
  table: rows.length ? { columns: TEAM_COLUMNS, rows: rows.map(r => [r.member, r.role, r.ovr, r.engine, r.sentence]), status: rows.map(r => r.status) } : null,
243
248
  options, input: null,
244
249
  };
@@ -253,37 +258,39 @@ function member({ id, name, sentence: said, job = null, line = null }) {
253
258
  };
254
259
  }
255
260
 
256
- // what a job in the deck is doing, when there is no yes on it to answer
261
+ // what a job in the deck is doing, when there is no yes on it to answer;
262
+ // how long it has been is the card's own time line, so it is said once
257
263
  function deckLine(card) {
258
- const when = card.time ? `, ${card.time}` : '';
259
- if (card.label === 'working') return `${card.member} is working on it${when}. nothing needs you here.`;
264
+ if (card.label === 'working') return `${card.member} is working on it. nothing needs you here.`;
260
265
  if (card.label === 'you answered') return `you answered this, so it is back with ${card.member}.`;
261
- return `${card.member} has not started it yet${when}.`;
266
+ return `${card.member} has not started it yet.`;
262
267
  }
263
268
 
264
269
  // one running job: its place in the deck, the yes it waits on, its table
265
270
  // row, and a box to talk to its member
266
271
  function deck({ id, at, total, card, columns, job = null }) {
267
272
  const options = [];
273
+ // previous and next first: the page draws them at the top, beside the card's place in the deck
274
+ if (total > 1) options.push(nav({ id: 'prev', label: 'previous', action: 'flip', binds: { step: -1 } }), nav({ id: 'next', label: 'next', action: 'flip', binds: { step: 1 } }));
268
275
  if (card.yes) {
269
276
  const binds = { job: card.mission_id, key: card.yes.key, hash: card.yes.hash || null };
270
277
  options.push(answer(id, { id: 'yes', label: 'yes', action: 'yes', style: 'primary', confirm: 'tap again to say yes', binds }));
271
278
  options.push(answer(id, { id: 'no', label: 'no', action: 'no', binds }));
272
279
  options.push(answer(id, { id: 'later', label: 'not now', action: 'later', binds }));
273
280
  }
274
- if (total > 1) options.push(nav({ id: 'prev', label: 'previous', action: 'flip', binds: { step: -1 } }));
275
- options.push(nav({ id: 'back', label: 'the whole team', action: 'back' }));
276
- if (total > 1) options.push(nav({ id: 'next', label: 'next', action: 'flip', binds: { step: 1 } }));
281
+ options.push(nav({ id: 'back', label: 'back to the team', action: 'back' }));
277
282
  return {
278
283
  id, kind: 'deck', asks: id,
279
284
  title: card.yes ? card.yes.what : card.rows[0].work,
280
285
  body: card.yes ? card.yes.why : deckLine(card),
281
286
  line: card.said || null,
282
- count: `${at + 1} of ${total}`,
287
+ // a place with its unit, so it never reads as "1 of 4 needs you"
288
+ count: `job ${at + 1} of ${total}`,
283
289
  label: card.label,
284
290
  time: card.time || null,
285
291
  job,
286
- table: { columns, rows: card.rows.map(r => columns.map(k => r[k] || '')) },
292
+ // each row's status too, so its cell reads in the same color as on the team page
293
+ table: { columns, rows: card.rows.map(r => columns.map(k => r[k] || '')), status: card.rows.map(r => r.status || '') },
287
294
  options,
288
295
  input: { ...SAY, label: `talk to ${card.member}`, placeholder: `talk to ${card.member}`, action: 'talk' },
289
296
  member: card.member,
@@ -294,7 +301,7 @@ function deck({ id, at, total, card, columns, job = null }) {
294
301
 
295
302
  // The swap card renders the swap list as it comes: Atris models, then the
296
303
  // owner's picks, then "more models", which turns this card's options into
297
- // the full list. A list with no picks opens short too: the model in use and
304
+ // the full list, with the models already shown first, where they were. A list with no picks opens short too: the model in use and
298
305
  // the next two, then more models, when that hides at least two. Without the
299
306
  // list, every model by tool.
300
307
  const SHORT_LIST = 3;
@@ -308,7 +315,13 @@ function swapChoices(choices, all = false) {
308
315
  more = { more: true, label: 'more models', options: list };
309
316
  list = [...list.filter(o => o.current), ...list.filter(o => !o.current)].slice(0, SHORT_LIST);
310
317
  }
311
- if (all && more) return { list: more.options || [], more: null };
318
+ if (all && more) {
319
+ // the models already on the card keep their places, and the rest come in under them
320
+ const same = (a, b) => a.engine === b.engine && a.model === b.model;
321
+ const full = more.options || [];
322
+ const kept = list.map(o => full.find(f => same(f, o))).filter(Boolean);
323
+ return { list: [...kept, ...full.filter(f => !kept.includes(f))], more: null };
324
+ }
312
325
  return { list, more };
313
326
  }
314
327
 
@@ -317,7 +330,9 @@ function swapChoices(choices, all = false) {
317
330
  function swap({ id, name, choices, scope = 'member', all = false, line = null }) {
318
331
  const { list, more } = swapChoices(choices, all);
319
332
  const options = list.map((m) => {
320
- const detail = m.current ? 'now' : [m.tool, m.record].filter(Boolean).join(', ') || null;
333
+ // the tool under the model, unless it is the same word (codex on codex)
334
+ const tool = m.tool && String(m.tool).trim().toLowerCase() !== String(m.label || '').trim().toLowerCase() ? m.tool : null;
335
+ const detail = m.current ? 'now' : [tool, m.record].filter(Boolean).join(', ') || null;
321
336
  return answer(id, { id: `m-${hash8([m.engine, m.model, m.label])}`, label: m.label, detail, action: m.current ? 'none' : 'swap_to', style: 'choice', chosen: Boolean(m.current), binds: { engine: m.engine, model: m.model, scope } });
322
337
  });
323
338
  if (more) options.push(nav({ id: 'more', label: more.label || 'more models', action: 'more' }));
@@ -381,10 +396,22 @@ function sequenceProblems(cards) {
381
396
  // Words the owner reads say what a thing is, not where a number comes from:
382
397
  // "300 more npm installs" reads "300 more installs".
383
398
  const ownerText = t => (typeof t === 'string' ? t.replace(/\bnpm (installs?|downloads?)\b/gi, '$1') : t);
399
+ // Every card speaks lowercase, so an idea or a job written "Write the page"
400
+ // reads "write the page" like the cards around it. Only a plain capitalized
401
+ // first word moves: a capital, then lowercase letters, and no other capital
402
+ // before the next space. OpenAI, GitHub, YouTube, McKinsey, API, and CRM stay
403
+ // as written, and iPhone starts lowercase already.
404
+ const lowerFirst = t => (typeof t === 'string' && /^[A-Z][a-z]+[^A-Z\s]*(?=\s|$)/.test(t) ? t[0].toLowerCase() + t.slice(1) : t);
405
+ // Only the words that are an idea or a job move. A talk card, a swap card,
406
+ // and the team page are titled with a name, and member and model names in
407
+ // cells and buttons are never changed: only the deck's work cell and the
408
+ // ideas on a choice card are sentences of that kind.
409
+ const NAMED = new Set(['member', 'swap', 'team']);
384
410
  function ownerWords(card) {
385
- const out = { ...card, title: ownerText(card.title), body: ownerText(card.body), line: ownerText(card.line) };
386
- out.options = (card.options || []).map(o => ({ ...o, label: ownerText(o.label), ...(o.detail ? { detail: ownerText(o.detail) } : {}) }));
387
- if (card.table) out.table = { ...card.table, rows: card.table.rows.map(row => row.map(ownerText)) };
411
+ const title = ownerText(card.title);
412
+ const out = { ...card, title: NAMED.has(card.kind) ? title : lowerFirst(title), body: ownerText(card.body), line: ownerText(card.line) };
413
+ out.options = (card.options || []).map(o => ({ ...o, label: card.kind === 'choice' && o.style === 'option' ? lowerFirst(ownerText(o.label)) : ownerText(o.label), ...(o.detail ? { detail: ownerText(o.detail) } : {}) }));
414
+ if (card.table) out.table = { ...card.table, rows: card.table.rows.map(row => row.map((cell, j) => (card.table.columns[j] === 'work' ? lowerFirst(ownerText(cell)) : ownerText(cell)))) };
388
415
  return out;
389
416
  }
390
417
 
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ // The phone page's icon: the lava orb's amber on the page's dark, small. An
4
+ // svg for the browser tab, and a 180px png for the home screen (iPhones take
5
+ // only a png there), drawn here with node's own zlib, no packages. Both go
6
+ // into the page inline as data, so no request for them ever needs the link's key.
7
+
8
+ const zlib = require('node:zlib');
9
+
10
+ const BG = [0x14, 0x11, 0x10];
11
+ // the lava's colors, lit from the upper left: cream where lit, amber, then deep
12
+ const LIT = [255, 232, 170];
13
+ const AMBER = [251, 150, 44];
14
+ const DEEP = [150, 52, 14];
15
+
16
+ const SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
17
+ + '<defs><radialGradient id="o" cx="40%" cy="38%" r="66%">'
18
+ + '<stop offset="0" stop-color="#ffe8aa"/><stop offset=".38" stop-color="#fb962c"/><stop offset="1" stop-color="#96340e"/>'
19
+ + '</radialGradient></defs>'
20
+ + '<rect width="64" height="64" rx="14" fill="#141110"/><circle cx="32" cy="32" r="20" fill="url(#o)"/></svg>';
21
+
22
+ const iconSvg = () => `data:image/svg+xml,${encodeURIComponent(SVG)}`;
23
+
24
+ const mix = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t);
25
+ const clamp01 = v => Math.max(0, Math.min(1, v));
26
+
27
+ // one pixel of the orb at (x, y) on a size-wide square, as [r, g, b]
28
+ function orbPixel(x, y, size) {
29
+ const c = size / 2, r = size * 0.33;
30
+ const d = Math.hypot(x - c, y - c);
31
+ // the lit point sits up and to the left; the color runs from it outward
32
+ const lit = Math.hypot(x - size * 0.43, y - size * 0.41) / (r * 1.32);
33
+ const face = lit < 0.38 ? mix(LIT, AMBER, lit / 0.38) : mix(AMBER, DEEP, clamp01((lit - 0.38) / 0.62));
34
+ // a soft glow just past the edge, then the page
35
+ const glow = mix(BG, AMBER, 0.16 * Math.pow(clamp01(1 - (d - r) / (r * 0.5)), 2));
36
+ const edge = clamp01(r + 0.5 - d);
37
+ return mix(glow, face, edge).map(Math.round);
38
+ }
39
+
40
+ function chunk(type, data) {
41
+ const len = Buffer.alloc(4);
42
+ len.writeUInt32BE(data.length);
43
+ const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
44
+ const crc = Buffer.alloc(4);
45
+ crc.writeUInt32BE(zlib.crc32(body));
46
+ return Buffer.concat([len, body, crc]);
47
+ }
48
+
49
+ // a size-wide square png of the orb, rgb with no alpha (the home screen fills
50
+ // see-through corners with black), each row filtered by its left neighbour so
51
+ // the smooth color compresses small
52
+ function orbPng(size = 180) {
53
+ const raw = Buffer.alloc(size * (1 + size * 3));
54
+ for (let y = 0; y < size; y++) {
55
+ const row = y * (1 + size * 3);
56
+ raw[row] = 1;
57
+ let prev = [0, 0, 0];
58
+ for (let x = 0; x < size; x++) {
59
+ const px = orbPixel(x + 0.5, y + 0.5, size);
60
+ for (let k = 0; k < 3; k++) raw[row + 1 + x * 3 + k] = (px[k] - prev[k]) & 0xff;
61
+ prev = px;
62
+ }
63
+ }
64
+ const head = Buffer.alloc(13);
65
+ head.writeUInt32BE(size, 0);
66
+ head.writeUInt32BE(size, 4);
67
+ head[8] = 8;
68
+ head[9] = 2;
69
+ return Buffer.concat([
70
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
71
+ chunk('IHDR', head),
72
+ chunk('IDAT', zlib.deflateSync(raw, { level: 9 })),
73
+ chunk('IEND', Buffer.alloc(0)),
74
+ ]);
75
+ }
76
+
77
+ let touch = null;
78
+ const touchIconPng = () => (touch || (touch = `data:image/png;base64,${orbPng(180).toString('base64')}`));
79
+
80
+ module.exports = { iconSvg, touchIconPng };