create-pmos 0.521.0 → 0.522.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 (33) hide show
  1. package/bin/create-pmos.js +81 -42
  2. package/package.json +26 -16
  3. package/payload/KIT-VERSION +1 -1
  4. package/payload/planning/evals/eval-calibration.md +1 -1
  5. package/payload/planning/okrs/health-budgets.md +1 -1
  6. package/payload/state/discoveries.md +1 -1
  7. package/workspace/bin/pmos.js +2 -1
  8. package/workspace/board-publish-ui-test.js +1 -1
  9. package/workspace/changelog.json +25 -0
  10. package/workspace/guide/home.md +2 -2
  11. package/workspace/guide/published-board.md +15 -3
  12. package/workspace/lib/board-pack.js +44 -1
  13. package/workspace/lib/board-publish-choices.js +22 -6
  14. package/workspace/lib/board-publish-parts.js +95 -0
  15. package/workspace/lib/boards-host.js +3 -3
  16. package/workspace/lib/boards-received.js +105 -0
  17. package/workspace/lib/kit-update.js +51 -21
  18. package/workspace/lib/routes-board-publish.js +42 -12
  19. package/workspace/package-lock.json +2 -2
  20. package/workspace/package.json +1 -1
  21. package/workspace/public/pm/bd-publish.css +3 -0
  22. package/workspace/public/pm/bd-publish.js +39 -9
  23. package/workspace/public/pm/bd-shared.css +5 -0
  24. package/workspace/public/pm/bd-shared.js +72 -10
  25. package/workspace/public/pm/chat-term.js +22 -0
  26. package/workspace/public/pm/cozy-board.css +10 -0
  27. package/workspace/public/pm/cozy-home.css +3 -0
  28. package/workspace/public/pm/home-chat.js +3 -0
  29. package/workspace/public/pm/premium-board.js +8 -2
  30. package/workspace/public/pm/premium-shell.js +2 -1
  31. package/workspace/public/pm/readonly-boot.js +4 -1
  32. package/workspace/public/pm/readonly.js +40 -5
  33. package/workspace/public/pm/term-view.js +39 -8
@@ -51,8 +51,8 @@ function hostOrigin(env = process.env) {
51
51
  }
52
52
 
53
53
  function readState(dir) {
54
- try { const s = JSON.parse(fs.readFileSync(fileOf(dir), 'utf8')); return { key: typeof s.key === 'string' ? s.key : null, host: typeof s.host === 'string' ? s.host : '', teams: Array.isArray(s.teams) ? s.teams : [], links: Array.isArray(s.links) ? s.links : [] }; }
55
- catch { return { key: null, host: '', teams: [], links: [] }; }
54
+ try { const s = JSON.parse(fs.readFileSync(fileOf(dir), 'utf8')); return { key: typeof s.key === 'string' ? s.key : null, host: typeof s.host === 'string' ? s.host : '', teams: Array.isArray(s.teams) ? s.teams : [], links: Array.isArray(s.links) ? s.links : [], received: Array.isArray(s.received) ? s.received : [] }; }
55
+ catch { return { key: null, host: '', teams: [], links: [], received: [] }; }
56
56
  }
57
57
  /** The address the PM saved on the Publish popover ('' when none). The environment variable PMOS_BOARDS_HOST wins over it (see addressOf). */
58
58
  function savedHost(dir) {
@@ -124,7 +124,7 @@ async function publish(dir, o, opts = {}) {
124
124
  const now = new Date(opts.now || Date.now()).toISOString();
125
125
  const cur = readState(dir);
126
126
  cur.key = st.key;
127
- cur.links = cur.links.filter((l) => l.id !== b.id).concat([{ id: b.id, url: b.url, team, name: String(o.name || ''), kind: o.kind, selection: o.selection || [], ws: o.ws || '', board: o.board || '', by: String(o.by || ''), made: now, ends: b.ends || null, views: 0 }]);
127
+ cur.links = cur.links.filter((l) => l.id !== b.id).concat([{ id: b.id, url: b.url, team, name: String(o.name || ''), kind: o.kind, selection: o.selection || [], parts: Array.isArray(o.parts) ? o.parts : undefined, ws: o.ws || '', board: o.board || '', by: String(o.by || ''), made: now, ends: b.ends || null, views: 0 }]);
128
128
  writeState(dir, cur);
129
129
  return { ok: true, id: b.id, url: b.url, ends: b.ends || null };
130
130
  }
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+ /* boards-received.js — boards other people shared with you, kept in your PMOS (batch 27 item 3, ShX X2 and X3; initiative board-share-several).
3
+ *
4
+ * A RECEIVED board is { id, host, url, password, name, by, kind, ends, added }, kept in <runtime state>/board-publish/state.json under `received`
5
+ * (lib/boards-host.js writeState: mode 0600). The password is a secret on this computer: never returned to the page, never logged (D08), like a
6
+ * team password. Every function takes the state folder.
7
+ *
8
+ * THE CONTRACT with the host (board-host/src/core.js):
9
+ * GET <host>/api/links/<id>/about X-Link-Password -> { ok, id, name, kind, by, made, ends }
10
+ * POST <host>/api/links/<id>/visit X-Link-Password -> { ok, url } one-time, 5 minutes
11
+ * 403 is a wrong password, 429 a lockout (the sentence names the minutes), 404 a link that ended or was turned off.
12
+ *
13
+ * parseLink(text) -> { host, id, url } | { error } "<host>/b/<id>" (a copy of the address bar works too)
14
+ * add(dir, { link, password }, { fetch }) -> { ok, board } | { ok: false, kind, error } checked with the host before anything is kept
15
+ * forget(dir, id) -> { ok }
16
+ * readAll(dir, { fetch, now }) -> [{ id, host, url, name, by, kind, ends, state }] state: open | ended | off | password | locked | unreachable
17
+ * visit(dir, id, { fetch }) -> { ok, url } | { ok: false, error }
18
+ * Ended and turned-off links stay as one quiet row until the PM forgets them: the host deletes both, so the end date kept here tells them apart. */
19
+ const Host = require('./boards-host.js');
20
+
21
+ const TIMEOUT_MS = 15000;
22
+ const MAX = 50;
23
+ const LINK_ID = /^[A-Za-z0-9_-]{8,64}$/;
24
+
25
+ function parseLink(text) {
26
+ const bad = { error: 'This is not a shared board link. It looks like https://boards.pm-os.app/b/…' };
27
+ let u; try { u = new URL(String(text || '').trim()); } catch { return bad; }
28
+ if (u.username || u.password) return bad;
29
+ const host = Host.hostOrigin({ PMOS_BOARDS_HOST: u.origin });
30
+ const m = /^\/b\/([A-Za-z0-9_-]+)\/?$/.exec(u.pathname);
31
+ if (!host || !m || !LINK_ID.test(m[1])) return bad;
32
+ return { host, id: m[1], url: host + '/b/' + m[1] };
33
+ }
34
+
35
+ async function call(host, method, p, password, fetchFn) {
36
+ const f = fetchFn || (typeof fetch === 'function' ? fetch : null);
37
+ if (!f) return { ok: false, kind: 'unreachable', error: 'This computer cannot reach the host (no network client).' };
38
+ const ctl = typeof AbortController === 'function' ? new AbortController() : null; const timer = ctl ? setTimeout(() => ctl.abort(), TIMEOUT_MS) : null;
39
+ try {
40
+ const headers = { Accept: 'application/json' }; if (password) headers['X-Link-Password'] = password;
41
+ const res = await f(host + p, { method, redirect: 'error', signal: ctl ? ctl.signal : undefined, headers });
42
+ let j = null; try { j = await res.json(); } catch { /* not JSON */ }
43
+ if (res.ok && j && j.ok !== false) return { ok: true, body: j };
44
+ const say = (j && typeof j.error === 'string' && j.error.slice(0, 240)) || '';
45
+ if (res.status === 403) return { ok: false, kind: 'password', error: 'That password does not work. Ask the person who shared it.' };
46
+ if (res.status === 429) return { ok: false, kind: 'locked', error: say || 'Too many wrong tries. Try again in a few minutes.' };
47
+ if (res.status === 404) return { ok: false, kind: 'gone', error: 'This link has ended or was turned off.' };
48
+ return { ok: false, kind: 'other', error: say || 'The host refused the request (' + res.status + ').' };
49
+ } catch (e) {
50
+ return { ok: false, kind: 'unreachable', error: e && e.name === 'AbortError' ? 'The host did not answer in time. Try again.' : 'The host could not be reached. Check the connection.' };
51
+ } finally { if (timer) clearTimeout(timer); }
52
+ }
53
+ const path = (id, what) => '/api/links/' + encodeURIComponent(id) + '/' + what;
54
+ const listOf = (st) => (Array.isArray(st.received) ? st.received : []);
55
+ const str = (v, n) => String(v == null ? '' : v).slice(0, n);
56
+ const about = (b) => ({ name: str(b.name, 120), by: str(b.by, 60), kind: str(b.kind, 20), ends: str(b.ends, 40) });
57
+
58
+ async function add(dir, o, opts = {}) {
59
+ const L = parseLink(o.link); if (L.error) return { ok: false, kind: 'link', error: L.error };
60
+ const password = o.password == null ? '' : String(o.password);
61
+ if (password.length > 100) return { ok: false, kind: 'password', error: 'A password is at most 100 characters.' };
62
+ const st = Host.readState(dir);
63
+ if (!listOf(st).some((r) => r.id === L.id) && listOf(st).length >= MAX) return { ok: false, kind: 'other', error: 'This computer keeps ' + MAX + ' shared boards. Forget one first.' };
64
+ const kept = listOf(st).find((r) => r.id === L.id && r.host === L.host);
65
+ const r = await call(L.host, 'GET', path(L.id, 'about'), password || (kept && kept.password) || '', opts.fetch);
66
+ if (!r.ok) return { ok: false, kind: r.kind, error: r.kind === 'password' && !password ? 'This board needs its password.' : r.error };
67
+ const cur = Host.readState(dir);
68
+ const row = Object.assign({ id: L.id, host: L.host, url: L.url, password: password || (kept && kept.password) || '', added: new Date(opts.now || Date.now()).toISOString() }, about(r.body));
69
+ cur.received = listOf(cur).filter((x) => x.id !== L.id).concat([row]);
70
+ Host.writeState(dir, cur);
71
+ return { ok: true, board: { id: row.id, name: row.name, by: row.by, ends: row.ends } };
72
+ }
73
+
74
+ function forget(dir, id) {
75
+ const st = Host.readState(dir); const had = listOf(st).some((r) => r.id === id);
76
+ st.received = listOf(st).filter((r) => r.id !== id); Host.writeState(dir, st); return { ok: had };
77
+ }
78
+
79
+ async function readAll(dir, opts = {}) {
80
+ const now = opts.now || Date.now();
81
+ const list = listOf(Host.readState(dir));
82
+ const rows = await Promise.all(list.map(async (x) => {
83
+ const base = { id: x.id, host: new URL(x.host).host, url: x.url, name: x.name, by: x.by, kind: x.kind, ends: x.ends };
84
+ const r = await call(x.host, 'GET', path(x.id, 'about'), x.password, opts.fetch);
85
+ if (r.ok) return Object.assign(base, about(r.body), { state: 'open' });
86
+ if (r.kind === 'gone') return Object.assign(base, { state: x.ends && Date.parse(x.ends) <= now ? 'ended' : 'off' });
87
+ return Object.assign(base, { state: r.kind === 'password' || r.kind === 'locked' ? r.kind : 'unreachable', error: r.error });
88
+ }));
89
+ // what the host said is kept (the name and end date can change on a republish), so an ended row still says when it ended
90
+ const cur = Host.readState(dir); let changed = false;
91
+ for (const row of rows) { const k = listOf(cur).find((x) => x.id === row.id); if (k && row.state === 'open' && (k.name !== row.name || k.ends !== row.ends || k.by !== row.by)) { Object.assign(k, { name: row.name, ends: row.ends, by: row.by }); changed = true; } }
92
+ if (changed) Host.writeState(dir, cur);
93
+ return rows;
94
+ }
95
+
96
+ async function visit(dir, id, opts = {}) {
97
+ const x = listOf(Host.readState(dir)).find((r) => r.id === id);
98
+ if (!x) return { ok: false, error: 'This board is not kept on this computer.' };
99
+ const r = await call(x.host, 'POST', path(x.id, 'visit'), x.password, opts.fetch);
100
+ if (!r.ok) return { ok: false, kind: r.kind, error: r.error };
101
+ const url = r.body.url; if (typeof url !== 'string' || !url.startsWith(x.host + '/b/' + x.id)) return { ok: false, error: 'The host sent an address this workspace does not accept.' };
102
+ return { ok: true, url };
103
+ }
104
+
105
+ module.exports = { MAX, parseLink, add, forget, readAll, visit };
@@ -15,8 +15,8 @@
15
15
  *
16
16
  * The page does what the terminal does, and nothing more. Each action runs the command the README tells the PM to type:
17
17
  * check npm view create-pmos version
18
- * kit npx -y create-pmos@<version> --update <repo> (the kernel only; state/ and planning/ untouched)
19
- * app npx -y create-pmos@<version> workspace --reinstall <repo> (what `pmos upgrade` runs)
18
+ * kit npx -y --prefer-online create-pmos@<version> --update <repo> (the kernel only; state/ and planning/ untouched)
19
+ * app npx -y --prefer-online create-pmos@<version> workspace --reinstall <repo> (what `pmos upgrade` runs)
20
20
  * LOCAL SOURCE. PMOS_UPDATE_SOURCE names a built create-pmos package on this computer: its folder (with package.json, bin/
21
21
  * and payload/ or workspace/) or its .tgz from `npm pack`. The check then reads that package's version instead of asking
22
22
  * the registry, and the update runs `npx -y --package=<that path> -- create-pmos ...` in place of `npx -y create-pmos@<version> ...`. Nothing
@@ -116,11 +116,12 @@ function source(env = process.env) {
116
116
 
117
117
  /**
118
118
  * The npx arguments that run create-pmos: the registry package at a version, or the local path. A bare path is not
119
- * enough: npx would try to run it as a command, so a local package goes in with --package.
119
+ * enough: npx would try to run it as a command, so a local package goes in with --package. --prefer-online makes npm ask
120
+ * the registry about the pinned version even when it has an older answer cached, which a cache clean used to force.
120
121
  */
121
122
  const npxArgs = (src, version, rest) => (src && src.kind === 'local'
122
123
  ? ['-y', `--package=${src.path}`, '--', PKG_NAME, ...rest]
123
- : ['-y', `${PKG_NAME}@${version}`, ...rest]);
124
+ : ['-y', '--prefer-online', `${PKG_NAME}@${version}`, ...rest]);
124
125
 
125
126
  /** One changelog entry as the page may show it, or null: a version, a time, and PRs with a number, a summary, from
126
127
  * kit-changelog format 2 a title, and from format 3 a kind (k: new, imp, fix or x), which decides New, Improved or Fixed.
@@ -266,10 +267,6 @@ function whereFrom({ desktop = false, onlineHost = false, tokenKept = false } =
266
267
  return { ok: false, error: 'This page signed in with a token that changes when the workspace restarts, so it could not come back. Update the workspace on the computer it runs on, or at its Online address' };
267
268
  }
268
269
 
269
- /**
270
- * Start the app reinstall and return at once. The child is detached from this server, because create-pmos stops this
271
- * server before it reinstalls, and it starts the new one after. Its output goes to `logFile`.
272
- */
273
270
  /**
274
271
  * The environment of the app update. A server started by a login item or a desktop shortcut can have a PATH without
275
272
  * the node that runs it, and then neither npx nor the pmos launcher is found. The folder of this node is added at the
@@ -282,26 +279,59 @@ function updateEnv(env = process.env, nodePath = process.execPath) {
282
279
  return parts.includes(dir) ? { ...env } : { ...env, [key]: [...parts, dir].join(path.delimiter) };
283
280
  }
284
281
 
285
- function upgradeApp(root, { version, logFile, spawn = cp.spawn, env = updateEnv(), source: src = source() } = {}) {
282
+ /**
283
+ * On Windows the update runs through this hop, so no step of it opens a console window (batch 27 item 7).
284
+ * A DETACHED child has no console at all, and then every console program it starts (cmd, npm, node, node-gyp) gets a
285
+ * new, VISIBLE console of its own: npm starts create-pmos without windowsHide, so the server cannot hide those. The hop
286
+ * is a detached node with no console. It starts the update once with windowsHide, which gives that child a console with
287
+ * no window, and every program under it shares that hidden console. The hop waits for the update and passes on its exit
288
+ * code. argv: ['direct', cmd, ...args] runs cmd itself; ['shell', cmd, ...args] runs it through cmd.exe (npx.cmd needs
289
+ * a shell), each argument quoted, so a repository path with a space stays one argument.
290
+ */
291
+ function windowsHop(argv, spawnSync) {
292
+ const [mode, cmd, ...args] = argv;
293
+ const q = (s) => (/[\s"&|<>^()]/.test(s) ? '"' + String(s).replace(/"/g, '""') + '"' : String(s));
294
+ const r = mode === 'shell'
295
+ ? spawnSync([cmd, ...args].map(q).join(' '), { stdio: 'inherit', windowsHide: true, shell: true })
296
+ : spawnSync(cmd, args, { stdio: 'inherit', windowsHide: true });
297
+ if (r.error) console.error('The update could not start: ' + r.error.message);
298
+ return r.status == null ? 1 : r.status;
299
+ }
300
+ const HOP = `process.exit((${windowsHop.toString()})(process.argv.slice(1), require('child_process').spawnSync))`;
301
+
302
+ /**
303
+ * The command that starts the app update: { cmd, args }. On macOS and Linux it is npx. On Windows it is this node
304
+ * running the hop above, and the hop runs npm's own npx-cli.js with this node when it is beside it (the usual Node
305
+ * install and nvm-windows), else npx.cmd through a shell.
306
+ */
307
+ function appUpdateCommand(npx, { platform = process.platform, execPath = process.execPath, exists = fs.existsSync } = {}) {
308
+ if (platform !== 'win32') return { cmd: 'npx', args: npx };
309
+ const cli = path.win32.join(path.win32.dirname(String(execPath)), 'node_modules', 'npm', 'bin', 'npx-cli.js');
310
+ const run = exists(cli) ? ['direct', String(execPath), cli] : ['shell', 'npx.cmd'];
311
+ return { cmd: String(execPath), args: ['-e', HOP, ...run, ...npx] };
312
+ }
313
+
314
+ /**
315
+ * Start the app reinstall and return at once. The child is detached from this server, because create-pmos stops this
316
+ * server before it reinstalls, and it starts the new one after. Its output goes to `logFile`.
317
+ * No npm cache clean: the version is pinned and npx runs with --prefer-online, so npm asks the registry for that
318
+ * version's details again and downloads the version if it is not in the cache. A clean took the whole cache, so every
319
+ * update downloaded every dependency again and built node-pty again (batch 27 item 7).
320
+ */
321
+ function upgradeApp(root, { version, logFile, spawn = cp.spawn, env = updateEnv(), source: src = source(),
322
+ platform = process.platform, execPath = process.execPath, exists = fs.existsSync } = {}) {
286
323
  if (!validVersion(version)) return { ok: false, error: 'Check for updates first' };
287
324
  if (src.kind === 'local' && src.error) return { ok: false, error: src.error };
325
+ const npx = npxArgs(src, version, ['workspace', '--reinstall', '--no-open', String(root)]);
326
+ const c = appUpdateCommand(npx, { platform, execPath, exists });
288
327
  let fd;
289
328
  try {
290
329
  fs.mkdirSync(path.dirname(logFile), { recursive: true });
291
330
  fd = fs.openSync(logFile, 'w');
292
- fs.writeSync(fd, `${new Date().toISOString()} clearing npm cache…\n`);
293
- fs.writeSync(fd, `${new Date().toISOString()} ${NPM} cache clean --force\n`);
294
- fs.writeSync(fd, `${new Date().toISOString()} ${NPX} ${npxArgs(src, version, ['workspace', '--reinstall', '--no-open', String(root)]).join(' ')}\n`);
331
+ fs.writeSync(fd, `${new Date().toISOString()} ${platform === 'win32' ? 'npx.cmd' : 'npx'} ${npx.join(' ')}\n`);
295
332
  } catch (e) { return { ok: false, error: `Could not write the log file: ${e.message}` }; }
296
333
  try {
297
- // Clear npm cache synchronously before spawning the update, so npx downloads the fresh version
298
- cp.execSync(`${NPM} cache clean --force`, { cwd: String(root), env, stdio: ['ignore', fd, fd] });
299
- } catch (e) {
300
- try { fs.appendFileSync(logFile, `Note: cache clear had an issue, but continuing: ${e.message}\n`); } catch { /* silent */ }
301
- }
302
- try {
303
- const child = spawn(NPX, npxArgs(src, version, ['workspace', '--reinstall', '--no-open', String(root)]),
304
- { cwd: String(root), env, detached: true, stdio: ['ignore', fd, fd], windowsHide: true, shell: IS_WIN });
334
+ const child = spawn(c.cmd, c.args, { cwd: String(root), env, detached: true, stdio: ['ignore', fd, fd], windowsHide: true });
305
335
  // A child that could not start writes nothing itself: say why in the log, which the page names when it gives up.
306
336
  if (child && typeof child.on === 'function') {
307
337
  child.on('error', (e) => { try { fs.appendFileSync(logFile, `The update could not start: ${e.message}\n`); } catch { /* the page times out */ } });
@@ -315,4 +345,4 @@ function upgradeApp(root, { version, logFile, spawn = cp.spawn, env = updateEnv(
315
345
  }
316
346
  }
317
347
 
318
- module.exports = { PKG_NAME, cmpVer, readChanges, readLastCheck, writeLastCheck, notServedYet, validVersion, source, npxArgs, readKit, fromCheckout, status, compare, latest, updateKit, updateEnv, upgradeApp, whereFrom };
348
+ module.exports = { PKG_NAME, windowsHop, appUpdateCommand, cmpVer, readChanges, readLastCheck, writeLastCheck, notServedYet, validVersion, source, npxArgs, readKit, fromCheckout, status, compare, latest, updateKit, updateEnv, upgradeApp, whereFrom };
@@ -2,7 +2,8 @@
2
2
  /* routes-board-publish.js — publish a board to the central host from the workspace (initiative board-publish, slice 2; D103; PRD P1 to P5).
3
3
  *
4
4
  * GET /api/board-publish -> { ok, host, days, links } a read: whether a host is set, the choices, the open links (never the key)
5
- * POST /api/board-publish { ws, board, select, team, password, days, shared_by } -> { ok, id, url, ends }
5
+ * POST /api/board-publish { ws, board, select | parts, name?, team, password, days, shared_by } -> { ok, id, url, ends, name }
6
+ * parts (batch 27 item 1): several products and programs in one link, one tab per pick (lib/board-publish-parts.js)
6
7
  * POST /api/board-publish/republish { id } -> { ok, ends } the same selection, as the board is now; same link, same password
7
8
  * POST /api/board-publish/off { id } -> { ok } the host deletes the copy at once
8
9
  * POST /api/board-publish/password { id } -> { ok, password } a new link password from the host, shown once; the old one stops
@@ -11,6 +12,10 @@
11
12
  * POST /api/board-publish/join { invite } or { host, team, password } -> { ok, team } verified with the host first; the password stays on this computer
12
13
  * POST /api/board-publish/leave { team } -> { ok } forgets the team here
13
14
  * POST /api/board-publish/open { team, id } -> { ok, url } a one-time address for a team member, opened in a browser tab
15
+ * POST /api/board-publish/receive { link, password? } -> { ok, board } a board shared with you, kept here (batch 27 item 3; lib/boards-received.js)
16
+ * POST /api/board-publish/forget { id } -> { ok } forgets it here
17
+ * POST /api/board-publish/visit { id } -> { ok, url } its one-time address, opened in a browser tab
18
+ * GET /api/board-publish/shared also answers withYou: [{ id, host, url, name, by, kind, ends, state }] (no password, ever)
14
19
  *
15
20
  * EVERY POST IS A PAGE PRESS (pm-guard requirePagePress): only a press on the page publishes, not a request an agent session makes (PRD P2, D97 S3).
16
21
  * THE SECRET SCAN runs on the finished pack before anything leaves (P3): a hit blocks the publish and the answer names the rule, never the text (D08).
@@ -24,6 +29,8 @@ const Host = require('./boards-host.js');
24
29
  const shareStore = require('./share-store.js');
25
30
  const secretScan = require('./secret-scan.js');
26
31
  const Teams = require('./boards-teams.js');
32
+ const Parts = require('./board-publish-parts.js');
33
+ const Received = require('./boards-received.js');
27
34
 
28
35
  const PUBLIC = path.join(__dirname, '..', 'public');
29
36
  let versionMemo = null;
@@ -74,7 +81,7 @@ module.exports = {
74
81
  prefixes: ['/api/board-publish'],
75
82
  handle: async (req, res, url, ctx) => {
76
83
  const p = url.pathname;
77
- if (!/^\/api\/board-publish(\/(republish|off|password|host|shared|join|leave|open))?$/.test(p)) return false;
84
+ if (!/^\/api\/board-publish(\/(republish|off|password|host|shared|join|leave|open|receive|forget|visit))?$/.test(p)) return false;
78
85
  const send = (obj, code) => { ctx.json(res, obj, code); return true; };
79
86
  const dir = stateDir(ctx);
80
87
  if (!dir) return send({ ok: false, error: 'No state folder.' }, 500);
@@ -88,8 +95,8 @@ module.exports = {
88
95
  }
89
96
  // the Shared view's read (slice 4): your own links and every joined team's list, read now from each host. Nothing is kept from the read.
90
97
  if (req.method === 'GET' && p === '/api/board-publish/shared') {
91
- const teams = await Teams.readAll(dir);
92
- return send({ ok: true, readAt: new Date().toISOString(), yours: Host.links(dir).map((l) => ({ id: l.id, url: l.url, team: l.team, name: l.name, kind: l.kind, by: l.by, made: l.made, ends: l.ends, views: l.views })), teams });
98
+ const [teams, withYou] = await Promise.all([Teams.readAll(dir), Received.readAll(dir)]);
99
+ return send({ ok: true, readAt: new Date().toISOString(), withYou, yours: Host.links(dir).map((l) => ({ id: l.id, url: l.url, team: l.team, name: l.name, kind: l.kind, by: l.by, made: l.made, ends: l.ends, views: l.views })), teams });
93
100
  }
94
101
  if (req.method !== 'POST') return send({ ok: false, error: 'GET or POST only' }, 405);
95
102
 
@@ -104,6 +111,16 @@ module.exports = {
104
111
  const r = await Teams.join(dir, { invite: body.invite, host: body.host, team: body.team, password: body.password });
105
112
  return send(r.ok ? { ok: true, team: r.team } : { ok: false, error: r.error, kind: r.kind }, r.ok ? 200 : (r.kind === 'locked' ? 429 : 400));
106
113
  }
114
+ // a board someone shared with you (batch 27 item 3, ShX): checked with its host first; the password stays on this computer (D08)
115
+ if (p === '/api/board-publish/receive') {
116
+ const r = await Received.add(dir, { link: body.link, password: body.password });
117
+ return send(r.ok ? { ok: true, board: r.board } : { ok: false, error: r.error, kind: r.kind }, r.ok ? 200 : (r.kind === 'locked' ? 429 : r.kind === 'unreachable' ? 502 : 400));
118
+ }
119
+ if (p === '/api/board-publish/forget') { const r = Received.forget(dir, String(body.id || '')); return send(r.ok ? { ok: true } : { ok: false, error: 'This board is not kept on this computer.' }, r.ok ? 200 : 404); }
120
+ if (p === '/api/board-publish/visit') {
121
+ const r = await Received.visit(dir, String(body.id || ''));
122
+ return send(r.ok ? { ok: true, url: r.url } : { ok: false, error: r.error, kind: r.kind }, r.ok ? 200 : 502);
123
+ }
107
124
  if (p === '/api/board-publish/leave') { const r = Teams.leave(dir, String(body.team || '')); return send(r.ok ? { ok: true } : { ok: false, error: 'This computer has not joined that team.' }, r.ok ? 200 : 404); }
108
125
  if (p === '/api/board-publish/open') { // the one-time address of a team's board, to open in a browser tab
109
126
  const r = await Teams.open(dir, String(body.team || ''), String(body.id || ''));
@@ -124,28 +141,41 @@ module.exports = {
124
141
 
125
142
  // publish and republish both make a pack, scan it, then send it
126
143
  const now = Date.now();
127
- let sel; let meta;
144
+ let sel; let meta; let parts = null;
128
145
  if (p === '/api/board-publish/republish') {
129
146
  const l = Host.readState(dir).links.find((x) => x.id === String(body.id || ''));
130
147
  if (!l) return send({ ok: false, error: 'This link is not one of yours.' }, 404);
131
148
  sel = { ws: l.ws, board: l.board, select: (l.selection || []).map((s) => s.kind + ':' + s.id).join(','), by: l.by, ends: l.ends };
132
- meta = l;
149
+ meta = l; parts = Array.isArray(l.parts) && l.parts.length ? Parts.parseParts(l.parts) : null;
133
150
  } else {
134
151
  const days = Number(body.days);
135
152
  if (!Host.DAYS.includes(days)) return send({ ok: false, error: 'Ends in 1, 7 or 30 days.' }, 400);
136
153
  sel = { ws: body.ws, board: body.board || body.ws, select: Array.isArray(body.select) ? body.select.map((s) => s.kind + ':' + s.id).join(',') : String(body.select || ''), by: String(body.shared_by || '').slice(0, 60) || sharedBy(), ends: new Date(now + days * 86400000).toISOString() };
154
+ if (body.parts != null) { parts = Parts.parseParts(body.parts); if (!parts) return send({ ok: false, error: 'Pick up to ' + Parts.MAX_PARTS + ' products, programs or initiatives to publish.' }, 400); }
155
+ }
156
+ const base = { at: new Date(now).toISOString(), viewer: viewerVersion(), view: body.view || (meta && meta.view) };
157
+ let pack; let name; let kind;
158
+ if (parts) {
159
+ // several picks (batch 27 item 1, ShA): a pack per product, each made and swept as today, joined into one pack with a tab per pick
160
+ const c = await require('./board-publish-choices.js').contextOf(req, ctx, sel.ws, sel.board);
161
+ if (!c) return send({ ok: false, error: 'The board is still being read. Try again in a moment.' }, 202);
162
+ const r = await Parts.packOf(parts, { programs: c.programs, boards: c.boards, name: meta ? meta.name : String(body.name || '').slice(0, 120), make: (g) => makePack(req, ctx, Object.assign({ by: sel.by, ends: sel.ends }, base, { ws: g.ws, board: g.board, select: g.select })) });
163
+ if (!r.ok) return send({ ok: false, error: r.error }, r.status || 502);
164
+ pack = r.pack; name = r.label; kind = r.kind;
165
+ } else {
166
+ const pk = await makePack(req, ctx, Object.assign({}, sel, base));
167
+ if (!pk.ok) return send({ ok: false, error: pk.error }, pk.status || 502);
168
+ pack = pk.pack; name = pack.scope.label; kind = kindOf(pack);
137
169
  }
138
- const pk = await makePack(req, ctx, Object.assign({}, sel, { at: new Date(now).toISOString(), viewer: viewerVersion(), view: body.view }));
139
- if (!pk.ok) return send({ ok: false, error: pk.error }, pk.status || 502);
140
- const hits = secretScan.scan(JSON.stringify(pk.pack));
170
+ const hits = secretScan.scan(JSON.stringify(pack));
141
171
  if (hits.length) return send({ ok: false, error: 'This board holds what looks like a secret (' + hits.join(', ') + '). Remove it, then publish again.', secrets: hits }, 422);
142
172
 
143
173
  if (p === '/api/board-publish/republish') {
144
- const r = await Host.republish(dir, meta.id, pk.pack, { env });
174
+ const r = await Host.republish(dir, meta.id, pack, { env });
145
175
  return send(r.ok ? { ok: true, ends: r.ends } : { ok: false, error: r.error }, r.ok ? 200 : (r.status || 502));
146
176
  }
147
177
  const selection = Array.isArray(body.select) ? body.select.map((s) => ({ kind: String(s.kind), id: String(s.id) })) : String(body.select || '').split(',').filter(Boolean).map((s) => ({ kind: s.slice(0, s.indexOf(':')), id: s.slice(s.indexOf(':') + 1) }));
148
- const r = await Host.publish(dir, { pack: pk.pack, team: body.team, name: pk.pack.scope.label, kind: kindOf(pk.pack), by: sel.by, days: Number(body.days), password: body.password == null ? '' : body.password, selection, ws: sel.ws, board: sel.board }, { env });
149
- return send(r.ok ? { ok: true, id: r.id, url: r.url, ends: r.ends } : { ok: false, error: r.error }, r.ok ? 200 : (r.status || 502));
178
+ const r = await Host.publish(dir, { pack, team: body.team, name, kind, by: sel.by, days: Number(body.days), password: body.password == null ? '' : body.password, selection, parts, ws: sel.ws, board: sel.board }, { env });
179
+ return send(r.ok ? { ok: true, id: r.id, url: r.url, ends: r.ends, name } : { ok: false, error: r.error }, r.ok ? 200 : (r.status || 502));
150
180
  },
151
181
  };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.521.0",
3
+ "version": "0.522.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.521.0",
9
+ "version": "0.522.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@anthropic-ai/sdk": "^1.3.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.521.0",
3
+ "version": "0.522.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -47,3 +47,6 @@
47
47
  .bppop :focus-visible{outline:2px solid var(--ember);outline-offset:2px}
48
48
  #bdPublish{position:relative}#bdPublish .bpn{position:absolute;top:2px;right:0;min-width:16px;padding:0 5px;border-radius:99px;background:var(--solid);color:var(--on-solid);font:var(--fw-strong) var(--fs-badge)/var(--lh-badge) var(--mono);text-align:center;line-height:16px}
49
49
  @media (max-width:640px){.bppop{left:0 !important;right:0;bottom:0;top:auto !important;width:auto;max-width:none;border-radius:16px 16px 0 0;border-width:1px 0 0;box-shadow:0 -8px 30px rgb(0 0 0 / .18)}.bppop .bpbody{max-height:85vh;padding:12px 12px calc(18px + env(safe-area-inset-bottom,0px))}.bppop .bpcaret{display:none}}
50
+ /* several picks (batch 27 item 1, ShA): Products and Programs as chip groups, and the link's name made from the picks */
51
+ .bppop .bpcap{font:var(--fw-strong) var(--fs-meta)/var(--lh-meta) var(--sans,inherit);color:var(--ink-3);padding:6px 8px 0}
52
+ .bppop .bpname{font-weight:var(--fw-strong);color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -4,7 +4,8 @@
4
4
  * POST /api/board-publish, which is a page press: only a press here publishes, never an agent session.
5
5
  *
6
6
  * No host "Add the board host": one field and a save icon. Nothing is sent before that.
7
- * Ready What to share (chips) · Team (optional) · Password (on by default, generated; a new one, or off) · Ends in 1, 7 or 30 days
7
+ * Ready What to share: Products (the board on screen first) and Programs (each says which products it reaches) as chips, in pick
8
+ * order; several picks are one link with a tab per pick and a name made from the picks, the pencil renames it (batch 27 item 1) · Team (optional) · Password (on by default, generated; a new one, or off) · Ends in 1, 7 or 30 days
8
9
  * · the host with a pencil to change it · the send-up icon publishes
9
10
  * Link ready the link and the password, one copy icon, shown once (the workspace does not keep the password)
10
11
  * Open links not listed here any more: one row "N open links" opens the Shared tab, where they are managed. Zero is silence.
@@ -27,13 +28,32 @@ const OK = svg('<path d="M5 12l5 5 9-10"/>');
27
28
  const UP = svg('<path d="M12 15V4M7.5 8.5L12 4l4.5 4.5M5 14v5h14v-5"/>');
28
29
  const COPY = svg('<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h9"/>');
29
30
  const MORE = svg('<path d="M9 6l6 6-6 6"/>');
31
+ const TAG = svg('<path d="M3 12V4h8l10 10-8 8zM7.5 8.5h.01"/>');
30
32
  /** An icon-only button: the word is its tooltip and its label. */
31
33
  const ib = (icon, label, attrs, cls = '') => `<button type="button" class="bpib${cls ? ' ' + cls : ''}" aria-label="${esc(label)}" title="${esc(label)}" ${attrs}>${icon}</button>`;
32
34
  const SOON_MS = 36 * 3600 * 1000;
33
35
 
34
36
  export const COUNT = (links) => (Array.isArray(links) ? links.length : 0);
35
37
  const keyOf = (k, id) => k + ':' + id;
36
- const chip = (S, kind, c, extra = '') => `<button type="button" class="bpchip" data-bp-sel="${esc(keyOf(kind, c.id))}" aria-pressed="${S.sel.has(keyOf(kind, c.id))}">${esc(c.label)} <small>${extra || kind}</small></button>`;
38
+ /** A product's key holds its ws and board (batch 27 item 1): a pick of another product publishes that product's board. */
39
+ const prodKey = (p) => (p.ws ? p.ws + '|' + p.id : p.id);
40
+ const chipKey = (kind, c) => keyOf(kind, kind === 'product' ? prodKey(c) : c.id);
41
+ const chip = (S, kind, c, extra = '') => `<button type="button" class="bpchip" data-bp-sel="${esc(chipKey(kind, c))}" aria-pressed="${S.sel.has(chipKey(kind, c))}">${esc(c.label)}${extra ? ` <small>${esc(extra)}</small>` : ''}</button>`;
42
+ /** The link's name made from the picks, in pick order: three names, then "+ N more" (lib/board-pack.js nameOf). */
43
+ export function autoName(S) {
44
+ const c = S.choices || { products: [], programs: [], initiatives: [] };
45
+ const labels = [...S.sel].map((k) => { const i = k.indexOf(':'); const kind = k.slice(0, i), id = k.slice(i + 1);
46
+ const list = kind === 'product' ? c.products : kind === 'program' ? c.programs : c.initiatives;
47
+ const x = (list || []).find((y) => (kind === 'product' ? prodKey(y) : y.id) === id); return x ? x.label : id; });
48
+ return labels.length > 3 ? labels.slice(0, 3).join(' + ') + ' + ' + (labels.length - 3) + ' more' : labels.join(' + ');
49
+ }
50
+ /** The picks as the publish route takes them, in pick order (lib/board-publish-parts.js). */
51
+ export function partsOf(S, ctx) {
52
+ return [...S.sel].map((k) => { const i = k.indexOf(':'); const kind = k.slice(0, i), id = k.slice(i + 1);
53
+ if (kind === 'product') { const j = id.indexOf('|'); return j < 0 ? { kind, ws: ctx.ws, board: id } : { kind, ws: id.slice(0, j), board: id.slice(j + 1) }; }
54
+ if (kind === 'program') return { kind, id };
55
+ return { kind, id, ws: ctx.ws, board: ctx.board || ctx.ws }; });
56
+ }
37
57
 
38
58
  /** The popover's inside. S = { phase, host, days, choices, sel:Set, team, pw, pass, err, busy, made, links, iniOpen, copied, now } */
39
59
  export function publishHtml(S) {
@@ -55,10 +75,17 @@ export function publishHtml(S) {
55
75
  }
56
76
  const c = S.choices || { products: [], programs: [], initiatives: [] };
57
77
  const picked = c.initiatives.filter((i) => S.sel.has(keyOf('initiative', i.id)));
58
- const chips = c.products.map((p) => chip(S, 'product', p)).join('') + c.programs.map((p) => chip(S, 'program', p)).join('') + picked.map((i) => chip(S, 'initiative', i)).join('')
78
+ const prods = c.products.map((p) => chip(S, 'product', p, p.n ? String(p.n) : '')).join('');
79
+ const progs = c.programs.map((p) => chip(S, 'program', p, p.reach || '')).join('');
80
+ const inis = picked.map((i) => chip(S, 'initiative', i, 'initiative')).join('')
59
81
  + (c.initiatives.length ? `<button type="button" class="bpchip add" data-bp="ini" aria-expanded="${!!S.iniOpen}">${PLUS}Initiative</button>` : '');
60
82
  const menu = S.iniOpen ? `<div class="bpmenu" role="menu">${c.initiatives.filter((i) => !S.sel.has(keyOf('initiative', i.id))).map((i) => `<button type="button" role="menuitem" data-bp-add="${esc(i.id)}">${esc(i.label)}</button>`).join('') || '<p class="bpnote">Every initiative is picked.</p>'}</div>` : '';
61
- return `<div class="bpeye">What to share</div><div class="bpchips">${chips}</div>${menu}`
83
+ // several picks: one link, one tab per pick; its name is made from the picks, and the pencil renames it (batch 27 item 1, ShA)
84
+ const name = S.sel.size > 1 ? (S.naming
85
+ ? `<div class="bpfield"><div class="bpinp2">${TAG}<input id="bpName" type="text" maxlength="120" autocomplete="off" aria-label="The link's name" value="${esc(S.name || autoName(S))}">${ib(OK, 'Keep this name', 'data-bp="named"', 'gh sm')}</div></div>`
86
+ : `<div class="bpfield"><div class="bpinp2" title="The link's name. Made from your picks; the pencil changes it.">${TAG}<span class="bpg bpname">${esc(S.name || autoName(S))}</span>${ib(PEN, 'Rename the link', 'data-bp="rename"', 'gh sm')}</div></div>`) : '';
87
+ return `<div class="bpeye">What to share</div>${c.products.length > 1 || c.programs.length ? '<div class="bpcap">Products</div>' : ''}<div class="bpchips">${prods}</div>`
88
+ + (progs ? `<div class="bpcap">Programs</div><div class="bpchips">${progs}</div>` : '') + `<div class="bpchips">${inis}</div>${menu}` + name
62
89
  + `<div class="bpfield"><div class="bpinp2">${TEAM}<input id="bpTeam" type="text" maxlength="40" autocomplete="off" aria-label="Team" placeholder="Team (optional)" value="${esc(S.team)}"></div></div>`
63
90
  + `<div class="bpfield"><div class="bpinp2">${LOCK}${S.pw ? `<input id="bpPass" type="text" spellcheck="false" autocomplete="off" aria-label="Password" value="${esc(S.pass)}">${ib(DICE, 'New password', 'data-bp="newpw"', 'gh sm')}` : '<span class="bpg bpoff">No password</span>'}<button type="button" class="bpsw" role="switch" aria-checked="${S.pw}" aria-label="Password" title="Password" data-bp="pw"></button></div></div>`
64
91
  + `<div class="bpline2"><span class="bpnote">Ends in</span><div class="bpseg" role="group" aria-label="Link ends after (days)">${[1, 7, 30].map((d) => `<button type="button" data-bp-days="${d}" aria-pressed="${S.days === d}" aria-label="${d === 1 ? '1 day' : d + ' days'}">${d}</button>`).join('')}</div><span class="bpnote">days</span></div>`
@@ -110,7 +137,8 @@ export function open(btn, ctx) {
110
137
  const onKey = (e) => { if (e.key === 'Escape' && m.isConnected) { e.stopPropagation(); close(true); } };
111
138
  const onOut = (e) => { if (m.isConnected && !m.contains(e.target) && !btn.contains(e.target)) close(false); };
112
139
  const draw = (focus) => {
113
- const keep = { team: m.querySelector('#bpTeam'), pass: m.querySelector('#bpPass'), host: m.querySelector('#bpHost') };
140
+ const keep = { team: m.querySelector('#bpTeam'), pass: m.querySelector('#bpPass'), host: m.querySelector('#bpHost'), name: m.querySelector('#bpName') };
141
+ if (keep.name) S.name = keep.name.value.trim() === autoName(S) ? '' : keep.name.value.trim();
114
142
  if (keep.team) S.team = keep.team.value; if (keep.pass) S.pass = keep.pass.value; if (keep.host) S.hostIn = keep.host.value;
115
143
  body.innerHTML = publishHtml(S); place();
116
144
  const f = focus && m.querySelector(focus); if (f) f.focus();
@@ -120,7 +148,7 @@ export function open(btn, ctx) {
120
148
  const r = await pmGet(q).catch(() => null);
121
149
  if (!r || !r.ok) { S.phase = 'form'; S.err = (r && r.error) || 'The workspace did not answer. Try again.'; return draw(); }
122
150
  S.phase = 'form'; S.host = r.host; S.links = r.links || []; S.choices = r.choices; if (!S.pass) S.pass = r.password || ''; S.now = Date.now();
123
- if (!S.sel.size && S.choices && S.choices.products[0]) S.sel.add(keyOf('product', S.choices.products[0].id));
151
+ if (!S.sel.size && S.choices && S.choices.products[0]) S.sel.add(chipKey('product', S.choices.products[0]));
124
152
  draw(); refreshCount(btn);
125
153
  };
126
154
  m.onclick = async (e) => {
@@ -138,6 +166,8 @@ export function open(btn, ctx) {
138
166
  if (!r || !r.ok) { S.err = (r && r.error) || 'The address was not saved.'; return draw('#bpHost'); }
139
167
  S.phase = 'loading'; draw(); return load();
140
168
  }
169
+ if (act === 'rename') { S.naming = true; return draw('#bpName'); }
170
+ if (act === 'named') { S.naming = false; return draw('[data-bp="rename"]'); }
141
171
  if (act === 'ini') { S.iniOpen = !S.iniOpen; return draw('[data-bp="ini"]'); }
142
172
  if (act === 'pw') { S.pw = !S.pw; return draw('[data-bp="pw"]'); }
143
173
  if (act === 'newpw') { const r = await pmGet('/api/board-publish').catch(() => null); if (r && r.ok) S.pass = r.password; return draw('#bpPass'); }
@@ -148,10 +178,10 @@ export function open(btn, ctx) {
148
178
  }
149
179
  if (act === 'make') {
150
180
  draw(); if (S.busy) return; S.busy = true; S.err = ''; draw();
151
- const select = [...S.sel].map((k) => { const i = k.indexOf(':'); return { kind: k.slice(0, i), id: k.slice(i + 1) }; });
152
- const r = await pmPost('/api/board-publish', { ws: ctx.ws, board: ctx.board || ctx.ws, select, team: S.team.trim(), password: S.pw ? S.pass : '', days: S.days, view: ctx.view }).catch(() => null);
181
+ const parts = partsOf(S, ctx); const several = S.sel.size > 1;
182
+ const r = await pmPost('/api/board-publish', { ws: ctx.ws, board: ctx.board || ctx.ws, parts, name: several ? S.name || '' : '', team: S.team.trim(), password: S.pw ? S.pass : '', days: S.days, view: ctx.view }).catch(() => null);
153
183
  S.busy = false;
154
- if (r && r.ok) { const l = (await pmGet('/api/board-publish').catch(() => null)) || {}; S.links = l.links || S.links; S.made = { url: r.url, pass: S.pw ? S.pass : '', ends: r.ends, team: S.team.trim(), name: (S.links.find((x) => x.id === r.id) || {}).name || 'Board' }; S.pass = ''; S.err = ''; draw('[data-bp="copy"]'); return refreshCount(btn); }
184
+ if (r && r.ok) { const l = (await pmGet('/api/board-publish').catch(() => null)) || {}; S.links = l.links || S.links; S.made = { url: r.url, pass: S.pw ? S.pass : '', ends: r.ends, team: S.team.trim(), name: r.name || (S.links.find((x) => x.id === r.id) || {}).name || 'Board' }; S.pass = ''; S.err = ''; draw('[data-bp="copy"]'); return refreshCount(btn); }
155
185
  S.err = (r && r.error) || 'The workspace did not answer. Try again.'; return draw();
156
186
  }
157
187
  };
@@ -92,3 +92,8 @@
92
92
  #cShared .bdsh-pwpop::before { display: none; }
93
93
  #cShared .bdsh-first { margin: 8px auto; }
94
94
  }
95
+ /* Shared with you (batch 27 item 3, ShX X2 and X3): the paste field on top, then one quiet row per board someone shared with you */
96
+ #cShared .bdsh-paste { display: grid; gap: 8px; margin-bottom: 12px; }
97
+ #cShared .bdsh-col > .bdsh-cap { display: block; margin: 12px 0 4px; }
98
+ #cShared .bdsh-pill { display: inline-flex; align-items: center; height: 22px; padding: 0 9px; border-radius: 999px; background: var(--cz-well); font-size: var(--fs-badge); font-weight: var(--fw-strong); color: var(--cz-ink2); flex: none; }
99
+ #cShared .bdsh-pill.w { background: var(--cz-riskbg); color: var(--cz-risk); }