clavarium-binder 0.1.1-next.2 → 0.1.1

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/bin/binder.mjs CHANGED
@@ -17,6 +17,7 @@ export const HELP = `binder: the lists you were given, and their records.
17
17
  binder search "text" in every list you can see
18
18
  binder add LIST "title" [--description "text"] [--state S] [--due DATE] [--owner me] [--note "text"] [--field name=value]
19
19
  binder edit ID [--title "t"] [--description "text"] [--due DATE|none] [--owner me|ID|none] [--field name=value]
20
+ [--cover PICTURE|none] the picture the card is shown by: one attached (its ref), or a photo field
20
21
  binder move ID STATE past a gate, only what a person approved
21
22
  binder note ID "text" a short note: a decision, a fact, a result
22
23
  binder attach ID KIND REF ["title"] KIND: message, file, asset, branch, link, record
@@ -56,7 +57,7 @@ export function read(argv) {
56
57
  case 'get': return rest[0] ? ['get', { id: rest[0] }] : { refused: 'say which record: binder get ID' };
57
58
  case 'search': return rest.length ? ['search', { text: rest.join(' ') }] : { refused: 'say what to look for' };
58
59
  case 'add': return rest.length >= 2 ? ['add', { list: rest[0], title: rest.slice(1).join(' '), state: flags.state, due, owners, note: flags.note, description: flags.description, ...has }] : { refused: 'binder add LIST "title"' };
59
- case 'edit': return rest[0] ? ['edit', { id: rest[0], title: flags.title, description: flags.description, due, owners, ...has }] : { refused: 'say which record: binder edit ID' };
60
+ case 'edit': return rest[0] ? ['edit', { id: rest[0], title: flags.title, description: flags.description, due, owners, ...(flags.cover === undefined ? {} : { cover: flags.cover === 'none' ? '' : flags.cover }), ...has }] : { refused: 'say which record: binder edit ID' };
60
61
  case 'move': return rest.length === 2 ? ['move', { id: rest[0], state: rest[1] }] : { refused: 'binder move ID STATE' };
61
62
  case 'note': return rest.length >= 2 ? ['note', { id: rest[0], text: rest.slice(1).join(' ') }] : { refused: 'binder note ID "text"' };
62
63
  case 'attach': return rest.length >= 3 ? ['attach', { id: rest[0], kind: rest[1], ref: rest[2], title: rest.slice(3).join(' ') }] : { refused: 'binder attach ID KIND REF ["title"]' };
package/docs/protocol.md CHANGED
@@ -64,3 +64,11 @@ whoever wrote it (`edit-note`).
64
64
 
65
65
  And the screen: `mount(element, { call, words, lang, people, picture, openAttached })`
66
66
  from `clavarium-binder/ui`, with `ui/binder-ui.css` and `ui/texts/*.json`.
67
+
68
+ What a card shows: what is set, nothing about what is empty. The fields set
69
+ make one line under its title; its state is said unless the card is in the
70
+ first state and that is the list's only state not done (with no gate). Its
71
+ picture -- the `cover` chosen (`binder edit ID --cover REF`, an attached
72
+ picture or a photo field), else its photo field set, else its only picture
73
+ attached, else none -- is put forward
74
+ on its page and in Tiles, through the page's `picture(value)`.
@@ -57,9 +57,9 @@ export function say(op, data, list) {
57
57
  const out = [`#${r.id} ${r.title}`, ` list: ${l.title}; state: ${st?.name ?? r.state}${r.approved ? ' (approved)' : ''}${r.archived ? '; archived' : ''}${owners(r) ? `; owners:${owners(r)}` : ''}${due(r)}`];
58
58
  if (r.description) out.push('Description:', ...r.description.split('\n').map((x) => (x ? ` ${x}` : '')));
59
59
  else out.push('No description yet: give one (what it is and why, 1-3 lines for a person) with binder edit ID --description "...".');
60
- for (const f of l.fields) if (r.fields[f.id] !== undefined) out.push(` ${f.name}: ${r.fields[f.id]}`);
60
+ for (const f of l.fields) if (r.fields[f.id] !== undefined) out.push(` ${f.name}: ${r.fields[f.id]}${r.cover === `field:${f.id}` ? ' (cover)' : ''}`);
61
61
  if (r.notes.length) { out.push('Notes:'); for (const n of r.notes) out.push(` ${day(n.at)} ${n.by.name}: ${n.text.replace(/\n/g, '\n ')}${n.edited ? ' (edited)' : ''}`); }
62
- if (r.attached.length) { out.push('Attached:'); for (const a of r.attached) out.push(` ${a.kind} ${a.kind === 'record' ? `#${a.ref}` : a.ref}${a.title ? ` "${a.title}"` : ''}`); }
62
+ if (r.attached.length) { out.push('Attached:'); for (const a of r.attached) out.push(` ${a.kind} ${a.kind === 'record' ? `#${a.ref}` : a.ref}${a.title ? ` "${a.title}"` : ''}${r.cover === a.id ? ' (cover)' : ''}`); }
63
63
  out.push(`History: ${history.length} change${history.length === 1 ? '' : 's'}, the last ${history.length ? `${day(history.at(-1).at)} by ${history.at(-1).by.name}` : 'none'}.`);
64
64
  return out.join('\n');
65
65
  }
@@ -89,7 +89,7 @@ export function keeper(dir, { sides = () => [], now = () => Date.now() } = {}) {
89
89
  case 'get': data = store.record(a.id, who); break;
90
90
  case 'search': data = store.search(a.text, who); break;
91
91
  case 'add': data = store.add(a.list, { title: a.title, description: a.description, state: a.state, fields: a.fields, owners: me(a.owners), due: a.due, note: a.note }, who); break;
92
- case 'edit': data = store.edit(a.id, { title: a.title, description: a.description, fields: a.fields, owners: me(a.owners), due: a.due }, who); break;
92
+ case 'edit': data = store.edit(a.id, { title: a.title, description: a.description, fields: a.fields, owners: me(a.owners), due: a.due, cover: a.cover }, who); break;
93
93
  case 'move': { const r = store.move(a.id, a.state, who); data = { ...r, stateName: store.record(r.id, who).list.states.find((x) => x.id === r.state)?.name }; break; }
94
94
  case 'note': data = store.note(a.id, a.text, who); break;
95
95
  case 'attach': data = store.attach(a.id, { kind: a.kind, ref: a.ref, title: a.title }, who); break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clavarium-binder",
3
- "version": "0.1.1-next.2",
3
+ "version": "0.1.1",
4
4
  "description": "Clavarium Binder: lists of records kept together by people and AI agents, each with its id (a command and an MCP server for agents, a keeper and a screen for the application). No dependency.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/clavarium/clavarium-binder#readme",
package/server/binder.mjs CHANGED
@@ -23,7 +23,7 @@ import { fileURLToPath } from 'node:url';
23
23
  import { randomUUID } from 'node:crypto';
24
24
  import { createInterface } from 'node:readline';
25
25
 
26
- export const VERSION = '0.1.1-next.2';
26
+ export const VERSION = '0.1.1';
27
27
  export const FOLDER = '.binder';
28
28
  /* How long an ask waits for its answer. The keeper answers within a second or two. */
29
29
  export const WAIT_MS = 20_000;
@@ -68,7 +68,7 @@ export const TOOLS = [
68
68
  { name: 'get', description: 'One record whole: its fields, notes, what is attached, its history.', inputSchema: { type: 'object', properties: { id: S('the record number, as #12 or 12') }, required: ['id'] } },
69
69
  { name: 'search', description: 'Records whose title, notes or fields hold a text, in every list you can see.', inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] } },
70
70
  { name: 'add', description: 'Add a record to a list (in its first state unless you say), with a short title and a description a person understands at a glance. You cannot add it approved: a person ticks it.', inputSchema: { type: 'object', properties: { list: S('its name or id'), title: { type: 'string' }, description: DESCRIBE, state: { type: 'string' }, due: S('2026-11-04 or 2026-11-04 09:00'), owner: S('"me", or an id'), note: { type: 'string' }, fields: { type: 'object', description: '{ field name: value }' } }, required: ['list', 'title'] } },
71
- { name: 'edit', description: 'Change a record: title, description, fields, owners, due date (empty to remove it).', inputSchema: { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' }, description: DESCRIBE, due: { type: 'string' }, owner: S('"me", or an id; empty for nobody'), fields: { type: 'object' } }, required: ['id'] } },
71
+ { name: 'edit', description: 'Change a record: title, description, fields, owners, due date, cover picture (empty to remove it).', inputSchema: { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' }, description: DESCRIBE, due: { type: 'string' }, owner: S('"me", or an id; empty for nobody'), fields: { type: 'object' }, cover: S('the picture the card is shown by, when it has several: a picture attached (its ref) or a photo field; empty to remove it') }, required: ['id'] } },
72
72
  { name: 'move', description: 'Put a record in another state of its list. Past a gate (an approval), only once a person approved it.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, state: { type: 'string' } }, required: ['id', 'state'] } },
73
73
  { name: 'note', description: 'Add a short note to a record: a decision, a fact, a result. Talking with the person stays in the chat.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, text: { type: 'string' } }, required: ['id', 'text'] } },
74
74
  { name: 'attach', description: 'Attach something to a record: a message of the chat, a file, an asset, a branch, a link, another record.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, kind: { type: 'string', enum: ['message', 'file', 'asset', 'branch', 'link', 'record'] }, ref: S('what it is: a path, a URL, repository:branch, a message id, a record number'), title: { type: 'string' } }, required: ['id', 'kind', 'ref'] } },
@@ -84,7 +84,7 @@ export function asked(name, a = {}) {
84
84
  case 'get': return ['get', { id: a.id }];
85
85
  case 'search': return ['search', { text: a.text }];
86
86
  case 'add': return ['add', { list: a.list, title: a.title, description: a.description, state: a.state, due: a.due, owners, note: a.note, fields: a.fields }];
87
- case 'edit': return ['edit', { id: a.id, title: a.title, description: a.description, due: a.due, owners, fields: a.fields }];
87
+ case 'edit': return ['edit', { id: a.id, title: a.title, description: a.description, due: a.due, owners, fields: a.fields, cover: a.cover }];
88
88
  case 'move': return ['move', { id: a.id, state: a.state }];
89
89
  case 'note': return ['note', { id: a.id, text: a.text }];
90
90
  case 'attach': return ['attach', { id: a.id, kind: a.kind, ref: a.ref, title: a.title }];
package/server/store.mjs CHANGED
@@ -36,6 +36,8 @@ export const STATES_MAX = 12;
36
36
  export const FIELDS_MAX = 20;
37
37
  export const OWNERS_MAX = 10;
38
38
  export const ATTACH_KINDS = ['message', 'file', 'asset', 'branch', 'link', 'record'];
39
+ /* A file attached that is a picture, by its name (the screen has the same rule). */
40
+ export const isPicture = (ref) => /\.(jpe?g|png|webp|gif)$/i.test(String(ref ?? '').split(/[?#]/)[0]);
39
41
 
40
42
  const NOT_SHOWN = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F​-‏‪-‮⁠-⁤⁦-⁩]/gu;
41
43
  export const oneLine = (text, max) => String(text ?? '').replace(/\r/g, '').replace(NOT_SHOWN, '').replace(/\s+/g, ' ').trim().slice(0, max);
@@ -259,7 +261,12 @@ export function openStore(dir, { now = () => Date.now() } = {}) {
259
261
  save(); log(w, 'add', { record: id, list: list.id, change: { title: t, ...(d ? { description: d } : {}), state: st.id } });
260
262
  return r;
261
263
  },
262
- edit(given, { title, description, fields, owners, due } = {}, who) {
264
+ /* COVER: the one picture a card is shown by, when it has several. It is a
265
+ * picture attached (its id; given by id, the start of it, or its ref) or a
266
+ * photo field set ("field:ID"; given by the field's id or name). Empty
267
+ * clears it. Without one, a card is shown by its photo field set, else by
268
+ * its only picture attached. */
269
+ edit(given, { title, description, fields, owners, due, cover } = {}, who) {
263
270
  const w = whoOf(who);
264
271
  const { r, list } = recordOf(given, w, 'write');
265
272
  const change = {};
@@ -268,6 +275,7 @@ export function openStore(dir, { now = () => Date.now() } = {}) {
268
275
  if (fields !== undefined) change.fields = setFields(list, r, fields);
269
276
  if (owners !== undefined) r.owners = change.owners = ownersOf(owners);
270
277
  if (due !== undefined) { if (due === null || due === '') { delete r.due; change.due = null; } else r.due = change.due = dueOf(due); }
278
+ if (cover !== undefined) { const c = coverOf(list, r, cover); if (c) r.cover = change.cover = c; else { delete r.cover; change.cover = null; } }
271
279
  r.changed = stamp();
272
280
  save(); log(w, 'edit', { record: r.id, list: list.id, change });
273
281
  return r;
@@ -335,6 +343,7 @@ export function openStore(dir, { now = () => Date.now() } = {}) {
335
343
  const a = r.attached.find((x) => x.id === attachedId || x.id.startsWith(String(attachedId ?? '-')) || x.ref === attachedId);
336
344
  if (!a) no('nothing like that is attached');
337
345
  r.attached = r.attached.filter((x) => x !== a); r.changed = stamp();
346
+ if (r.cover === a.id) delete r.cover;
338
347
  save(); log(w, 'detach', { record: r.id, list: list.id, change: { kind: a.kind, ref: a.ref } });
339
348
  return r;
340
349
  },
@@ -372,7 +381,7 @@ export function openStore(dir, { now = () => Date.now() } = {}) {
372
381
  for (const [k, v] of Object.entries(given)) {
373
382
  const f = list.fields.find((x) => x.id === k) ?? list.fields.find((x) => x.name.toLowerCase() === String(k).toLowerCase());
374
383
  if (!f) no(`the list ${list.title} has no field "${k}"${list.fields.length ? ` (it has: ${list.fields.map((x) => x.name).join(', ')})` : ''}`);
375
- if (v === null || v === '') { delete r.fields[f.id]; change[f.id] = null; continue; }
384
+ if (v === null || v === '') { delete r.fields[f.id]; change[f.id] = null; if (r.cover === `field:${f.id}`) delete r.cover; continue; }
376
385
  const value = oneLine(v, FIELD_MAX);
377
386
  if (f.type === 'number' && !Number.isFinite(Number(value))) no(`${f.name} is a number`);
378
387
  if (f.type === 'date' && !/^\d{4}-\d{2}-\d{2}$/.test(value)) no(`${f.name} is a date: 2026-11-04`);
@@ -381,6 +390,16 @@ export function openStore(dir, { now = () => Date.now() } = {}) {
381
390
  }
382
391
  return change;
383
392
  }
393
+ function coverOf(list, r, given) {
394
+ if (given === null || given === '') return '';
395
+ const g = oneLine(given, REF_MAX);
396
+ const a = r.attached.find((x) => x.id === g) ?? r.attached.find((x) => x.ref === g) ?? (g.length >= 4 ? r.attached.find((x) => x.id.startsWith(g)) : undefined);
397
+ if (a) { if (a.kind !== 'file' || !isPicture(a.ref)) no(`the cover is a picture: ${a.ref} is not one`); return a.id; }
398
+ const name = g.replace(/^field:/, '');
399
+ const f = list.fields.find((x) => x.id === name) ?? list.fields.find((x) => x.name.toLowerCase() === name.toLowerCase());
400
+ if (f) { if (f.type !== 'photo') no(`the cover is a picture: ${f.name} is not a photo field`); if (!r.fields[f.id]) no(`${f.name} is empty`); return `field:${f.id}`; }
401
+ return no('the cover is a picture attached (its id, or what it is) or a photo field');
402
+ }
384
403
  function ownersOf(given) {
385
404
  const list = (Array.isArray(given) ? given : [given]).map((x) => oneLine(x, 100)).filter(Boolean);
386
405
  if (list.length > OWNERS_MAX) no(`${OWNERS_MAX} owners at most`);
package/ui/binder-ui.css CHANGED
@@ -35,6 +35,9 @@
35
35
  .binder-chip { border: 1px solid var(--binder-line); background: transparent; border-radius: 999px; padding: .3em .8em; color: var(--binder-dim); }
36
36
  .binder-chip[aria-pressed="true"] { border-color: var(--binder-action); color: var(--binder-text); background: color-mix(in srgb, var(--binder-action) 14%, transparent); }
37
37
  .binder-owners { display: flex; gap: .35rem; flex-wrap: wrap; }
38
+ .binder-pop { position: relative; }
39
+ .binder-dots { font-weight: 700; letter-spacing: .05em; padding: .4em .75em; line-height: 1.15; }
40
+ .binder-menu-end { left: auto; right: 0; }
38
41
  .binder-primary { background: var(--binder-action); color: var(--binder-on-action) !important; border: 1px solid var(--binder-action); border-radius: 8px; padding: .45em 1em; font-weight: 600; }
39
42
  .binder-ghost { background: var(--binder-panel); border: 1px solid var(--binder-line); border-radius: 8px; padding: .45em .9em; }
40
43
  .binder-danger { background: var(--binder-danger); color: var(--binder-bg) !important; border: 0; border-radius: 8px; padding: .45em .9em; font-weight: 600; }
@@ -51,6 +54,11 @@
51
54
  .binder-what { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
52
55
  .binder-what b { overflow-wrap: anywhere; }
53
56
  .binder-sub { color: var(--binder-dim); font-size: .9em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
57
+ /* The fields set, on one line under a title. */
58
+ .binder-facts { color: var(--binder-dim); overflow-wrap: anywhere; }
59
+ .binder-head .binder-facts { font-size: 1.02rem; color: var(--binder-text); opacity: .82; }
60
+ .binder-what .binder-facts { font-size: .9em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
61
+ .binder-facts-small { font-size: .82em; }
54
62
  .binder-meta { display: flex; gap: .6em; flex-wrap: wrap; font-size: .85em; color: var(--binder-dim); }
55
63
  .binder-tick { width: 1.4rem; height: 1.4rem; border: 2px solid var(--binder-dim); border-radius: 5px; background: transparent; display: grid; place-items: center; padding: 0; font-weight: 800; font-size: .85rem; line-height: 1; }
56
64
  .binder-tick[aria-checked="true"] { background: var(--binder-ok); border-color: var(--binder-ok); color: var(--binder-bg); }
@@ -67,7 +75,9 @@
67
75
  /* As cards, with a face. */
68
76
  .binder-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); gap: .8rem; }
69
77
  .binder-face { align-items: center; text-align: center; }
70
- .binder-face img, .binder-initial { width: 5.5rem; height: 5.5rem; border-radius: 50%; object-fit: cover; }
78
+ .binder-face { padding: .6rem .6rem .8rem; }
79
+ .binder-face img { width: 100%; aspect-ratio: 4 / 3; border-radius: 6px; object-fit: cover; background: var(--binder-panel); }
80
+ .binder-initial { width: 5.5rem; height: 5.5rem; border-radius: 50%; margin: .4rem 0; }
71
81
  .binder-initial { display: grid; place-items: center; background: var(--binder-panel); font-size: 2rem; font-weight: 700; color: var(--binder-dim); }
72
82
 
73
83
  /* A record, whole: one calm column, read on a phone first. */
@@ -78,7 +88,13 @@
78
88
  .binder .binder-text-btn { border: 0; background: transparent; padding: .35em 0; color: var(--binder-action); font-weight: 500; text-align: left; }
79
89
  .binder-text-btn:hover { text-decoration: underline; }
80
90
  .binder-small { padding: .35em .85em; font-size: .92em; }
81
- .binder-head { display: flex; flex-direction: column; gap: .6rem; }
91
+ .binder-head { display: flex; flex-direction: column; gap: .45rem; }
92
+ .binder-hero { display: block; width: 100%; max-height: 26rem; object-fit: cover; border-radius: 14px; background: var(--binder-panel); margin-bottom: -.4rem; }
93
+ .binder-offer { display: flex; flex-wrap: wrap; gap: .1rem .45rem; align-items: center; font-size: .92em; }
94
+ .binder .binder-offer .binder-text-btn { padding: .2em 0; }
95
+ .binder-thumb { object-fit: cover; padding: 0; }
96
+ .binder .binder-star { font-size: .85em; white-space: nowrap; color: var(--binder-dim); }
97
+ .binder .binder-star[aria-pressed="true"] { color: var(--binder-ask); }
82
98
  .binder .binder-title { font-size: 1.65rem; font-weight: 700; line-height: 1.25; letter-spacing: -.01em; border: 0; border-radius: 6px; background: transparent; padding: .05em .15em; margin: 0 -.15em;
83
99
  resize: none; overflow: hidden; width: 100%; box-sizing: border-box; field-sizing: content; }
84
100
  .binder .binder-title:hover:not([readonly]) { background: var(--binder-panel); }
@@ -154,4 +170,9 @@ button.binder-who:hover span:not(.binder-dot) { text-decoration: underline; text
154
170
  .binder-record { gap: 1.3rem; }
155
171
  .binder-row.binder-edit { grid-template-columns: 1fr auto; }
156
172
  .binder-cols { grid-auto-columns: minmax(80%, 1fr); }
173
+ .binder-hero { max-height: 18rem; }
174
+ .binder-bar { gap: .4rem; }
175
+ .binder-bar h2 { flex-basis: 100%; }
176
+ .binder-seg button { padding: .4em .6em; }
177
+ .binder-bar .binder-primary { padding: .45em .75em; }
157
178
  }
package/ui/binder-ui.js CHANGED
@@ -18,14 +18,77 @@
18
18
 
19
19
  export const KEYS = Object.freeze(['lists', 'new-list', 'new-item', 'no-lists', 'template', 'create', 'cancel', 'title', 'list', 'columns', 'cards', 'back',
20
20
  'state', 'owners', 'due', 'notes', 'add-note', 'attached', 'attach', 'history', 'archive', 'restore', 'delete', 'delete-sure', 'approved', 'settings',
21
- 'states', 'fields', 'access', 'read', 'write', 'none', 'save', 'empty', 'nobody', 'archived', 'show-archived', 'gate', 'done', 'add-state', 'add-field',
21
+ 'states', 'fields', 'access', 'read', 'write', 'none', 'save', 'empty', 'archived', 'show-archived', 'gate', 'done', 'add-state', 'add-field',
22
22
  'kind-message', 'kind-file', 'kind-asset', 'kind-branch', 'kind-link', 'kind-record', 'what', 'name', 'type', 'saved', 'edited', 'by', 'mine', 'all',
23
- 'delete-list', 'delete-list-sure', 'archive-list', 'no-records', 'search', 'description', 'add-description', 'edit', 'more', 'approve', 'no-due', 'add', 'ok']);
23
+ 'delete-list', 'delete-list-sure', 'archive-list', 'no-records', 'search', 'description', 'edit', 'more', 'approve', 'add', 'ok',
24
+ 'add-what', 'owner', 'attachment', 'tools', 'cover',
25
+ 'op-add', 'op-edit', 'op-move', 'op-note', 'op-edit-note', 'op-remove-note', 'op-attach', 'op-detach', 'op-archive', 'op-restore', 'op-delete']);
24
26
  const KINDS = ['message', 'file', 'asset', 'branch', 'link', 'record'];
25
27
  const ICON = { message: '💬', file: '📄', asset: '🗄', branch: '⑂', link: '🔗', record: '▣' };
26
28
  const TYPES = ['text', 'number', 'date', 'link', 'photo'];
27
29
  const DESCRIPTION_MAX = 4000;
28
30
 
31
+ /* WHAT A CARD SHOWS: what is set, and nothing about what is empty. These rules
32
+ * know no template: only a list's states and fields, and a record's values. */
33
+
34
+ /* A file that is a picture, by its name: what a screen may put forward. */
35
+ export const isPicture = (ref) => /\.(jpe?g|png|webp|gif)$/i.test(String(ref ?? '').split(/[?#]/)[0]);
36
+
37
+ /* A card's pictures: its photo fields set ("field:ID"), then its files attached
38
+ * that are pictures (their id): [{ key, value }]. */
39
+ export function pictures(l, r) {
40
+ const out = [];
41
+ for (const f of l.fields ?? []) if (f.type === 'photo' && r.fields?.[f.id]) out.push({ key: `field:${f.id}`, value: r.fields[f.id] });
42
+ for (const a of r.attached ?? []) if (a.kind === 'file' && isPicture(a.ref)) out.push({ key: a.id, value: a.ref });
43
+ return out;
44
+ }
45
+ /* The one put forward: the cover a person or an agent chose; else a photo field
46
+ * set (a picture put there on purpose); else the only picture attached; else none. */
47
+ export function coverOf(l, r) {
48
+ const all = pictures(l, r);
49
+ const attached = all.filter((p) => !p.key.startsWith('field:'));
50
+ return all.find((p) => p.key === r.cover) ?? all.find((p) => p.key.startsWith('field:')) ?? (attached.length === 1 ? attached[0] : null);
51
+ }
52
+ /* It, as a picture to show, through the page's `picture` hook; else none. */
53
+ export function imageOf(l, r, picture = () => null) {
54
+ const c = coverOf(l, r);
55
+ return c ? picture(c.value) ?? null : null;
56
+ }
57
+
58
+ /* The fields set, as one short line: [{ id, text }]. A date or a number says its
59
+ * field's name ("Naissance : 12 avril 1931": alone, a date or "3" says not what
60
+ * it is); a text or a link says itself. Photos are shown, not said. Empty fields
61
+ * are not there. */
62
+ export function facts(l, r, lang = 'en') {
63
+ const out = [];
64
+ const named = (name, text) => `${name}${String(lang).startsWith('fr') ? '\u202f: ' : ': '}${text}`;
65
+ for (const f of l.fields ?? []) {
66
+ const v = r.fields?.[f.id];
67
+ if (v === undefined || v === null || v === '' || f.type === 'photo') continue;
68
+ let text = String(v);
69
+ if (f.type === 'date') {
70
+ const t = Date.parse(`${text.slice(0, 10)}T00:00:00`);
71
+ if (Number.isFinite(t)) { try { text = new Date(t).toLocaleDateString(lang, { day: 'numeric', month: 'long', year: 'numeric' }); } catch { /* as typed */ } }
72
+ text = named(f.name, text);
73
+ } else if (f.type === 'number') {
74
+ try { text = named(f.name, Number(v).toLocaleString(lang)); } catch { text = named(f.name, text); }
75
+ } else if (f.type === 'link') {
76
+ try { text = new URL(text).host.replace(/^www\./, ''); } catch { /* as typed */ }
77
+ }
78
+ out.push({ id: f.id, text });
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /* Whether a card's state says anything. It says nothing when the card is in the
84
+ * first state and that is the list's only state not done, with no gate: then
85
+ * "open" is what every card is until it is done. Any other state is said. */
86
+ export function stateShown(l, r) {
87
+ const open = l.states.filter((s) => !s.done);
88
+ if (open.length !== 1 || l.states.some((s) => s.gate)) return true;
89
+ return r.state !== l.states[0]?.id;
90
+ }
91
+
29
92
  /* A DESCRIPTION, READ: a few lines of Markdown-ish text, as blocks a screen can
30
93
  * draw element by element. Paragraphs (a blank line between), lists ("- " or
31
94
  * "* "), **bold**, links ([text](https://...) or a bare http(s) address).
@@ -117,18 +180,27 @@ export function mount(root, { call, words = {}, lang = 'en', people = () => [],
117
180
  /* ---- the parts ---- */
118
181
  const top = () => h('div', { class: 'binder-top' },
119
182
  h('div', { class: 'binder-tabs', role: 'tablist' }, ui.lists.map((l) => h('button', { type: 'button', role: 'tab', 'aria-selected': String(l.id === ui.at), 'data-list': l.id,
120
- onclick: () => { ui.at = l.id; ui.view = null; ui.mode = 'list'; ui.record = null; void refresh(); } }, l.title, h('small', {}, String(l.count))))),
183
+ onclick: () => { ui.at = l.id; ui.view = null; ui.mode = 'list'; ui.record = null; ui.open = null; void refresh(); } }, l.title, h('small', {}, String(l.count))))),
121
184
  h('button', { type: 'button', class: 'binder-new-list', onclick: () => { ui.mode = 'new-list'; paint(); } }, `+ ${w('new-list')}`));
122
185
 
186
+ /* The bar: the title, the views, "+ Card"; the rest behind "…". A filter on is
187
+ * said by a chip that takes it off. */
123
188
  function bar(l) {
124
189
  const view = ui.view ?? l.view;
190
+ const flip = (k) => { ui[k] = !ui[k]; ui.open = null; void refresh(); };
191
+ const item = (on, text, act, fn) => h('button', { type: 'button', role: on === null ? 'menuitem' : 'menuitemcheckbox', 'aria-checked': on === null ? null : String(on), 'data-act': act, onclick: fn },
192
+ h('span', { class: 'binder-check', 'aria-hidden': 'true' }, on ? '✓' : ''), text);
193
+ const menu = ui.open === 'tools' ? h('div', { class: 'binder-menu binder-menu-end', role: 'menu' },
194
+ item(ui.mine, w('mine'), 'mine', () => flip('mine')),
195
+ item(ui.archived, w('archived'), 'archived', () => flip('archived')),
196
+ l.access === 'write' && l.grants ? item(null, w('settings'), 'settings', () => { ui.open = null; ui.mode = 'settings'; paint(); }) : null) : null;
197
+ const chip = (k) => (ui[k] ? h('button', { type: 'button', class: 'binder-chip', 'aria-pressed': 'true', 'data-act': `off-${k}`, onclick: () => flip(k) }, w(k === 'mine' ? 'mine' : 'archived'), h('span', { 'aria-hidden': 'true' }, ' ×')) : null);
125
198
  return h('div', { class: 'binder-bar' },
126
- h('h2', {}, l.title),
199
+ h('h2', {}, l.title), chip('mine'), chip('archived'),
127
200
  h('div', { class: 'binder-seg', role: 'group' }, ['list', 'columns', 'cards'].map((v) => h('button', { type: 'button', 'aria-pressed': String(v === view), 'data-view': v, onclick: () => { ui.view = v; paint(); } }, w(v)))),
128
- h('button', { type: 'button', class: 'binder-chip', 'aria-pressed': String(ui.mine), onclick: () => { ui.mine = !ui.mine; void refresh(); } }, w('mine')),
129
- h('button', { type: 'button', class: 'binder-chip', 'aria-pressed': String(ui.archived), onclick: () => { ui.archived = !ui.archived; void refresh(); } }, w('archived')),
130
- l.access === 'write' && l.grants ? h('button', { type: 'button', class: 'binder-ghost', 'data-act': 'settings', onclick: () => { ui.mode = 'settings'; paint(); } }, w('settings')) : null,
131
- l.access === 'write' && !ui.archived ? h('button', { type: 'button', class: 'binder-primary', 'data-act': 'new-item', onclick: () => { ui.mode = 'new-item'; paint(); } }, `+ ${w('new-item')}`) : null);
201
+ l.access === 'write' && !ui.archived ? h('button', { type: 'button', class: 'binder-primary', 'data-act': 'new-item', onclick: () => { ui.mode = 'new-item'; paint(); } }, `+ ${w('new-item')}`) : null,
202
+ h('div', { class: 'binder-pop' }, h('button', { type: 'button', class: 'binder-ghost binder-dots', 'data-act': 'tools', 'aria-haspopup': 'menu', 'aria-expanded': String(ui.open === 'tools'), 'aria-label': w('tools'), title: w('tools'),
203
+ onclick: () => { ui.open = ui.open === 'tools' ? null : 'tools'; paint(); } }, '…'), menu));
132
204
  }
133
205
 
134
206
  /* A due date as a person reads it: "26 oct.", "4 nov. 2027 09:00". */
@@ -150,34 +222,35 @@ export function mount(root, { call, words = {}, lang = 'en', people = () => [],
150
222
  return h('button', { type: 'button', class: 'binder-tick', role: 'checkbox', 'aria-checked': String(on), title: g >= 0 ? w('approved') : w('done'), disabled: l.access !== 'write' || !l.grants && g >= 0,
151
223
  onclick: (e) => { e.stopPropagation(); void soft(g >= 0 ? ask('approve', { id: r.id, yes: !on }) : ask('move', { id: r.id, state: on ? l.states[0].id : doneOf(l).id })).then(refresh); } }, on ? '✓' : '');
152
224
  }
153
- const pill = (l, r) => { const st = l.states.find((s) => s.id === r.state); return h('span', { class: 'binder-pill', 'data-state': r.state, 'data-done': st?.done ? '' : null, 'data-gate': st?.gate ? '' : null }, st?.name ?? r.state); };
225
+ const pill = (l, r) => { if (!stateShown(l, r)) return null; const st = l.states.find((s) => s.id === r.state); return h('span', { class: 'binder-pill', 'data-state': r.state, 'data-done': st?.done ? '' : null, 'data-gate': st?.gate ? '' : null }, st?.name ?? r.state); };
154
226
  const meta = (l, r) => h('span', { class: 'binder-meta' },
155
227
  r.owners.length ? r.owners.map(nameOf).join(', ') : null,
156
228
  r.due ? h('span', { class: late(l, r) ? 'binder-late' : null }, `⏰ ${when(r.due)}`) : null,
157
229
  r.attached.length ? h('span', {}, `📎 ${r.attached.length}`) : null,
158
230
  r.notes.length ? h('span', {}, `✎ ${r.notes.length}`) : null);
159
231
  const sub = (r) => (r.description ? h('span', { class: 'binder-sub' }, firstLine(r.description)) : null);
232
+ /* The fields set, on one line: "Naissance 12 avril 1931 · Saint-Aubin". */
233
+ const factLine = (l, r, cls = 'binder-facts') => { const f = facts(l, r, lang); return f.length ? h('span', { class: cls }, f.map((x) => x.text).join(' · ')) : null; };
160
234
  const leave = () => { ui.open = null; ui.more = false; ui.draft = {}; };
161
235
  const open = (r) => { void soft(ask('get', { id: r.id }).then((d) => { leave(); ui.record = d; ui.mode = 'record'; paint(); window.scrollTo?.(0, 0); })); };
162
236
 
163
237
  function asList(l, records) {
164
238
  return h('div', { class: 'binder-rows' }, records.map((r) => h('div', { class: 'binder-row', 'data-record': r.id, tabindex: '0', onclick: () => open(r), onkeydown: (e) => { if (e.key === 'Enter') open(r); } },
165
- tick(l, r), h('div', { class: 'binder-what' }, h('b', {}, r.title), sub(r), meta(l, r)), pill(l, r))));
239
+ tick(l, r), h('div', { class: 'binder-what' }, h('b', {}, r.title), factLine(l, r), sub(r), meta(l, r)), pill(l, r) ?? h('span'))));
166
240
  }
167
241
  function asColumns(l, records) {
168
242
  return h('div', { class: 'binder-cols' }, l.states.map((s) => {
169
243
  const here = records.filter((r) => r.state === s.id);
170
244
  return h('section', { class: 'binder-col', 'data-state': s.id }, h('h3', {}, s.name, h('span', {}, String(here.length))),
171
- here.map((r) => h('div', { class: 'binder-card', 'data-record': r.id, tabindex: '0', onclick: () => open(r), onkeydown: (e) => { if (e.key === 'Enter') open(r); } }, h('b', {}, r.title), sub(r), meta(l, r))));
245
+ here.map((r) => h('div', { class: 'binder-card', 'data-record': r.id, tabindex: '0', onclick: () => open(r), onkeydown: (e) => { if (e.key === 'Enter') open(r); } }, h('b', {}, r.title), factLine(l, r), sub(r), meta(l, r))));
172
246
  }));
173
247
  }
174
248
  function asCards(l, records) {
175
- const photo = l.fields.find((f) => f.type === 'photo');
176
249
  return h('div', { class: 'binder-grid' }, records.map((r) => {
177
- const src = photo && r.fields[photo.id] ? picture(r.fields[photo.id]) : null;
250
+ const src = imageOf(l, r, picture);
178
251
  return h('div', { class: 'binder-card binder-face', 'data-record': r.id, tabindex: '0', onclick: () => open(r), onkeydown: (e) => { if (e.key === 'Enter') open(r); } },
179
252
  src ? h('img', { src, alt: r.title, loading: 'lazy' }) : h('div', { class: 'binder-initial', 'aria-hidden': 'true' }, r.title.slice(0, 1).toUpperCase()),
180
- h('b', {}, r.title), pill(l, r));
253
+ h('b', {}, r.title), factLine(l, r, 'binder-facts binder-facts-small'), pill(l, r));
181
254
  }));
182
255
  }
183
256
 
@@ -203,9 +276,11 @@ export function mount(root, { call, words = {}, lang = 'en', people = () => [],
203
276
  h('button', { type: 'button', class: 'binder-primary', onclick: go }, w('create'))));
204
277
  }
205
278
 
206
- /* A RECORD, WHOLE, read at a glance: its title, its state, what it is (the
207
- * description); who and when on one line; what is attached; the notes.
208
- * Everything else (fields, history, archive, delete) is folded under "more". */
279
+ /* A RECORD, WHOLE, read at a glance: its picture, its title, its fields set on
280
+ * one line, its state when it says something, what it is (the description);
281
+ * who and when, when set; what is attached; the notes. What is empty is not
282
+ * shown: one quiet line offers to add it. The rest (fields to change,
283
+ * history, archive, delete) is folded under "more". */
209
284
  function record() {
210
285
  const { record: r, list: l, history } = ui.record;
211
286
  const can = l.access === 'write';
@@ -231,6 +306,15 @@ export function mount(root, { call, words = {}, lang = 'en', people = () => [],
231
306
  const approve = g >= 0 && can && l.grants && !r.approved
232
307
  ? h('button', { type: 'button', class: 'binder-primary binder-small', 'data-act': 'approve', onclick: () => done(ask('approve', { id: r.id, yes: true })) }, `✓ ${w('approve')}`) : null;
233
308
 
309
+ /* Its picture, put forward. */
310
+ const cover = coverOf(l, r);
311
+ const coverSrc = cover ? picture(cover.value) : null;
312
+ const hero = coverSrc ? h('img', { class: 'binder-hero', src: coverSrc, alt: r.title }) : null;
313
+ const all = pictures(l, r);
314
+ /* With several pictures, each says whether it is the one put forward, and can become it. */
315
+ const star = (key) => (can && all.length > 1 ? h('button', { type: 'button', class: 'binder-text-btn binder-star', 'data-act': 'cover', 'aria-pressed': String(cover?.key === key),
316
+ onclick: (e) => { e.preventDefault(); done(ask('edit', { id: r.id, cover: cover?.key === key ? '' : key })); } }, `${cover?.key === key ? '★' : '☆'} ${w('cover')}`) : null);
317
+
234
318
  /* What it is. */
235
319
  let about;
236
320
  if (ui.open === 'description') {
@@ -241,16 +325,15 @@ export function mount(root, { call, words = {}, lang = 'en', people = () => [],
241
325
  h('button', { type: 'button', class: 'binder-primary binder-small', 'data-act': 'save-description', onclick: () => { delete ui.draft.description; done(ask('edit', { id: r.id, description: text.value })); } }, w('save')),
242
326
  h('button', { type: 'button', class: 'binder-text-btn', onclick: () => { delete ui.draft.description; set(null); } }, w('cancel'))));
243
327
  } else {
244
- about = h('div', { class: 'binder-desc' }, r.description ? rich(r.description) : null,
245
- can ? h('button', { type: 'button', class: 'binder-text-btn', 'data-act': 'edit-description', onclick: () => set('description') }, r.description ? w('edit') : `+ ${w('add-description')}`) : null);
328
+ about = r.description ? h('div', { class: 'binder-desc' }, rich(r.description),
329
+ can ? h('button', { type: 'button', class: 'binder-text-btn', 'data-act': 'edit-description', onclick: () => set('description') }, w('edit')) : null) : null;
246
330
  }
247
331
 
248
- /* Who and when, on one line. */
332
+ /* Who and when, on one line: only what is set. */
249
333
  const names = r.owners.map(nameOf).join(', ');
250
- const whoLine = h(can ? 'button' : 'div', { type: can ? 'button' : null, class: 'binder-who', 'data-act': 'who', 'aria-expanded': can ? String(ui.open === 'who') : null, onclick: can ? () => set(ui.open === 'who' ? null : 'who') : null },
251
- h('span', { class: names ? null : 'binder-dim' }, `👤 ${names || w('nobody')}`),
252
- h('span', { class: 'binder-dot', 'aria-hidden': 'true' }, '·'),
253
- h('span', { class: r.due ? (late(l, r) ? 'binder-late' : null) : 'binder-dim' }, `⏰ ${r.due ? when(r.due) : w('no-due')}`));
334
+ const whoParts = [names ? h('span', {}, `👤 ${names}`) : null, r.due ? h('span', { class: late(l, r) ? 'binder-late' : null }, `⏰ ${when(r.due)}`) : null].filter(Boolean);
335
+ const whoLine = whoParts.length ? h(can ? 'button' : 'div', { type: can ? 'button' : null, class: 'binder-who', 'data-act': 'who', 'aria-expanded': can ? String(ui.open === 'who') : null, onclick: can ? () => set(ui.open === 'who' ? null : 'who') : null },
336
+ whoParts.flatMap((x, i) => (i ? [h('span', { class: 'binder-dot', 'aria-hidden': 'true' }, '·'), x] : [x]))) : null;
254
337
  let whoEdit = null;
255
338
  if (ui.open === 'who') {
256
339
  const day = h('input', { type: 'date', value: r.due?.slice(0, 10) ?? '', 'aria-label': w('due') });
@@ -265,7 +348,7 @@ export function mount(root, { call, words = {}, lang = 'en', people = () => [],
265
348
 
266
349
  /* What is attached: an icon and a name; a tap opens it. */
267
350
  const openIt = (a) => { if (a.kind === 'record') open({ id: a.ref }); else if (a.kind === 'link') window.open(a.ref, '_blank', 'noopener'); else openAttached?.(a); };
268
- const nameOfIt = (a) => a.title || (a.kind === 'record' ? `#${a.ref}` : a.kind === 'file' ? a.ref.split('/').filter(Boolean).at(-1) ?? a.ref : a.ref);
351
+ const nameOfIt = (a) => a.title || (a.kind === 'record' ? ui.data?.records?.find((x) => x.id === a.ref)?.title ?? `#${a.ref}` : a.kind === 'file' ? a.ref.split('/').filter(Boolean).at(-1) ?? a.ref : a.ref);
269
352
  let attachForm = null;
270
353
  if (can && ui.open === 'attach') {
271
354
  const kind = h('select', { 'aria-label': w('attach') }, KINDS.map((k) => h('option', { value: k }, w(`kind-${k}`))));
@@ -275,17 +358,32 @@ export function mount(root, { call, words = {}, lang = 'en', people = () => [],
275
358
  h('button', { type: 'button', class: 'binder-primary binder-small', 'data-act': 'attach', onclick: () => { if (ref.value.trim()) done(ask('attach', { id: r.id, kind: kind.value, ref: ref.value })); } }, w('attach')),
276
359
  h('button', { type: 'button', class: 'binder-text-btn', onclick: () => set(null) }, w('cancel')));
277
360
  }
278
- const attached = r.attached.length || can ? h('section', { class: 'binder-sec' }, h('h3', {}, w('attached')),
361
+ const thumb = (a) => { const src = a.kind === 'file' && isPicture(a.ref) ? picture(a.ref) : null; return src ? h('img', { class: 'binder-ic binder-thumb', src, alt: '' }) : h('span', { class: 'binder-ic', 'aria-hidden': 'true' }, ICON[a.kind] ?? '•'); };
362
+ const attached = r.attached.length || attachForm ? h('section', { class: 'binder-sec' }, h('h3', {}, w('attached')),
279
363
  r.attached.length ? h('ul', { class: 'binder-atts' }, r.attached.map((a) => h('li', { class: 'binder-att', 'data-kind': a.kind },
280
364
  h('button', { type: 'button', class: 'binder-link', title: `${w(`kind-${a.kind}`)} · ${a.kind === 'record' ? `#${a.ref}` : a.ref}`, onclick: () => openIt(a) },
281
- h('span', { class: 'binder-ic', 'aria-hidden': 'true' }, ICON[a.kind] ?? '•'), h('span', { class: 'binder-att-name' }, nameOfIt(a))),
365
+ thumb(a), h('span', { class: 'binder-att-name' }, nameOfIt(a))),
366
+ a.kind === 'file' && isPicture(a.ref) ? star(a.id) : null,
282
367
  can ? h('button', { type: 'button', class: 'binder-x', 'aria-label': `${w('delete')} ${nameOfIt(a)}`, onclick: () => void soft(ask('detach', { id: r.id, what: a.id })).then(refresh) }, '×') : null))) : null,
283
368
  attachForm ?? (can ? h('button', { type: 'button', class: 'binder-text-btn', 'data-act': 'show-attach', onclick: () => set('attach') }, `+ ${w('attach')}`) : null)) : null;
284
369
 
370
+ /* What is empty, offered in one quiet line: "+ Ajouter : description · échéance · responsable · pièce jointe · champ". */
371
+ const low = (k) => w(k).toLocaleLowerCase(lang);
372
+ const offers = can ? [
373
+ !r.description && ui.open !== 'description' ? ['description', low('description'), () => set('description')] : null,
374
+ !r.due && ui.open !== 'who' ? ['due', low('due'), () => set('who')] : null,
375
+ !r.owners.length && people().length && ui.open !== 'who' ? ['owner', low('owner'), () => set('who')] : null,
376
+ !r.attached.length && ui.open !== 'attach' ? ['attach', low('attachment'), () => set('attach')] : null,
377
+ l.fields.some((f) => r.fields[f.id] === undefined || r.fields[f.id] === '') ? ['fields', low('fields'), () => { ui.more = true; paint(); requestAnimationFrame(() => root.querySelector?.('.binder-fields input')?.focus()); }] : null,
378
+ ].filter(Boolean) : [];
379
+ const addRow = offers.length ? h('div', { class: 'binder-offer' }, h('span', { class: 'binder-dim' }, `+ ${w('add-what')}`),
380
+ offers.flatMap(([act, text, fn], i) => [i ? h('span', { class: 'binder-dot', 'aria-hidden': 'true' }, '·') : null,
381
+ h('button', { type: 'button', class: 'binder-text-btn', 'data-add': act, onclick: fn }, text)])) : null;
382
+
285
383
  /* Notes: small dated messages, and a box to add one. */
286
384
  const note = h('textarea', { rows: '2', placeholder: w('add-note'), 'aria-label': w('add-note'), maxlength: '2000', text: ui.draft.note ?? '' });
287
385
  note.addEventListener('input', () => { ui.draft.note = note.value; });
288
- const notes = r.notes.length || can ? h('section', { class: 'binder-sec' }, h('h3', {}, w('notes')),
386
+ const notes = r.notes.length || can ? h('section', { class: 'binder-sec' }, r.notes.length ? h('h3', {}, w('notes')) : null,
289
387
  r.notes.map((n) => h('div', { class: 'binder-note', 'data-kind': n.by.kind },
290
388
  h('div', { class: 'binder-note-by' }, h('b', {}, n.by.name), ` · ${stamp(n.at)}${n.edited ? ` · ${w('edited')}` : ''}`), h('p', {}, n.text))),
291
389
  can ? h('div', { class: 'binder-note-add' }, note, h('button', { type: 'button', class: 'binder-ghost binder-small', 'data-act': 'note',
@@ -297,7 +395,7 @@ export function mount(root, { call, words = {}, lang = 'en', people = () => [],
297
395
  const input = h('input', { type: f.type === 'date' ? 'date' : f.type === 'number' ? 'number' : 'text', value: String(v), disabled: !can, 'aria-label': f.name, 'data-field': f.id });
298
396
  input.addEventListener('change', () => void soft(ask('edit', { id: r.id, fields: { [f.id]: input.value } })).then(refresh));
299
397
  const src = f.type === 'photo' && v ? picture(v) : null;
300
- return h('label', { class: 'binder-field' }, h('span', {}, f.name), src ? h('img', { src, alt: f.name, class: 'binder-photo' }) : null, input);
398
+ return h('label', { class: 'binder-field' }, h('span', {}, f.name), src ? h('img', { src, alt: f.name, class: 'binder-photo' }) : null, input, f.type === 'photo' && v ? star(`field:${f.id}`) : null);
301
399
  };
302
400
  const stateName = (id) => l.states.find((s) => s.id === id)?.name ?? id;
303
401
  const sure = h('span', { class: 'binder-sure', hidden: true }, w('delete-sure'),
@@ -306,19 +404,22 @@ export function mount(root, { call, words = {}, lang = 'en', people = () => [],
306
404
  const more = h('details', { class: 'binder-more', open: ui.more, ontoggle: (e) => { ui.more = e.currentTarget.open; } }, h('summary', { 'data-act': 'more' }, `⋯ ${w('more')}`),
307
405
  l.fields.length ? h('div', { class: 'binder-fields' }, l.fields.map(field)) : null,
308
406
  h('div', { class: 'binder-history' }, h('h3', {}, `${w('history')} · ${history.length}`),
309
- history.slice().reverse().map((x) => h('div', { class: 'binder-log' }, h('time', {}, stamp(x.at)), `${x.by.name} · ${x.op}${x.op === 'move' && x.change ? ` → ${stateName(x.change.to)}` : ''}`))),
407
+ history.slice().reverse().map((x) => h('div', { class: 'binder-log' }, h('time', {}, stamp(x.at)), `${x.by.name} · ${words[`op-${x.op}`] ?? x.op}${x.op === 'move' && x.change ? ` → ${stateName(x.change.to)}` : ''}`))),
310
408
  can ? h('div', { class: 'binder-actions' },
409
+ /* The state said nothing, so it is not shown: closing the card is here. */
410
+ !stateShown(l, r) ? h('button', { type: 'button', class: 'binder-ghost binder-small', 'data-act': 'to-done', onclick: () => done(ask('move', { id: r.id, state: doneOf(l).id })) }, `✓ ${doneOf(l).name}`) : null,
311
411
  h('button', { type: 'button', class: 'binder-ghost binder-small', 'data-act': 'archive', onclick: () => void soft(ask('archive', { id: r.id, restore: !!r.archived })).then(() => { ui.record = null; ui.mode = 'list'; return refresh(); }) }, r.archived ? w('restore') : w('archive')),
312
412
  l.grants ? h('button', { type: 'button', class: 'binder-ghost binder-small binder-warn', 'data-act': 'delete', onclick: () => { sure.hidden = false; } }, w('delete')) : null, sure) : null);
313
413
 
314
414
  return h('div', { class: 'binder-page binder-record', 'data-record': r.id },
315
415
  h('div', { class: 'binder-crumb' }, h('button', { type: 'button', class: 'binder-text-btn', 'data-act': 'back', onclick: () => { leave(); ui.record = null; ui.mode = 'list'; void refresh(); } }, `‹ ${l.title}`),
316
416
  h('span', { class: 'binder-dim' }, `#${r.id}${r.archived ? ` · ${w('archived')}` : ''}`)),
317
- h('header', { class: 'binder-head' }, title,
318
- h('div', { class: 'binder-status' }, h('div', { class: 'binder-state-wrap' }, pillOf, menu), approve)),
417
+ hero,
418
+ h('header', { class: 'binder-head' }, title, factLine(l, r),
419
+ stateShown(l, r) || approve ? h('div', { class: 'binder-status' }, stateShown(l, r) ? h('div', { class: 'binder-state-wrap binder-pop' }, pillOf, menu) : null, approve) : null),
319
420
  about,
320
- h('div', { class: 'binder-who-wrap' }, whoLine, whoEdit),
321
- attached, notes, more);
421
+ whoLine || whoEdit ? h('div', { class: 'binder-who-wrap' }, whoLine, whoEdit) : null,
422
+ attached, addRow, notes, more);
322
423
  }
323
424
 
324
425
  function settings(l) {
@@ -383,9 +484,10 @@ export function mount(root, { call, words = {}, lang = 'en', people = () => [],
383
484
  box.replaceChildren(...parts);
384
485
  }
385
486
 
386
- /* The state menu closes on a tap elsewhere, or Escape. */
387
- const away = (e) => { if (ui.open === 'states' && !e.target?.closest?.('.binder-state-wrap')) { ui.open = null; paint(); } };
388
- const escape = (e) => { if (e.key === 'Escape' && ui.open === 'states') { ui.open = null; paint(); } };
487
+ /* A menu closes on a tap elsewhere, or Escape. */
488
+ const menus = ['states', 'tools'];
489
+ const away = (e) => { if (menus.includes(ui.open) && !e.target?.closest?.('.binder-pop')) { ui.open = null; paint(); } };
490
+ const escape = (e) => { if (e.key === 'Escape' && menus.includes(ui.open)) { ui.open = null; paint(); } };
389
491
  document.addEventListener('pointerdown', away);
390
492
  document.addEventListener('keydown', escape);
391
493
  void refresh();
package/ui/texts/en.json CHANGED
@@ -33,7 +33,6 @@
33
33
  "none": "None",
34
34
  "save": "Save",
35
35
  "empty": "Empty",
36
- "nobody": "Nobody",
37
36
  "archived": "Archived",
38
37
  "show-archived": "Archived",
39
38
  "gate": "Needs approval",
@@ -60,11 +59,25 @@
60
59
  "no-records": "Nothing here.",
61
60
  "search": "Search",
62
61
  "description": "Description",
63
- "add-description": "Add a description",
64
62
  "edit": "Edit",
65
63
  "more": "Details",
66
64
  "approve": "Approve",
67
- "no-due": "No due date",
68
65
  "add": "Add",
69
- "ok": "OK"
66
+ "ok": "OK",
67
+ "add-what": "Add:",
68
+ "owner": "Owner",
69
+ "attachment": "Attachment",
70
+ "tools": "More",
71
+ "cover": "Cover",
72
+ "op-add": "Created",
73
+ "op-edit": "Edited",
74
+ "op-move": "Moved",
75
+ "op-note": "Note",
76
+ "op-edit-note": "Note edited",
77
+ "op-remove-note": "Note removed",
78
+ "op-attach": "Attached",
79
+ "op-detach": "Detached",
80
+ "op-archive": "Archived",
81
+ "op-restore": "Restored",
82
+ "op-delete": "Deleted"
70
83
  }
package/ui/texts/fr.json CHANGED
@@ -33,7 +33,6 @@
33
33
  "none": "Aucun",
34
34
  "save": "Enregistrer",
35
35
  "empty": "Vide",
36
- "nobody": "Personne",
37
36
  "archived": "Archivées",
38
37
  "show-archived": "Archivées",
39
38
  "gate": "À valider",
@@ -60,11 +59,25 @@
60
59
  "no-records": "Rien ici.",
61
60
  "search": "Chercher",
62
61
  "description": "Description",
63
- "add-description": "Ajouter une description",
64
62
  "edit": "Modifier",
65
63
  "more": "Détails",
66
64
  "approve": "Valider",
67
- "no-due": "Sans échéance",
68
65
  "add": "Ajouter",
69
- "ok": "OK"
66
+ "ok": "OK",
67
+ "add-what": "Ajouter :",
68
+ "owner": "Responsable",
69
+ "attachment": "Pièce jointe",
70
+ "tools": "Plus",
71
+ "cover": "À la une",
72
+ "op-add": "Créée",
73
+ "op-edit": "Modifiée",
74
+ "op-move": "Déplacée",
75
+ "op-note": "Note",
76
+ "op-edit-note": "Note modifiée",
77
+ "op-remove-note": "Note retirée",
78
+ "op-attach": "Attaché",
79
+ "op-detach": "Détaché",
80
+ "op-archive": "Archivée",
81
+ "op-restore": "Restaurée",
82
+ "op-delete": "Supprimée"
70
83
  }