clavarium 0.2.377-next.1 → 0.2.378-next.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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.377-next.1",
3
+ "version": "0.2.378-next.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clavarium",
9
- "version": "0.2.377-next.1",
9
+ "version": "0.2.378-next.1",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.377-next.1",
3
+ "version": "0.2.378-next.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A conversation is a VM with a terminal in it. Everything else is a folder.",
@@ -101,7 +101,7 @@ export const SETTINGS_WORDS_FR = {
101
101
  'set-as-nobody': 'personne',
102
102
  /* The one list of assets (06-11, kit AssetList): its views, its one button, a group's head, what an empty one says. */
103
103
  'set-as-new': 'Nouveau', 'set-as-by-group': 'Groupes', 'set-as-by-type': 'Types', 'set-as-no-group': 'Sans groupe',
104
- 'set-as-fold': 'Replier', 'set-as-unfold': 'Déplier', 'set-as-group-open': 'Réglages de {name}', 'set-as-group-gear': 'Donner, renommer, supprimer',
104
+ 'set-as-fold': 'Replier', 'set-as-unfold': 'Déplier', 'set-as-group-gear': 'Donner, renommer, supprimer',
105
105
  'set-as-group-empty': 'Vide : glissez un asset ici', 'set-as-all-grouped': 'Chaque asset est dans un groupe',
106
106
  'set-as-no-groups': 'Créez un groupe, puis glissez-y un asset',
107
107
  /* One asset opened (06-11, kit AssetPanel, AccessList): who has it, its kind, what a calendar's levels mean, delete. */
@@ -140,7 +140,6 @@ export const SETTINGS_WORDS_FR = {
140
140
  'set-as-card': '1 carte', 'set-as-cards': '{n} cartes', 'set-as-linked': 'lié : {list}',
141
141
  'set-as-to-send': 'À envoyer', 'set-as-to-send-sub': 'tous les dépôts, relus et envoyés ensemble', 'set-as-waiting': '{n} vous attendent',
142
142
  'set-as-none-given': 'Rien ne lui est encore donné.', 'set-as-no-match': 'Rien à ce nom.',
143
- 'set-as-group': 'Groupe', 'set-as-group-make': 'Créez-en un dans la vue « Groupes ».',
144
143
  'set-as-files': 'Fichiers', 'set-as-browse': 'Parcourir',
145
144
  'set-cx-mail': 'boîte mail', 'set-cx-calendar': 'agenda', 'set-cx-contacts': 'carnet d\'adresses', 'set-cx-files': 'dossier en ligne',
146
145
  /* External MCP servers (mcpx.mjs): the form, their tools, the rights in their own words. */
@@ -163,7 +162,16 @@ export const SETTINGS_WORDS_FR = {
163
162
  'set-acc-share': 'Partager avec un appareil…', 'set-acc-in-assets': 'Ouvrir dans Assets',
164
163
  'set-acc-used-1': 'Utilisé par 1 conversation : mettez « Aucun »', 'set-acc-used-n': 'Utilisé par {n} conversations : mettez « Aucun »',
165
164
  /* Give… (07-11, assets step 4): in a conversation's tab; Settings › Access one line; a secret's next start. */
166
- 'set-give-go': 'Donner', 'set-give-all': 'Elle a déjà chaque asset.', 'set-group-none-all': 'Tout à Aucun',
165
+ 'set-give-go': 'Donner', 'set-give-all': 'Elle a déjà chaque asset.',
166
+ /* A group (07-11, assets step 5): opened in the asset panel, given to every member, made from any view. */
167
+ 'set-group-new': 'Nouveau groupe', 'set-group-new-dots': 'Nouveau groupe…', 'set-group-name': 'Nom du groupe',
168
+ 'set-group-add': 'Ajouter', 'set-group-cancel': 'Annuler', 'set-group-of': 'Groupe de {name}',
169
+ 'set-group-in': 'Dans le groupe', 'set-group-has': '{has} sur {of}', 'set-group-per-asset': 'Le droit reste sur chaque asset.',
170
+ 'set-group-empty-hint': 'Vide : choisissez ce groupe sur un asset.',
171
+ 'set-group-rename': 'Renommer…', 'set-group-save': 'Renommer', 'set-group-remove': 'Retirer le groupe…',
172
+ 'set-group-remove-q': 'Ses assets restent ; seul le groupe part.', 'set-group-remove-yes': 'Retirer',
173
+ 'set-group-skipped-1': '1 ignoré : {why}', 'set-group-skipped-n': '{n} ignorés : {why}',
174
+ 'set-group-only': '{kind}, {levels} seulement', 'set-group-no-device': '{kind}, pas pour un appareil',
167
175
  'set-acc-assets-1': '1 asset · voir', 'set-acc-assets-n': '{n} assets · voir', 'set-acc-asset-1': '1 asset', 'set-acc-asset-n': '{n} assets',
168
176
  'set-secret-next-on': '{name} : remis à son outil au prochain démarrage', 'set-secret-next-off': '{name} : plus remis dès son prochain démarrage',
169
177
  'set-files-list': 'Liste', 'set-files-icons': 'Icônes', 'set-files-empty': 'Ce dossier est vide.',
package/web/app.js CHANGED
@@ -3858,21 +3858,6 @@
3858
3858
  for (const d of ['m3 17 2 2 4-4', 'm3 7 2 2 4-4', 'M13 6h8', 'M13 12h8', 'M13 18h8']) { const path = document.createElementNS(SVG, 'path'); path.setAttribute('d', d); svg.append(path); }
3859
3859
  return svg;
3860
3860
  }
3861
- const roundPlus = (label, onclick) => { const b = el('button', 'as-add', '+'); b.type = 'button'; b.setAttribute('aria-label', label); b.title = label; b.addEventListener('click', onclick); return b; };
3862
- /* One row, used by both the by-type and the by-group views. The icon
3863
- follows the item's own kind (a group mixes kinds). */
3864
- function assetRow(item, { chosen, choose }) {
3865
- const row = el('button', 'as-row'); row.type = 'button'; row.dataset.asset = item.key; row.dataset.name = item.name;
3866
- if (item.key === chosen) row.setAttribute('aria-current', 'true');
3867
- const name = el('b', '', item.name);
3868
- if (item.badge) { const badge = el('span', `as-badge${item.badgeTone ? ` ${item.badgeTone}` : ''}`, item.badge); if (item.badgeTitle) badge.title = item.badgeTitle; name.append(badge); }
3869
- const sub = el('small', item.tone ? `as-sub ${item.tone}` : 'as-sub', item.sub ?? '');
3870
- if (item.dot) { sub.classList.add('status-line'); sub.dataset.tone = item.dot; }
3871
- const words = el('span', 'as-words'); words.append(name, sub);
3872
- row.append(kindIcon(item.kind), words);
3873
- row.addEventListener('click', () => choose(item.key));
3874
- return row;
3875
- }
3876
3861
  /* AN ITEM OF THESE LISTS, as the kit's AssetCard takes it (06-11, assets step 2): its point when it asks for
3877
3862
  you (a secret soon or past, a mailbox failing), the shade of its line, who has it or its right. */
3878
3863
  const cardOf = (item) => ({ key: item.key, kind: item.kind, name: item.name, sub: item.sub ?? '', live: item.live,
@@ -3946,9 +3931,6 @@
3946
3931
  /* Once a finger's drag has begun, the page must not scroll under it. */
3947
3932
  holder.addEventListener('touchmove', (e) => { if (drag?.on) e.preventDefault(); }, { passive: false });
3948
3933
  }
3949
- /* The shortcut "give all": each member at the lightest real level for its type. */
3950
- const giveValue = (item, on) => (!on ? (item.target || item.kind !== 'secret' ? 'none' : 'off')
3951
- : item.target ? 'watch' : item.kind === 'secret' ? 'use' : 'read');
3952
3934
  async function applyMemberGrant(item, conversationName, value) {
3953
3935
  if (item.target) return grantTarget(item.target.id, conversationName, value);
3954
3936
  if (item.kind === 'secret') return handTo(conversationName, item.name, value === 'use', asErrId());
@@ -3968,14 +3950,15 @@
3968
3950
  bar.replaceChildren(KIT.Segmented({ options: [{ value: 'group', label: uiSaid('set-as-by-group', 'Groups') }, { value: 'type', label: uiSaid('set-as-by-type', 'Types') }], value: view, label: 'Arrange assets',
3969
3951
  onChange: (by) => { if (by === view) return; assetsBy = by; keepAssetsBy(); groupAdding = false; void paintAssets(); } }));
3970
3952
  }
3971
- /* "+ New group", above the groups: a name in place, then made and opened. */
3953
+ /* "+ New group", above the list in either view (05-11, step 5: groups are made from any view): a name in place,
3954
+ then made and opened. */
3972
3955
  function newGroupBar() {
3973
3956
  const addBar = el('div', 'as-group-new');
3974
3957
  if (groupAdding) {
3975
3958
  const form = el('form', 'as-group-form'); form.autocomplete = 'off';
3976
- const input = el('input'); input.placeholder = 'Group name'; input.maxLength = 40; input.setAttribute('aria-label', 'New group name');
3977
- const make = el('button', 'primary', 'Add'); make.type = 'submit';
3978
- const cancel = el('button', '', 'Cancel'); cancel.type = 'button'; cancel.addEventListener('click', () => { groupAdding = false; void paintAssets(); });
3959
+ const input = el('input'); input.placeholder = uiSaid('set-group-name', 'Group name'); input.maxLength = 40; input.setAttribute('aria-label', input.placeholder);
3960
+ const make = el('button', 'primary', uiSaid('set-group-add', 'Add')); make.type = 'submit';
3961
+ const cancel = el('button', '', uiSaid('set-group-cancel', 'Cancel')); cancel.type = 'button'; cancel.addEventListener('click', () => { groupAdding = false; void paintAssets(); });
3979
3962
  form.append(input, make, cancel);
3980
3963
  form.addEventListener('submit', async (event) => {
3981
3964
  event.preventDefault(); $('assets-error').hidden = true;
@@ -3985,112 +3968,128 @@
3985
3968
  });
3986
3969
  addBar.append(form);
3987
3970
  queueMicrotask(() => input.focus());
3988
- } else addBar.append(roundPlus('New group', () => { groupAdding = true; void paintAssets(); }), el('span', 'hint', 'New group'));
3971
+ } else {
3972
+ const word = uiSaid('set-group-new', 'New group');
3973
+ const go = el('button', 'as-group-add'); go.type = 'button';
3974
+ go.append(el('span', 'as-add', '+'), el('span', 'hint', word));
3975
+ go.addEventListener('click', () => { groupAdding = true; void paintAssets(); });
3976
+ addBar.append(go);
3977
+ }
3989
3978
  return addBar;
3990
3979
  }
3991
- /* In an asset's detail: which group it is in. Moving it is a folder move
3992
- (it leaves any other group). New groups are made in the by-group view. */
3993
- function groupPicker(chosen, groups) {
3994
- const wrap = el('div', 'as-group-of'); wrap.append(el('span', 'as-group-of-label', uiSaid('set-as-group', 'Group')));
3995
- if (!groups.length) { wrap.append(el('small', 'hint', uiSaid('set-as-group-make', 'Make one in “By group”.'))); return wrap; }
3996
- const sel = el('select'); sel.setAttribute('aria-label', `Group for ${chosen.name}`);
3997
- const none = el('option', '', uiSaid('set-as-no-group', 'No group')); none.value = ''; sel.append(none);
3998
- const inId = groupOf(chosen.key, groups);
3999
- for (const g of groups) { const o = el('option', '', g.label); o.value = g.id; if (g.id === inId) o.selected = true; sel.append(o); }
4000
- if (!inId) none.selected = true;
4001
- sel.addEventListener('change', async () => {
3980
+ /* AN ASSET'S GROUP, a chip on its head in the menu (kit GroupChip: « Home ▾ »): moving it is a folder move (it leaves any
3981
+ other group); « New group… » makes one in place and puts it there. */
3982
+ function groupChip(chosen, groups) {
3983
+ const place = async (group) => {
4002
3984
  $('assets-error').hidden = true;
4003
- try { await post(`${API}/asset-groups/place`, { asset: chosen.key, group: sel.value || null }); }
3985
+ try { await post(`${API}/asset-groups/place`, { asset: chosen.key, group }); }
4004
3986
  catch (error) { $('assets-error').textContent = error.message; $('assets-error').hidden = false; }
4005
3987
  void paintAssets();
4006
- });
4007
- wrap.append(sel);
4008
- return wrap;
3988
+ };
3989
+ return KIT.GroupChip({ groups, value: groupOf(chosen.key, groups), name: chosen.name, onPlace: place,
3990
+ onMake: async (label) => {
3991
+ $('assets-error').hidden = true;
3992
+ let made;
3993
+ try { made = await post(`${API}/asset-groups`, { label }); }
3994
+ catch (error) { $('assets-error').textContent = error.message; $('assets-error').hidden = false; return false; }
3995
+ await place(made.id);
3996
+ return true;
3997
+ } });
3998
+ }
3999
+ /* A GROUP OPENED (05-11, assets step 5): the same panel as an asset (kit AssetPanel). Who has it: a conversation or a
4000
+ device given a level gets it on every member that takes it, « None » takes it from every member (kit groupFanOut);
4001
+ those skipped are said once. A right stays stored asset by asset: the group is only a label. Then its members (each
4002
+ opens), Rename, Remove (the assets stay). */
4003
+ let groupSaid = null;
4004
+ /* One holder's level of each member: { [key]: level }. */
4005
+ function groupCurrent(members, row) {
4006
+ if (String(row.id).startsWith('device:')) {
4007
+ const grants = sharedDevices.find((d) => `device:${d.id}` === row.id)?.grants ?? [];
4008
+ return Object.fromEntries(members.map((m) => [m.key, KIT.rightOf(KIT.rightsKey(m), grants.find((g) => g.asset === m.key)?.rights, 'device')]));
4009
+ }
4010
+ const name = String(row.id).slice('conversation:'.length);
4011
+ return Object.fromEntries(members.map((m) => [m.key, KIT.rightOf(KIT.rightsKey(m), memberCurrent(m, name))]));
4012
+ }
4013
+ function groupHolders(members) {
4014
+ const rows = [];
4015
+ const has = (g) => (g.level === 'some' ? uiSaid('set-group-has', '{has} of {of}', { has: g.has, of: g.of }) : '');
4016
+ /* Given in part: each member it has, with its right (as the old screen showed each one's level). */
4017
+ const parts = (g, now) => (g.level === 'some' ? members.filter((m) => (now[m.key] ?? 'none') !== 'none').map((m) => ({ name: m.name, key: KIT.rightsKey(m), level: now[m.key] })) : undefined);
4018
+ for (const c of conversations) {
4019
+ const id = `conversation:${c.name}`;
4020
+ const now = groupCurrent(members, { id });
4021
+ const g = KIT.groupLevel(members, now, 'conversation');
4022
+ if (c.phase !== 'ready' && g.level === 'none') continue;
4023
+ rows.push({ id, name: shownName(c) || c.name, sub: has(g) || (c.toolLabel ?? ''), level: g.level, ready: c.phase === 'ready', parts: parts(g, now) });
4024
+ }
4025
+ for (const d of sharedDevices) {
4026
+ if (!(d.grants ?? []).some((x) => members.some((m) => m.key === x.asset))) continue;
4027
+ const id = `device:${d.id}`;
4028
+ const now = groupCurrent(members, { id });
4029
+ const g = KIT.groupLevel(members, now, 'device');
4030
+ rows.push({ id, name: d.label ?? d.name ?? d.id, sub: has(g) || undefined, level: g.level, parts: parts(g, now) });
4031
+ }
4032
+ return rows;
4033
+ }
4034
+ async function setGroupAccess(group, members, row, level) {
4035
+ const err = $('assets-error'); err.hidden = true;
4036
+ const fan = KIT.groupFanOut({ members, level, holder: row.holder, current: groupCurrent(members, row) });
4037
+ let ok = true;
4038
+ for (const x of fan.give) {
4039
+ if (row.holder === 'device') {
4040
+ const id = String(row.id).slice('device:'.length);
4041
+ try { if (x.stored) await post(`${API}/devices/${id}/give`, { asset: x.key, rights: x.stored }); else await post(`${API}/devices/${id}/take`, { asset: x.key }); }
4042
+ catch (error) { sayAsErr(error); ok = false; }
4043
+ } else await applyMemberGrant(x.m, String(row.id).slice('conversation:'.length), x.stored ?? x.level);
4044
+ /* The first refusal stops it, said (a repository shared for writing asks its confirmation there). */
4045
+ if (!ok || !err.hidden) { ok = false; break; }
4046
+ }
4047
+ if (row.holder === 'device') await readDevices();
4048
+ groupSaid = { id: group.id, text: KIT.skippedSaid(fan.skip) };
4049
+ await paintAssets();
4050
+ return ok;
4009
4051
  }
4010
- /* A group's own detail: rename it, remove it (the assets stay), give the
4011
- whole group to a conversation at a level, and its members (each opens). */
4012
- function groupDetail(detail, group, items, back) {
4052
+ function groupPanel(detail, group, items, { back, choose }) {
4013
4053
  const members = (group.members ?? []).map((k) => items.find((i) => i.key === k)).filter(Boolean);
4014
- const top = el('div', 'as-title');
4015
- const all = el('button', 'as-link as-back', '‹ Assets'); all.type = 'button'; all.addEventListener('click', () => { if (!nav.close('asset')) back(); });
4016
- const h = el('h2'); h.append(el('span', '', group.label), el('span', 'as-badge count', String(members.length)));
4017
- top.append(all, h);
4018
- detail.append(top);
4019
- const repaint = () => void paintAssets();
4020
- const openAsset = (key) => { placeAssetStep(); if (key !== assetOpen) groupGiveShow.clear(); assetOpen = key; remember(); void paintAssets(); };
4021
-
4022
- /* GIVE THE GROUP: a block per conversation, a row per asset AT ITS OWN
4023
- level. Two shortcuts set or clear all at once (not a sticky state).
4024
- Rights stay stored per asset -- this is just a handy place to set them. */
4025
- const ready = conversations.filter((c) => c.phase === 'ready');
4026
- const isGiven = (c) => members.some((m) => !['none', 'off'].includes(memberCurrent(m, c.name)));
4027
- const shownConvs = ready.filter((c) => isGiven(c) || groupGiveShow.has(c.name));
4028
- const setAll = async (c, on) => { for (const m of members) { try { await applyMemberGrant(m, c.name, giveValue(m, on)); } catch { /* shown by the grant */ } } repaint(); };
4029
- const convBlock = (c) => {
4030
- const block = el('div', 'as-give-conv');
4031
- const head = el('div', 'as-give-head');
4032
- const giveAll = el('button', 'as-mini', 'Give all'); giveAll.type = 'button'; giveAll.addEventListener('click', () => setAll(c, true));
4033
- const clearAll = el('button', 'as-mini', uiSaid('set-group-none-all', 'All to None')); clearAll.type = 'button'; clearAll.addEventListener('click', () => setAll(c, false));
4034
- head.append(el('b', '', shownName(c)), el('span', 'as-give-acts', ''));
4035
- head.lastChild.append(giveAll, clearAll);
4036
- block.append(head);
4037
- for (const m of members) {
4038
- const line = el('div', 'as-give-row');
4039
- const nm = el('span', 'as-give-name'); nm.append(kindIcon(m.kind), el('span', '', m.name));
4040
- /* The one picker (kit RightsPicker, « None » first): the same levels as the asset's own panel. */
4041
- const pick = KIT.RightsPicker({ key: KIT.rightsKey(m), kind: m.kind, value: memberCurrent(m, c.name), label: `${m.name} for ${shownName(c)}`,
4042
- onChange: async (level, stored) => { await applyMemberGrant(m, c.name, stored ?? level); repaint(); } });
4043
- if (pick) line.append(nm, pick); else line.append(nm);
4044
- block.append(line);
4045
- }
4046
- if (members.length === 0) block.append(el('p', 'hint', 'The group is empty.'));
4047
- return block;
4048
- };
4049
- detail.append(chapter('Give the group to a conversation'));
4050
- const giveBox = el('div', 'as-give');
4051
- for (const c of shownConvs) giveBox.append(convBlock(c));
4052
- if (shownConvs.length === 0) giveBox.append(el('p', 'hint', members.length ? 'Not given to any conversation yet.' : 'Add assets to the group first (open an asset, pick this group).'));
4053
- detail.append(giveBox);
4054
- /* Add a conversation to set up, even before any asset is given to it. */
4055
- const others = ready.filter((c) => !shownConvs.includes(c));
4056
- const addConv = el('button', 'as-link as-add-conv', '+ a conversation'); addConv.type = 'button';
4057
- const picker = el('div', 'as-pick'); picker.hidden = true;
4058
- for (const c of others) { const b = el('button', 'as-pick-conv', shownName(c)); b.type = 'button'; b.addEventListener('click', () => { groupGiveShow.add(c.name); repaint(); }); picker.append(b); }
4059
- if (others.length === 0) picker.append(el('p', 'hint', ready.length ? 'Every conversation is shown.' : 'No conversation is ready.'));
4060
- addConv.addEventListener('click', () => { picker.hidden = !picker.hidden; });
4061
- if (members.length) detail.append(addConv, picker);
4062
- const note = el('p', 'as-assoc hint');
4063
- note.append(el('b', '', 'Per asset'), document.createTextNode(': each keeps the levels of its own type (a mailbox reads, a secret is used, a connexion watches…). The two shortcuts just set or clear all at once; each row stays editable, and the right is stored per asset.'));
4064
- detail.append(note);
4065
-
4066
- /* The assets in the group, each opens its own detail. */
4067
- detail.append(chapter('Assets in the group'));
4068
- const rowsBox = el('div', 'as-group-members');
4069
- for (const m of members) rowsBox.append(assetRow(m, { chosen: '', choose: openAsset }));
4070
- if (members.length === 0) rowsBox.append(el('p', 'hint', 'Open an asset and pick this group to put it here.'));
4071
- detail.append(rowsBox);
4072
-
4073
- /* Rename, then remove (a plain link -- it only drops the grouping). */
4074
- detail.append(chapter('Name'));
4075
- const form = el('form', 'as-group-rename'); form.autocomplete = 'off';
4076
- const name = el('input'); name.value = group.label; name.maxLength = 40; name.setAttribute('aria-label', 'Group name');
4077
- const go = el('button', 'btn', 'Rename'); go.type = 'submit';
4078
- form.append(name, go);
4079
- form.addEventListener('submit', async (event) => {
4080
- event.preventDefault(); $('assets-error').hidden = true;
4081
- try { await post(`${API}/asset-groups/${group.id}/rename`, { label: name.value.trim() }); } catch (error) { $('assets-error').textContent = error.message; $('assets-error').hidden = false; }
4082
- repaint();
4083
- });
4084
- detail.append(form);
4085
- const remove = el('div', 'as-group-remove');
4086
- const del = el('button', 'as-remove-link', 'Remove the group'); del.type = 'button';
4087
- del.addEventListener('click', async () => {
4088
- $('assets-error').hidden = true;
4089
- try { await post(`${API}/asset-groups/${group.id}/remove`, {}); groupGiveShow.clear(); assetOpen = ''; remember(); } catch (error) { $('assets-error').textContent = error.message; $('assets-error').hidden = false; }
4090
- repaint();
4054
+ if (groupSaid?.id !== group.id) groupSaid = null;
4055
+ const parts = [];
4056
+ if (members.length) {
4057
+ const levels = { conversation: KIT.groupLevels(members, 'conversation'), device: KIT.groupLevels(members, 'device') };
4058
+ const model = KIT.accessModel({ key: 'group', holders: groupHolders(members), levels });
4059
+ parts.push(KIT.AccessList(model, { key: 'group', name: group.label, levels, onChange: (row, level) => setGroupAccess(group, members, row, level) }));
4060
+ if (groupSaid?.text) parts.push(el('p', 'hint as-group-skipped', groupSaid.text));
4061
+ parts.push(el('p', 'hint as-group-note', uiSaid('set-group-per-asset', 'A right stays on each asset.')));
4062
+ }
4063
+ const rows = el('div', 'as-group-members');
4064
+ for (const m of members) rows.append(KIT.AssetCard(cardOf(m), { cls: 'as-row', onOpen: choose }));
4065
+ if (!members.length) rows.append(el('p', 'hint', uiSaid('set-group-empty-hint', 'Empty: pick this group on an asset.')));
4066
+ parts.push({ title: uiSaid('set-group-in', 'In the group'), body: [rows], cls: 'as-group-in' });
4067
+ /* Rename in place; remove, asked in place: the assets stay, only the label goes. */
4068
+ const rename = el('button', 'as-link as-group-rename-open', uiSaid('set-group-rename', 'Rename…')); rename.type = 'button';
4069
+ rename.addEventListener('click', () => {
4070
+ const form = el('form', 'as-group-rename'); form.autocomplete = 'off';
4071
+ const name = el('input'); name.value = group.label; name.maxLength = 40; name.setAttribute('aria-label', uiSaid('set-group-name', 'Group name'));
4072
+ const go = el('button', 'primary', uiSaid('set-group-save', 'Rename')); go.type = 'submit';
4073
+ const keep = el('button', '', uiSaid('set-group-cancel', 'Cancel')); keep.type = 'button';
4074
+ keep.addEventListener('click', () => { form.replaceWith(rename); });
4075
+ form.append(name, go, keep);
4076
+ form.addEventListener('submit', async (event) => {
4077
+ event.preventDefault(); $('assets-error').hidden = true;
4078
+ try { await post(`${API}/asset-groups/${group.id}/rename`, { label: name.value.trim() }); } catch (error) { $('assets-error').textContent = error.message; $('assets-error').hidden = false; return; }
4079
+ void paintAssets();
4080
+ });
4081
+ rename.replaceWith(form); focusField(name);
4091
4082
  });
4092
- remove.append(del, el('p', 'hint', 'The assets stay; only the group is removed.'));
4093
- detail.append(remove);
4083
+ const remove = KIT.AssetDelete({ label: uiSaid('set-group-remove', 'Remove group…'), lost: uiSaid('set-group-remove-q', 'Its assets stay; only the group goes.'), yes: uiSaid('set-group-remove-yes', 'Remove'),
4084
+ onYes: async () => {
4085
+ $('assets-error').hidden = true;
4086
+ try { await post(`${API}/asset-groups/${group.id}/remove`, {}); assetOpen = ''; groupSaid = null; remember(); }
4087
+ catch (error) { $('assets-error').textContent = error.message; $('assets-error').hidden = false; }
4088
+ void paintAssets();
4089
+ } });
4090
+ const n = members.length;
4091
+ const card = { key: `group:${group.id}`, kind: 'group', name: group.label, sub: uiSaid(n === 1 ? 'set-acc-asset-1' : 'set-acc-asset-n', n === 1 ? '1 asset' : '{n} assets', { n }) };
4092
+ detail.append(KIT.AssetPanel(card, { back: { onBack: () => { if (!nav.close('asset')) back(); } }, parts, foot: [rename, remove], cls: 'as-group-panel' }));
4094
4093
  }
4095
4094
  const chapter = (title, ...extra) => { const head = el('div', 'as-chapter'); head.append(el('h3', '', title), ...extra); return head; };
4096
4095
  const boxSub = (box) => (!box ? '' : box.failure ? box.failure : uiSaid(box.messages === 1 ? 'set-as-message' : 'set-as-messages', box.messages === 1 ? '1 message' : '{n} messages', { n: box.messages }));
@@ -4891,12 +4890,8 @@
4891
4890
  let assetsBy = '';
4892
4891
  try { const kept = localStorage.getItem('clavarium-assets-by'); if (kept === 'group' || kept === 'type') assetsBy = kept; } catch { /* a private window */ }
4893
4892
  const keepAssetsBy = () => { try { localStorage.setItem('clavarium-assets-by', assetsBy); } catch { /* as above */ } };
4894
- /* The "+ New group" inline form being shown in the by-group view. */
4893
+ /* The "+ New group" inline form being shown above the list. */
4895
4894
  let groupAdding = false;
4896
- /* Conversations shown in a group's "give" settings although none of its
4897
- assets is given to them yet (the owner just added them to set levels).
4898
- Cleared when the open group changes. */
4899
- const groupGiveShow = new Set();
4900
4895
  /* A binder opened in Assets: its screen, the card of the address (a reload); a card, a step (binderSteps). */
4901
4896
  let assetsView = null; let assetsAt = null;
4902
4897
  const assetsSteps = binderSteps(() => assetsView, { address: () => remember() });
@@ -5202,7 +5197,6 @@
5202
5197
  const copyBox = (chosen.kind === 'mailbox' || chosen.kind === 'folder') && !t;
5203
5198
  /* WHAT IT IS. */
5204
5199
  if (chosen.kind === 'mailbox' && !t) parts.push(el('p', chosen.box?.failure ? 'error' : 'hint', boxSub(chosen.box)));
5205
- if (inMenu && groups) parts.push(groupPicker(chosen, groups));
5206
5200
  /* ACTIONS TOO (02-11): a mailbox copied as files is read only; the same mailbox, connected live, can
5207
5201
  send, reply, move, flag -- within the rights given. Made here with its address and password (they
5208
5202
  stay on the server); only its sending server is asked, found from the address when known. */
@@ -5422,14 +5416,14 @@
5422
5416
  foot.push(there);
5423
5417
  }
5424
5418
  /* Its right said once: by its own row's picker when it has one (« Lire » pressed), else by the head's badge. */
5425
- const card = { ...cardOf(chosen), holders: undefined, right: inMenu || model?.here ? undefined : chosen.right, chips: chipsOf(chosen) };
5419
+ const card = { ...cardOf(chosen), holders: undefined, right: inMenu || model?.here ? undefined : chosen.right, chips: [inMenu && groups && !own ? { node: groupChip(chosen, groups) } : null, ...chipsOf(chosen)] };
5426
5420
  /* A later paint began while this one read: it draws, not this one. */
5427
5421
  if (stale()) return;
5428
5422
  detail.append(KIT.AssetPanel(card, { back: back ? { onBack: () => { if (!nav.close('asset')) back(); } } : null, parts, foot }));
5429
5423
  if (gitHolder) await gitDetail(gitHolder, chosen.name, { place: inMenu, others: gits.length > 1 });
5430
5424
  }
5431
5425
  const ASSET_WORD = { folder: 'Folder', git: 'Repository', mailbox: 'Mailbox', agenda: 'Calendar', contacts: 'Contacts', secret: 'Secret', list: LISTS_WORD.one, acces: 'Access' };
5432
- /* Under its name: its kind (its group is the picker below it, in the menu). */
5426
+ /* Under its name: its kind (its group, a chip before it, in the menu). */
5433
5427
  function chipsOf(chosen) {
5434
5428
  const kind = chosen.target ? (TETHER_CAT[chosen.target.kind] ?? 'acces') : chosen.kind;
5435
5429
  const word = uiSaid(`set-acc-kind-${kind}`, ASSET_WORD[kind] ?? kind);
@@ -5479,15 +5473,15 @@
5479
5473
  const allApprovals = Array.isArray(tether.approvals) ? tether.approvals : [];
5480
5474
  const onDemandes = !assetsAdding && !chosen && !chosenGroup && assetOpen === 'demandes:all';
5481
5475
  $('assets-as').dataset.open = chosen || chosenGroup || assetsAdding || onDemandes ? 'detail' : 'list';
5482
- const choose = (key) => { placeAssetStep(); if (key !== assetOpen) { groupGiveShow.clear(); clearAssetDetail(); } assetOpen = key; assetsAdding = ''; remoteEditing = false; remember(); void paintAssets(); };
5476
+ const choose = (key) => { placeAssetStep(); if (key !== assetOpen) { groupSaid = null; clearAssetDetail(); } assetOpen = key; assetsAdding = ''; remoteEditing = false; remember(); void paintAssets(); };
5483
5477
  /* THE ONE LIST (kit AssetList), scope all: groups by default once one exists; who has each on its right. */
5484
5478
  const model = KIT.assetListModel({ items: items.map(cardOf), groups: assetGroups, scope: 'all', by: assetsBy, query: assetsQuery, folded: [...groupsFolded], kinds: assetKinds() });
5485
5479
  paintAssetsView(model.view);
5486
5480
  /* Above them all, once something waits: the owner reaches every call from here, not only by opening the
5487
- right connexion. Then, in the view by group, "+ New group". */
5481
+ right connexion. Then "+ New group", in either view. */
5488
5482
  const pinned = [];
5489
5483
  if (allApprovals.length && !assetsQuery) pinned.push(pinnedCard({ key: 'demandes:all', kind: 'demandes', name: 'Demandes', sub: uiSaid('set-as-waiting', '{n} waiting for you', { n: allApprovals.length }), count: allApprovals.length }, { chosen: onDemandes ? 'demandes:all' : '', onOpen: choose, extra: 'demande-entry' }));
5490
- if (model.view === 'group') pinned.push(newGroupBar());
5484
+ if (!assetsQuery || groupAdding) pinned.push(newGroupBar());
5491
5485
  paintAssetList($('assets-rows'), model, { chosen: chosen?.key ?? (chosenGroup ? `group:${chosenGroup.id}` : onDemandes ? 'demandes:all' : ''), onOpen: choose, pinned,
5492
5486
  onFold: (id) => { if (groupsFolded.has(id)) groupsFolded.delete(id); else groupsFolded.add(id); keepGroupsFolded(); void paintAssets(); },
5493
5487
  onGroup: (id) => choose(`group:${id}`), noMatch: uiSaid('set-as-no-match', 'Nothing by that name.') });
@@ -5504,7 +5498,7 @@
5504
5498
  if (kind) focusFirst(detail);
5505
5499
  return;
5506
5500
  }
5507
- if (chosenGroup) { groupDetail(detail, chosenGroup, items, back); return; }
5501
+ if (chosenGroup) { groupPanel(detail, chosenGroup, items, { back, choose }); return; }
5508
5502
  if (onDemandes) {
5509
5503
  const body = [el('p', 'hint', 'Each is a Tether call its agent asked you to allow. Accept, and it runs; refuse, and it is told so.'),
5510
5504
  allApprovals.length ? demandesList(allApprovals, { withWho: true, after: () => paintAssets() }) : el('p', 'hint', 'Nothing is waiting now.')];
package/web/kit.js CHANGED
@@ -30,6 +30,8 @@
30
30
  * AccessList WHO HAS IT (06-11, assets step 3): a row per conversation or device, its RightsPicker; seen from one
31
31
  * conversation, its row first, then "Also with"; accessModel places the rows.
32
32
  * AssetPanel ONE ASSET OPENED, the same in the Assets menu and a conversation's tab: head, parts, foot (AssetDelete).
33
+ * A group of assets opens in it too (assets step 5): groupLevels, groupLevel, groupFanOut give a level
34
+ * to every member that takes it (skippedSaid: the others, said once); GroupChip, an asset's group ▾.
33
35
  * AssetPicker GIVE… (07-11, assets step 4): the one list of what a conversation has not yet, one chosen, its right,
34
36
  * then « Give »; a new asset below it (NewAsset). assetPickModel places the rows.
35
37
  * RIGHTS THE ONE TABLE of what an asset can be given at, by its kind and by who gets it (a
@@ -71,10 +73,12 @@
71
73
 
72
74
  /* A chip: { text, cls?, tone?: 'need' | 'note', icon?: 'room' | 'box', title? }. Empty: nothing. */
73
75
  function Chips(list = []) {
74
- const shown = list.filter((c) => c && c.text !== undefined && c.text !== '');
76
+ const shown = list.filter((c) => c && (c.node || (c.text !== undefined && c.text !== '')));
75
77
  if (!shown.length) return null;
76
78
  const box = node('span', 'kit-chips');
77
79
  for (const c of shown) {
80
+ /* A chip that is a control of the page's (an asset's group ▾): placed as it is, a chip by its look. */
81
+ if (c.node) { addClass(c.node, 'kit-chip'); box.append(c.node); continue; }
78
82
  const chip = node('small', `kit-chip${c.cls ? ` ${c.cls}` : ''}`);
79
83
  if (c.tone) chip.dataset.tone = c.tone;
80
84
  if (c.icon) { const i = node('span', `kit-chip-ic kit-ic-${c.icon}`); i.setAttribute('aria-hidden', 'true'); chip.append(i); }
@@ -429,19 +433,24 @@
429
433
 
430
434
  /* AN ASSET'S SIGN, by its kind: the icon set's name (Lucide, icons.js), or '' for one drawn here (a binder).
431
435
  A live link (a connexion) takes the sign of what it links (a mailbox, a calendar); `acces`, any other. */
432
- const ASSET_ICONS = { folder: 'folder', git: 'git-branch', mailbox: 'mail', agenda: 'calendar', contacts: 'user', secret: 'key-round', list: '', acces: 'link', give: 'plus', send: 'arrow-up', demandes: 'arrow-up', box: 'mic', device: 'smartphone', conversation: 'message-square' };
436
+ const ASSET_ICONS = { folder: 'folder', git: 'git-branch', mailbox: 'mail', agenda: 'calendar', contacts: 'user', secret: 'key-round', list: '', group: '', acces: 'link', give: 'plus', send: 'arrow-up', demandes: 'arrow-up', box: 'mic', device: 'smartphone', conversation: 'message-square' };
433
437
  const ASSET_KINDS = Object.keys(ASSET_ICONS);
434
- /* A binder: the set has no such sign, so it is drawn here, in its style (was app.js listIcon). */
435
- function listSign() {
438
+ /* A binder, a group of assets: the set has no such sign, so each is drawn here, in its style (was app.js listIcon;
439
+ a group, the layers of the Assets menu and the map). */
440
+ const OWN_SIGNS = {
441
+ list: ['m3 17 2 2 4-4', 'm3 7 2 2 4-4', 'M13 6h8', 'M13 12h8', 'M13 18h8'],
442
+ group: ['m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z', 'm22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65', 'm22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65'],
443
+ };
444
+ function ownSign(kind) {
436
445
  const svg = svgNode('svg'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true');
437
- for (const d of ['m3 17 2 2 4-4', 'm3 7 2 2 4-4', 'M13 6h8', 'M13 12h8', 'M13 18h8']) { const p = svgNode('path'); p.setAttribute('d', d); svg.append(p); }
446
+ for (const d of OWN_SIGNS[kind] ?? OWN_SIGNS.list) { const p = svgNode('path'); p.setAttribute('d', d); svg.append(p); }
438
447
  return svg;
439
448
  }
440
449
  function AssetIcon({ kind = 'folder', cls = '' } = {}) {
441
450
  const k = kind in ASSET_ICONS ? kind : 'folder';
442
451
  const box = node('span', `kit-asset-ic${cls ? ` ${cls}` : ''}`); box.dataset.kind = k; box.setAttribute('aria-hidden', 'true');
443
452
  const name = ASSET_ICONS[k];
444
- const svg = name ? window.clavariumIcons?.icon?.(name) : listSign();
453
+ const svg = name ? window.clavariumIcons?.icon?.(name) : ownSign(k);
445
454
  if (svg) box.append(svg);
446
455
  return box;
447
456
  }
@@ -534,8 +543,9 @@
534
543
  to choose (a kind not given to that holder): null. */
535
544
  /* A CALENDAR, CONTACTS: given live, Read is Watch and Write is Propose (what Attach made of them) -- said on the picker. */
536
545
  const RIGHT_SAME = { agenda: { watch: 'read', propose: 'write' }, contacts: { watch: 'read', propose: 'write' } };
537
- function RightsPicker({ key = '', kind = '', holder = 'conversation', value, level, label = '', cls = '', onChange = () => {} } = {}) {
538
- const levels = rightsFor(key, holder);
546
+ function RightsPicker({ key = '', kind = '', holder = 'conversation', value, level, levels: given = null, label = '', cls = '', onChange = () => {} } = {}) {
547
+ /* `levels`: a group's (the levels its members take, groupLevels); else the kind's, from the table. */
548
+ const levels = given ?? rightsFor(key, holder);
539
549
  if (!levels.length) return null;
540
550
  const now = level ?? rightOf(key, value, holder);
541
551
  const options = levels.map((l) => ({ value: l, label: wordOf(key, l, 'pick') }));
@@ -714,8 +724,8 @@
714
724
  }
715
725
 
716
726
  /* The list drawn from its model. A row is an AssetCard (`rowCls` added: the page's handle on it); `pinned`,
717
- rows above every section (what waits: To send, Demandes). `onOpen(key)`; a group's head folds it
718
- (`onFold(id)`), its ⚙ opens it (`onGroup(id)`). `empty`: said when there is nothing at all; `noMatch`,
727
+ rows above every section (what waits: To send, Demandes). `onOpen(key)`; a group's row (05-11, assets step 5):
728
+ its ▾ folds it (`onFold(id)`), its name and count open it in the asset panel (`onGroup(id)`), who has it on its right. `empty`: said when there is nothing at all; `noMatch`,
719
729
  when a search finds nothing. `after(a)`: a node drawn under a row (Give…: the chosen one's right). */
720
730
  function AssetList(model, { chosen = '', onOpen = () => {}, onFold = () => {}, onGroup = () => {}, rowCls = '', pinned = [], empty = '', noMatch = '', after = null } = {}) {
721
731
  const box = node('div', 'kit-aslist'); box.dataset.view = model.view;
@@ -733,18 +743,18 @@
733
743
  if (s.group) {
734
744
  if (s.folded) head.dataset.folded = 'true';
735
745
  const toggle = node('button', 'kit-aslist-fold'); toggle.type = 'button';
736
- if (`group:${s.group}` === chosen) toggle.setAttribute('aria-current', 'true');
737
746
  toggle.setAttribute('aria-expanded', String(!s.folded));
747
+ toggle.setAttribute('aria-label', `${s.folded ? say('set-as-unfold', 'Unfold') : say('set-as-fold', 'Fold')} ${s.title}`);
738
748
  toggle.title = s.folded ? say('set-as-unfold', 'Unfold') : say('set-as-fold', 'Fold');
739
- toggle.append(node('span', 'kit-aslist-chev', s.folded ? '▸' : '▾'), node('h3', '', s.title), node('span', 'kit-aslist-n', String(s.items.length)));
749
+ toggle.append(node('span', 'kit-aslist-chev', s.folded ? '▸' : '▾'));
740
750
  toggle.addEventListener('click', () => onFold(s.group));
741
- head.append(toggle);
751
+ const open = node('button', 'kit-aslist-open'); open.type = 'button'; open.dataset.group = s.group;
752
+ if (`group:${s.group}` === chosen) open.setAttribute('aria-current', 'true');
753
+ open.title = say('set-as-group-gear', 'Give, rename, remove');
754
+ open.append(node('h3', '', s.title), node('span', 'kit-aslist-n', String(s.items.length)));
755
+ open.addEventListener('click', () => onGroup(s.group));
756
+ head.append(toggle, open);
742
757
  if (s.holders?.length) head.append(Holders(s.holders));
743
- const gear = node('button', 'kit-aslist-gear', '⚙'); gear.type = 'button';
744
- gear.setAttribute('aria-label', say('set-as-group-open', 'Settings for {name}').replace('{name}', s.title));
745
- gear.title = say('set-as-group-gear', 'Give, rename, remove');
746
- gear.addEventListener('click', (e) => { e?.stopPropagation?.(); onGroup(s.group); });
747
- head.append(gear);
748
758
  } else head.append(node('h3', '', s.title));
749
759
  sec.append(head);
750
760
  if (!s.folded) {
@@ -818,16 +828,19 @@
818
828
 
819
829
  accessModel: the rows placed, no page needed (tested alone).
820
830
  key the table's key (rightsKey) from 'conversation:x' when seen from it ('' : the Assets menu)
821
- holders [{ id: 'conversation:x' | 'device:y', name, sub?, value | level, ready?: false, hue? }]
831
+ holders [{ id: 'conversation:x' | 'device:y', name, sub?, value | level, ready?: false, hue?, parts?: [{ name, key, level }] }]
822
832
  -> { here, given: [rows], others: [rows], count, said: '' | 'nobody' | 'everyone' | 'noready' } -- a row with its
823
833
  `holder` ('conversation' | 'device') and its `level`; a device is never offered (it is shared from its own sheet). */
824
834
  const holderOf = (id) => (String(id ?? '').startsWith('device:') ? 'device' : 'conversation');
825
- function accessModel({ key = '', holders = [], from = '' } = {}) {
835
+ /* `levels`: a group's, by holder ({ conversation: [...], device: [...] }, groupLevels); its row's level may be 'some'
836
+ (part of its members, or not all at one level: no level pressed). */
837
+ function accessModel({ key = '', holders = [], from = '', levels = null } = {}) {
838
+ const levelsOf = (holder) => levels?.[holder] ?? rightsFor(key, holder);
826
839
  const rows = (holders ?? []).filter((h) => h && h.id).map((h) => {
827
840
  const holder = holderOf(h.id);
828
841
  const level = h.level ?? rightOf(key, h.value, holder);
829
- return { ...h, holder, level: level in RIGHT_LEVELS ? level : 'none' };
830
- }).filter((r) => rightsFor(key, r.holder).length > 0);
842
+ return { ...h, holder, level: level in RIGHT_LEVELS || (levels && level === 'some') ? level : 'none' };
843
+ }).filter((r) => levelsOf(r.holder).length > 0);
831
844
  const here = from ? rows.find((r) => r.id === from) ?? null : null;
832
845
  const rest = rows.filter((r) => r !== here);
833
846
  const given = [...rest.filter((r) => r.holder === 'conversation' && r.level !== 'none'), ...rest.filter((r) => r.holder === 'device' && r.level !== 'none')];
@@ -841,7 +854,7 @@
841
854
  /* The list drawn from its model. `onChange(row, level, stored)` sets one right (the picker waits on it); `kind`, the
842
855
  asset's own kind (a calendar's levels say what they mean); `actions`, more gestures under it (Share with a device…).
843
856
  Its old handles kept for the pages' tests: .as-chapter .as-add, .as-users, .as-picker, .given-row[data-conversation]. */
844
- function AccessList(model, { key = '', kind = '', name = '', onChange = () => {}, actions = [] } = {}) {
857
+ function AccessList(model, { key = '', kind = '', name = '', levels = null, onChange = () => {}, actions = [] } = {}) {
845
858
  const box = node('section', 'kit-access');
846
859
  const head = node('div', 'as-chapter kit-access-head');
847
860
  head.append(node('h3', '', say('set-acc-title', 'Who has it')));
@@ -861,9 +874,15 @@
861
874
  const nm = node('span', 'kit-access-name', r.name);
862
875
  const sub = r.sub ?? (r.holder === 'device' ? say('set-acc-device', 'Device') : '');
863
876
  if (sub) nm.append(node('small', '', sub));
877
+ /* A group's row given in part: what it has of it, each member with its right (nothing hidden behind « 2 of 3 »). */
878
+ if (r.parts?.length) {
879
+ const has = node('span', 'kit-access-parts');
880
+ for (const p of r.parts) { const it = node('span', 'kit-access-part'); it.append(node('span', '', p.name), RightsBadge({ key: p.key, level: p.level })); has.append(it); }
881
+ nm.append(has);
882
+ }
864
883
  who.append(nm);
865
884
  line.append(who);
866
- const pick = RightsPicker({ key, kind, holder: r.holder, level: r.level, label: say('set-acc-what', 'What {who} may do with {name}').replace('{who}', r.name).replace('{name}', name),
885
+ const pick = RightsPicker({ key, kind, holder: r.holder, level: r.level, levels: levels?.[r.holder] ?? null, label: say('set-acc-what', 'What {who} may do with {name}').replace('{who}', r.name).replace('{name}', name),
867
886
  onChange: (level, stored) => onChange(r, level, stored) });
868
887
  if (pick) line.append(pick);
869
888
  return line;
@@ -887,6 +906,107 @@
887
906
  return box;
888
907
  }
889
908
 
909
+ /* A GROUP OF ASSETS (05-11, assets step 5): only a label its members carry (server groups.mjs); a right stays stored
910
+ asset by asset. « Give the group » is a shortcut: the level chosen for a conversation or a device is given to
911
+ every member that takes it, « None » takes it from every member; a member whose kind cannot take it is skipped,
912
+ and said once. A calendar's, contacts' Read is Watch, Write is Propose (RIGHT_SAME, as their own picker says).
913
+ Members: the page's items (rightsKey reads them); `current`: { [member key]: level } for one holder. */
914
+ const sameOf = (m) => (rightsKey(m) === 'connexion' ? RIGHT_SAME[m.kind] ?? null : null);
915
+ /* What a member gets for a group's level: its own level, or what it means for its kind; null: it cannot take it. */
916
+ function memberLevel(m, level, holder = 'conversation') {
917
+ const own = rightsFor(rightsKey(m), holder);
918
+ if (!own.length) return null;
919
+ if (own.includes(level)) return level;
920
+ const same = sameOf(m);
921
+ const as = same ? Object.keys(same).find((k) => same[k] === level) : null;
922
+ return as && own.includes(as) ? as : null;
923
+ }
924
+ /* The levels a group offers a holder: every level one of its members takes, in the table's order, « None » first.
925
+ A calendar's Watch, Propose are not offered again beside Read, Write (the same for it). */
926
+ function groupLevels(members = [], holder = 'conversation') {
927
+ const own = new Set((members ?? []).flatMap((m) => rightsFor(rightsKey(m), holder)));
928
+ const plain = new Set((members ?? []).filter((m) => !sameOf(m)).flatMap((m) => rightsFor(rightsKey(m), holder)));
929
+ const said = (l) => (members ?? []).some((m) => sameOf(m)?.[l] && plain.has(sameOf(m)[l]));
930
+ return own.size ? Object.keys(RIGHT_LEVELS).filter((l) => own.has(l) && (plain.has(l) || !said(l))) : [];
931
+ }
932
+ /* A holder's level of the group: the one all its members have (a calendar's Watch read as Read when the group offers
933
+ Read), 'none' when none has any, else 'some'; `has` of `of` its members that it can take have something. */
934
+ function groupLevel(members = [], current = {}, holder = 'conversation') {
935
+ const offered = groupLevels(members, holder);
936
+ const can = (members ?? []).filter((m) => rightsFor(rightsKey(m), holder).length);
937
+ const lv = can.map((m) => { const l = current[m.key] ?? 'none'; const as = sameOf(m)?.[l]; return as && offered.includes(as) ? as : l; });
938
+ const has = lv.filter((l) => l !== 'none').length;
939
+ const level = !has ? 'none' : has === can.length && new Set(lv).size === 1 ? lv[0] : 'some';
940
+ return { level, has, of: can.length };
941
+ }
942
+ /* THE FAN-OUT: one level for one holder -> each member's own grant (`give`: { m, key, level, stored }, only those it
943
+ changes), and those skipped (`skip`, by kind: { kind, n, levels }: what that kind takes). */
944
+ function groupFanOut({ members = [], level = 'none', holder = 'conversation', current = {} } = {}) {
945
+ const give = []; const skipped = [];
946
+ for (const m of members ?? []) {
947
+ const key = rightsKey(m);
948
+ const l = level === 'none' ? (rightsFor(key, holder).length ? 'none' : null) : memberLevel(m, level, holder);
949
+ if (!l) { if (level !== 'none') skipped.push(m); continue; }
950
+ if ((current[m.key] ?? 'none') === l) continue;
951
+ give.push({ m, key: m.key, level: l, stored: storedOf(key, l, holder) });
952
+ }
953
+ const skip = [];
954
+ for (const m of skipped) {
955
+ const k = m.kind ?? rightsKey(m);
956
+ const was = skip.find((x) => x.kind === k);
957
+ if (was) was.n++; else skip.push({ kind: k, n: 1, levels: rightsFor(rightsKey(m), holder).filter((l) => l !== 'none') });
958
+ }
959
+ return { give, skip };
960
+ }
961
+ /* Said once, under the group's « Who has it »: « 2 skipped: Secret, Use only ». */
962
+ const KIND_WORDS = { folder: 'Folder', git: 'Repository', mailbox: 'Mailbox', agenda: 'Calendar', contacts: 'Contacts', secret: 'Secret', list: 'Binder', acces: 'Access' };
963
+ function skippedSaid(skip = []) {
964
+ const n = skip.reduce((a, x) => a + x.n, 0);
965
+ if (!n) return '';
966
+ const why = skip.map((x) => {
967
+ const kind = say(`set-acc-kind-${x.kind}`, KIND_WORDS[x.kind] ?? x.kind);
968
+ return x.levels.length ? say('set-group-only', '{kind}, {levels} only').replace('{kind}', kind).replace('{levels}', x.levels.map((l) => rightWord(l, 'pick')).join(' / '))
969
+ : say('set-group-no-device', '{kind}, not for a device').replace('{kind}', kind);
970
+ }).join('; ');
971
+ return say(n === 1 ? 'set-group-skipped-1' : 'set-group-skipped-n', n === 1 ? '1 skipped: {why}' : '{n} skipped: {why}').replace('{n}', String(n)).replace('{why}', why);
972
+ }
973
+
974
+ /* AN ASSET'S GROUP, as a chip on its head (« Home ▾ »): no group, a group, or « New group… » -- made in place, then
975
+ the asset placed in it. `onPlace(id | null)`; `onMake(label)` (a promise; false or a throw: the name stays to fix). */
976
+ function GroupChip({ groups = [], value = '', name = '', onPlace = () => {}, onMake = () => {} } = {}) {
977
+ const box = node('span', 'kit-groupchip');
978
+ const sel = node('select', 'kit-groupchip-sel');
979
+ sel.setAttribute('aria-label', say('set-group-of', 'Group of {name}').replace('{name}', name));
980
+ const opt = (v, text) => { const o = node('option', '', text); o.value = v; sel.append(o); return o; };
981
+ opt('', say('set-as-no-group', 'No group'));
982
+ for (const g of groups ?? []) opt(g.id, g.label);
983
+ opt('+new', say('set-group-new-dots', 'New group…'));
984
+ const has = (groups ?? []).some((g) => g.id === value);
985
+ sel.value = has ? value : '';
986
+ for (const o of sel.children) if (o.value === sel.value) o.selected = true;
987
+ const form = node('form', 'kit-groupchip-form'); form.hidden = true; form.autocomplete = 'off';
988
+ const input = node('input'); input.maxLength = 40; input.placeholder = say('set-group-name', 'Group name'); input.setAttribute('aria-label', input.placeholder);
989
+ const add = node('button', 'primary', say('set-group-add', 'Add')); add.type = 'submit';
990
+ const cancel = node('button', '', say('set-group-cancel', 'Cancel')); cancel.type = 'button';
991
+ form.append(input, add, cancel);
992
+ const back = () => { form.hidden = true; sel.hidden = false; sel.value = has ? value : ''; };
993
+ cancel.addEventListener('click', back);
994
+ sel.addEventListener('change', async () => {
995
+ if (sel.value === '+new') { sel.hidden = true; form.hidden = false; input.focus?.(); return; }
996
+ sel.disabled = true;
997
+ try { await onPlace(sel.value || null); } finally { sel.disabled = false; }
998
+ });
999
+ form.addEventListener('submit', async (e) => {
1000
+ e?.preventDefault?.();
1001
+ const label = String(input.value ?? '').trim();
1002
+ if (!label) { input.focus?.(); return; }
1003
+ add.disabled = true;
1004
+ try { if ((await onMake(label)) === false) add.disabled = false; } catch { add.disabled = false; }
1005
+ });
1006
+ box.append(sel, form);
1007
+ return box;
1008
+ }
1009
+
890
1010
  /* ONE ASSET OPENED (06-11, assets step 3): the same panel in the Assets menu and in a conversation's tab. Its head (an
891
1011
  AssetCard head: sign, name, line, chips) under "‹ Assets" (a phone's way back), then its parts in order -- what it is,
892
1012
  who has it (AccessList), what is in it (files, branches, cards) -- then its foot (rename, delete…).
@@ -944,6 +1064,7 @@
944
1064
  window.clavariumKit = {
945
1065
  collectModel,
946
1066
  AssetIcon, AssetCard, Holders, MemberStack, memberStackModel, roomPatch, RightsBadge, RightsPicker, AssetList, assetListModel, AccessList, accessModel, AssetPanel, AssetDelete, AssetPicker, assetPickModel, RIGHT_SAME,
1067
+ memberLevel, groupLevels, groupLevel, groupFanOut, skippedSaid, GroupChip,
947
1068
  ASSET_ICONS, ASSET_KINDS, RIGHTS, RIGHT_LEVELS, MCP_WORDS, rightsKey, rightsFor, rightOf, storedOf, lightest, rightWord,
948
1069
  shareSlices, ShareDonut, ShareKey,
949
1070
  STATES, STATE_WORDS, stateOf, hueOf, initialOf, IDLE_MS, idleFor, idleWords, tidyList,
package/web/style.css CHANGED
@@ -1168,37 +1168,29 @@ header .ic, #folders .ic { width: 18px; height: 18px; fill: none; stroke: curren
1168
1168
  .as-view { display: flex; margin-bottom: 4px; }
1169
1169
  .as-view .kit-seg { width: 100%; }
1170
1170
  .as-view .kit-seg > button { flex: 1 1 0; font-size: .8125rem; padding: 7px 8px; }
1171
- /* The by-group view: make-a-group bar, its inline editor. */
1171
+ /* "+ New group" above the list (either view), its name in place; a group opened (assets step 5). */
1172
1172
  .as-group-new { display: flex; align-items: center; gap: 6px; margin: 2px 0 4px; }
1173
- .as-group-new .hint { color: var(--ink2); }
1173
+ .as-group-add { display: inline-flex; align-items: center; gap: 8px; background: transparent; border: 0; padding: 0; min-height: 0 !important; font: inherit; }
1174
+ .as-group-add .as-add { display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--line); }
1175
+ .as-group-add:hover .as-add { border-color: var(--brass); }
1176
+ .as-group-add .hint { color: var(--ink2); }
1174
1177
  .as-group-members { display: flex; flex-direction: column; gap: 2px; }
1175
- /* Give the group: a block per conversation, a row per asset at its own level. */
1176
- .as-give { display: flex; flex-direction: column; gap: 12px; }
1177
- .as-give-conv { border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; }
1178
- .as-give-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
1179
- .as-give-acts { display: flex; gap: 6px; }
1180
- .as-mini { font-size: .75rem; padding: 3px 8px; border-radius: 7px; background: transparent; color: var(--ink2); }
1181
- .as-mini:hover { border-color: var(--line); color: var(--ink); }
1182
- .as-give-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 4px 0; }
1183
- .as-give-name { display: flex; align-items: center; gap: 8px; min-width: 0; }
1184
- .as-give-name > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1185
- .as-give-row .levels button { padding: 4px 9px; font-size: .75rem; }
1186
- .as-add-conv { align-self: flex-start; }
1187
- .as-pick { display: flex; flex-wrap: wrap; gap: 6px; }
1188
- .as-pick-conv { font-size: .8125rem; padding: 5px 10px; border-radius: 8px; }
1189
- .as-group-rename { display: flex; gap: 8px; align-items: center; }
1190
- .as-group-rename input { flex: 1 1 8rem; min-width: 0; padding: 7px 10px; border-radius: 8px; }
1191
- .as-group-remove { margin-top: 6px; }
1192
- .as-remove-link { background: transparent; border: 0; color: var(--danger); text-decoration: underline; padding: 2px 0; font: inherit; font-size: .8125rem; }
1193
- .as-group-remove .hint { margin: 2px 0 0; }
1178
+ .as-group-note, .as-group-skipped { margin: 4px 0 0; font-size: .8125rem; }
1179
+ .as-group-skipped { color: var(--brass-ink); }
1180
+ .as-group-rename { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; flex: 1 1 100%; }
1181
+ .as-group-rename input { flex: 1 1 8rem; min-width: 0; padding: 6px 9px; border-radius: 8px; }
1194
1182
  .as-group-form { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; width: 100%; }
1195
1183
  .as-group-form input { flex: 1 1 8rem; min-width: 0; padding: 6px 9px; border-radius: 8px; }
1196
1184
  .as-group-form button { font-size: .8125rem; }
1197
1185
  .as-danger, button.as-danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, transparent); }
1198
- /* The group selector in an asset's detail. */
1199
- .as-group-of { display: flex; align-items: center; gap: 10px; }
1200
- .as-group-of-label { font-size: .6875rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ink2); }
1201
- .as-group-of select { padding: 6px 9px; border-radius: 8px; }
1186
+ /* An asset's group, a chip on its head (kit GroupChip): « Home ▾ », or « New group… » made in place. */
1187
+ .kit-groupchip.kit-chip { padding: 0; border: 0; overflow: visible; }
1188
+ .kit-groupchip-sel { font: inherit; font-size: .6875rem; line-height: 1.6; color: var(--ink2); background-color: transparent; border: 1px solid var(--line); border-radius: 999px; padding: 0 7px; min-height: 0 !important; max-width: 14rem; cursor: pointer; }
1189
+ .kit-groupchip-sel:hover { border-color: var(--brass); color: var(--ink); }
1190
+ .kit-groupchip-form { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px; }
1191
+ .kit-groupchip-form[hidden] { display: none; }
1192
+ .kit-groupchip-form input { width: 10rem; min-width: 0; padding: 3px 8px; border-radius: 8px; font-size: .8125rem; }
1193
+ .kit-groupchip-form button { font-size: .75rem; padding: 3px 9px; }
1202
1194
  .as-chapter { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
1203
1195
  .as-add { width: 30px; height: 30px; min-height: 0 !important; min-width: 0 !important; padding: 0; border-radius: 50%; font-size: 1rem; line-height: 1; color: var(--brass-ink); background: transparent; flex: 0 0 auto; }
1204
1196
  .as-add:hover { border-color: var(--brass); }
@@ -2819,6 +2811,7 @@ button.kit-switch[aria-checked="true"]::after { left: 18px; background: var(--ok
2819
2811
  .kit-asset-ic[data-kind="agenda"], .kit-asset-ic[data-kind="contacts"] { color: color-mix(in srgb, var(--wait) 55%, var(--danger)); }
2820
2812
  .kit-asset-ic[data-kind="secret"], .kit-asset-ic[data-kind="acces"], .kit-asset-ic[data-kind="device"], .kit-asset-ic[data-kind="conversation"] { color: var(--ink2); }
2821
2813
  .kit-asset-ic[data-kind="list"] { color: var(--ok); }
2814
+ .kit-asset-ic[data-kind="group"] { color: var(--brass-ink); }
2822
2815
  .kit-asset { display: flex; align-items: center; gap: 10px; min-width: 0; text-align: left; color: var(--ink); }
2823
2816
  .kit-asset-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
2824
2817
  .kit-asset-name { min-width: 0; margin: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -2876,14 +2869,14 @@ button.kit-switch[aria-checked="true"]::after { left: 18px; background: var(--ok
2876
2869
  .kit-aslist-sec { display: flex; flex-direction: column; gap: 1px; margin-top: 8px; }
2877
2870
  .kit-aslist-head { display: flex; align-items: center; gap: 6px; min-height: 30px; }
2878
2871
  .kit-aslist-head h3 { margin: 0; padding: 4px 6px; font-size: .6875rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ink2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2879
- .kit-aslist-fold { display: flex; align-items: center; gap: 4px; flex: 1 1 auto; min-width: 0; justify-content: flex-start; text-align: left; background: transparent; border: 1px solid transparent; border-radius: 8px; padding: 2px 4px; min-height: 0 !important; }
2872
+ /* A group's row: ▾ folds it; its name and count open it (the asset panel); who has it on its right. */
2873
+ .kit-aslist-fold { flex: none; display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; background: transparent; border: 1px solid transparent; border-radius: 8px; padding: 0; min-width: 0 !important; min-height: 0 !important; }
2880
2874
  .kit-aslist-fold:hover { border-color: var(--line); }
2881
- .kit-aslist-fold[aria-current="true"] { background: var(--panel); border-color: var(--line); }
2875
+ .kit-aslist-open { display: flex; align-items: center; gap: 4px; flex: 1 1 auto; min-width: 0; justify-content: flex-start; text-align: left; background: transparent; border: 1px solid transparent; border-radius: 8px; padding: 2px 4px; min-height: 30px !important; }
2876
+ .kit-aslist-open:hover { border-color: var(--line); }
2877
+ .kit-aslist-open[aria-current="true"] { background: var(--panel); border-color: var(--line); }
2882
2878
  .kit-aslist-chev { flex: none; width: 16px; text-align: center; color: var(--ink2); font-size: .75rem; }
2883
2879
  .kit-aslist-n { flex: none; font-size: .6875rem; font-weight: 600; padding: 0 7px; border-radius: 999px; background: var(--button); color: var(--ink); }
2884
- .kit-aslist-gear { flex: none; width: 30px; height: 30px; min-width: 0 !important; min-height: 0 !important; padding: 0; border-radius: 7px; border: 1px solid transparent; background: transparent; color: var(--ink2); font-size: 1rem; }
2885
- .kit-aslist-gear:hover { border-color: var(--line); color: var(--ink); }
2886
- @media (hover: hover) { .kit-aslist-gear { opacity: 0; } .kit-aslist-head:hover .kit-aslist-gear, .kit-aslist-gear:focus-visible, .kit-aslist-fold[aria-current="true"] ~ .kit-aslist-gear { opacity: 1; } }
2887
2880
  .kit-aslist-none, .kit-aslist-said { margin: 2px 8px 4px; font-size: .8125rem; }
2888
2881
  /* A line that asks for you keeps its shade: soon (warn), past or failing (bad). */
2889
2882
  .kit-asset-sub[data-tone="warn"] > .state-dot { background: var(--warn); }
@@ -2903,6 +2896,8 @@ button.kit-switch[aria-checked="true"]::after { left: 18px; background: var(--ok
2903
2896
  .kit-access-who > .kit-av { --kc-av: 26px; border-radius: 50%; }
2904
2897
  .kit-access-name { display: flex; flex-direction: column; min-width: 0; overflow-wrap: anywhere; }
2905
2898
  .kit-access-name > small { color: var(--ink2); font-size: .75rem; }
2899
+ .kit-access-parts { display: flex; flex-wrap: wrap; gap: 2px 10px; margin-top: 2px; font-size: .75rem; color: var(--ink2); }
2900
+ .kit-access-part { display: inline-flex; align-items: center; gap: 4px; min-width: 0; }
2906
2901
  .as-users.kit-access-here { border-color: color-mix(in srgb, var(--brass) 45%, var(--line)); background: color-mix(in srgb, var(--brass) 7%, var(--bar)); }
2907
2902
  .as-users > .kit-access-also { margin: 0; padding: 10px 16px 4px; border-top: 0; font-size: .6875rem; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--ink2); }
2908
2903
  .as-users > .kit-access-also + .given-row { border-top: 0; }