clavarium 0.2.378-next.1 → 0.2.379-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.378-next.1",
3
+ "version": "0.2.379-next.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clavarium",
9
- "version": "0.2.378-next.1",
9
+ "version": "0.2.379-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.378-next.1",
3
+ "version": "0.2.379-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.",
package/server/door.mjs CHANGED
@@ -153,7 +153,8 @@ export async function handleDoor(request, response, { devices, conversations, re
153
153
  if (request.method === 'GET' && route === '/me') {
154
154
  /* `conversations`: all this pairing may use. `shared`: what it was given for itself. */
155
155
  /* `terminal` and `chat`: the parts it was given, for the page to show only those. */
156
- const about = mine ? { name: mine.name, toolLabel: mine.toolLabel, chatName: mine.chatName, voiceMark: mine.voiceMark, phase: mine.phase, step: mine.step, progress: mine.progress, error: mine.error, work: mine.work, resources: resources.of(name), ...part } : { name: null, resources: [] };
156
+ /* Each attached one with its type: the page draws it as the asset it is (assets step 6). */
157
+ const about = mine ? { name: mine.name, toolLabel: mine.toolLabel, chatName: mine.chatName, voiceMark: mine.voiceMark, phase: mine.phase, step: mine.step, progress: mine.progress, error: mine.error, work: mine.work, resources: resources.of(name).map((r) => ({ ...r, type: resources.get(r.name)?.type ?? 'folder' })), ...part } : { name: null, resources: [] };
157
158
  return send(200, { ...about, conversations: names.filter((n) => conversations.list().some((c) => c.name === n)), shared: own });
158
159
  }
159
160
  /* The files a device was given for itself need no conversation; everything else does. */
@@ -189,5 +189,13 @@ export const SETTINGS_WORDS_FR = {
189
189
  'set-room-people': 'Personnes', 'set-room-ais-of': 'IA', 'set-room-boxes-of': 'Boîtiers', 'set-room-host-mark': 'accueille',
190
190
  'set-room-n-person': '1 personne', 'set-room-n-people': '{n} personnes', 'set-room-n-ai': '1 IA', 'set-room-n-ais': '{n} IA',
191
191
  'set-room-n-box': '1 boîtier', 'set-room-n-boxes': '{n} boîtiers',
192
+ /* Devices on the same pieces (assets step 6): /c/'s Files, a device's panel, by thing, the share basket. */
193
+ 'set-dev-given': 'Donné à cet appareil', 'set-dev-with': 'Avec {name}', 'set-dev-work': 'Là où travaille son outil',
194
+ 'set-dev-files': 'Fichiers', 'set-dev-has': 'Ce qu’il a', 'set-dev-things-1': '1 chose', 'set-dev-things-n': '{n} choses',
195
+ 'set-dev-seen': '{ago} · appairé le {date}', 'set-dev-collapse': 'Replier', 'set-dev-expand': 'Déplier',
196
+ 'set-dev-rename': 'Renommer', 'set-dev-save': 'Enregistrer', 'set-dev-name': 'Nom de cet appareil',
197
+ 'set-dev-revoke': 'Révoquer l’appareil', 'set-dev-revoke-q': '{name} perd tout, maintenant', 'set-dev-revoke-yes': 'Révoquer',
198
+ 'set-dev-more': '+ Donner plus', 'set-dev-add': '+ Ajouter', 'set-dev-added': 'Ajouté', 'set-dev-remove': 'Retirer',
199
+ 'set-dev-not-shared': 'Non partagé ({n})', 'set-dev-share': 'Partager…', 'set-dev-chat-off': 'Chat coupé : l’appareil attend',
192
200
  };
193
201
  export const settingsWords = (lang) => (String(lang ?? '').toLowerCase().split('-')[0] === 'fr' ? { ...SETTINGS_WORDS_FR } : {});
package/web/app.js CHANGED
@@ -6140,7 +6140,7 @@
6140
6140
  newAsset: () => newAssetFromMap(),
6141
6141
  autoSending: (conversation, name) => autoSending(conversation, name),
6142
6142
  partsFor: (name) => partsFor(name),
6143
- deviceCard: (id) => { const d = sharedDevices.find((x) => x.id === id); return d ? deviceCard(d, '', true) : null; },
6143
+ deviceCard: (id) => { const d = sharedDevices.find((x) => x.id === id); return d ? devicePanel(d, '', true) : null; },
6144
6144
  /* The map says when it turns to its flows by itself (a box's "Trace it"). */
6145
6145
  modeIs: (mode) => { if (mode !== mapMode) { mapMode = mode; paintTabs(); subStep(); } },
6146
6146
  /* Its box rising from the bottom on a phone: over the screen, closed by Back. */
@@ -9253,13 +9253,35 @@
9253
9253
  return box;
9254
9254
  }
9255
9255
 
9256
+ /* THE DEVICE'S FILES (/c/, assets step 6): the owner's one list (kit AssetList), scoped to this device -- by type, its
9257
+ right on each row, no « + New », no « Give… » -- and the one chosen opened in the asset panel (AssetPanel), its
9258
+ files inside it, read-only unless given to write. On a phone, the list or the one opened (« ‹ Files » back to the
9259
+ list); wider, both side by side. One thing only: opened at once. */
9260
+ let filesListing = false;
9261
+ function paintDeviceAssets(items) {
9262
+ const sheet = $('files');
9263
+ const model = KIT.assetListModel({ items, scope: `device:${pairing}`, kinds: assetKinds() });
9264
+ const chosen = items.find((i) => i.name === filesOf) ?? null;
9265
+ sheet.dataset.open = chosen && (!filesListing || items.length <= 1) ? 'detail' : 'list';
9266
+ sheet.dataset.many = String(items.length > 1);
9267
+ paintAssetList($('files-assets'), model, { chosen: chosen?.key ?? '',
9268
+ onOpen: (key) => { const i = items.find((x) => x.key === key); if (!i) return; filesListing = false; if (filesOf !== i.name) { filesOf = i.name; filesAt = ''; } void paintFiles(); } });
9269
+ const holder = $('files-panel');
9270
+ if (!chosen) { holder.replaceChildren(); holder.append($('files-body')); $('files-body').hidden = true; return; }
9271
+ $('files-body').hidden = false;
9272
+ const panel = KIT.AssetPanel(chosen, { back: items.length > 1 ? { label: `‹ ${uiSaid('set-dev-files', 'Files')}`, onBack: () => { filesListing = true; sheet.dataset.open = 'list'; } } : null,
9273
+ parts: [{ title: uiSaid('set-dev-files', 'Files'), body: [$('files-body')] }], cls: 'files-asset' });
9274
+ holder.replaceChildren(panel);
9275
+ }
9256
9276
  /* `keep`: an error just said stays (painted after a change that failed in part). */
9257
9277
  async function paintFiles({ keep = false } = {}) {
9258
9278
  const ownerOthers = DEVICE ? [] : (await (await fetch(`${API}/resources`)).json().catch(() => [])).map((r) => ({ name: r.name, access: r.type === 'mailbox' ? 'read' : 'write', yours: true }));
9259
9279
  /* First, where the tool works -- when that is kept here; then what is attached. */
9260
9280
  const here = conversations.find((x) => x.name === current);
9261
9281
  const attachedHere = here?.resources ?? [];
9262
- const mine = noTerminal() ? deviceShared.map((s) => ({ name: s.name, access: s.access, given: true })) : [...(here?.work === 'kept' ? [{ name: 'work', access: 'write', own: true }] : []), ...attachedHere.map((r) => ({ ...r })),
9282
+ /* ON A DEVICE (/c/, assets step 6): what it sees is the kit's (deviceItems), drawn as the owner's one list. */
9283
+ const seen = DEVICE ? KIT.deviceItems({ shared: deviceShared, resources: attachedHere, work: here?.work, chatOnly: noTerminal(), conversation: shownName(here) || current }) : [];
9284
+ const mine = DEVICE ? seen.map((i) => ({ name: i.name, access: i.access, given: i.via === 'device', own: i.via === 'work' })) : [...(here?.work === 'kept' ? [{ name: 'work', access: 'write', own: true }] : []), ...attachedHere.map((r) => ({ ...r })),
9263
9285
  /* On a device: what it was given for itself, whatever the conversation. */
9264
9286
  ...deviceShared.filter((s) => !attachedHere.some((r) => r.name === s.name)).map((s) => ({ name: s.name, access: s.access, given: true })),
9265
9287
  /* For the owner: everything else that is kept here, attached to this conversation or not. */
@@ -9272,7 +9294,8 @@
9272
9294
  if (!mine.some((r) => r.name === filesOf)) { filesOf = mine[0]?.name ?? ''; filesAt = ''; }
9273
9295
  const tabs = $('files-tabs');
9274
9296
  tabs.replaceChildren();
9275
- for (const r of mine) {
9297
+ if (DEVICE) paintDeviceAssets(seen);
9298
+ for (const r of DEVICE ? [] : mine) {
9276
9299
  const b = document.createElement('button');
9277
9300
  b.type = 'button'; b.textContent = r.own ? 'work - where the tool works' : `${r.name} - ${r.access === 'write' ? 'read-write' : 'read-only'}${r.given ? ' (given to this device)' : r.yours ? ' (not attached here)' : ''}`; b.setAttribute('role', 'tab');
9278
9301
  b.setAttribute('aria-selected', r.name === filesOf ? 'true' : 'false');
@@ -10589,8 +10612,9 @@
10589
10612
 
10590
10613
  /* FILES, a sheet over the screen: on a device's page only (the owner's are in the Assets tab of a conversation). */
10591
10614
  function showFiles() { if ($('assets-detail').contains($('files'))) unembedFiles(); nav.over('files', () => { if (!$('files').classList.contains('embedded')) { $('files').hidden = true; focusTerminal(); } }); $('files').hidden = false; }
10592
- $('files-open').addEventListener('click', async () => { await paintFiles(); showFiles(); });
10593
- $('files-close').addEventListener('click', () => { if (!nav.dropOver('files')) { $('files').hidden = true; focusTerminal(); } });
10615
+ /* On a device, Files opens on its list (a phone): the one to open is chosen there. */
10616
+ $('files-open').addEventListener('click', async () => { filesListing = DEVICE; await paintFiles(); showFiles(); });
10617
+ $('files-close').addEventListener('click', () => { filesListing = false; if (!nav.dropOver('files')) { $('files').hidden = true; focusTerminal(); } });
10594
10618
 
10595
10619
  /* ---------- sign-in mailbox ---------- */
10596
10620
  /* While a conversation with a mailbox is open, the page asks every few
@@ -10666,56 +10690,35 @@
10666
10690
  $('sharing-top').hidden = which === 'manage';
10667
10691
  };
10668
10692
 
10669
- /* A two-way control: Read | Write. `onChange(rights)`. */
10670
- function levelControl(rights, onChange, label) {
10671
- const group = document.createElement('div'); group.className = 'levels'; group.setAttribute('role', 'group'); group.setAttribute('aria-label', label);
10672
- for (const [value, text] of [['r', 'Read'], ['rw', 'Write']]) {
10673
- const b = document.createElement('button'); b.type = 'button'; b.textContent = text; b.dataset.rights = value;
10674
- b.setAttribute('aria-pressed', String(value === rights));
10675
- b.addEventListener('click', () => { if (value !== rights) onChange(value); });
10676
- group.append(b);
10677
- }
10678
- return group;
10679
- }
10680
- const fixedRight = (type) => { const s = document.createElement('span'); s.className = 'hint'; s.textContent = type === 'conversation' ? 'Use - the whole terminal' : 'Read only'; return s; };
10681
- /* A CONVERSATION IS GIVEN IN TWO PARTS, ticked one by one: its terminal
10682
- ("x") and its chat ("rw": read, and write there). At least one: the
10683
- last one ticked cannot be unticked (taking it all back is "Take back").
10684
- The server holds the device to it (see server/door.mjs). */
10693
+ /* A DEVICE'S RIGHT ON ONE THING (07-11, assets step 6): the kit's RightsPicker, its device column -- a folder, a
10694
+ repository: Read, Write; a mailbox: Read; a conversation in parts: Terminal, Chat, Both. « None » takes it back
10695
+ where it is given; in the share basket it is not offered (« Remove » is). Was levelControl, partsControl, fixedRight. */
10685
10696
  const talksHere = (name) => Boolean(conversations.find((c) => c.name === name)?.talk);
10686
10697
  /* What is proposed when a conversation is added: its chat where it talks through the app, else its terminal. */
10687
10698
  const partsFor = (name) => (talksHere(name) ? 'rw' : 'x');
10688
- function partsControl(rights, onChange, name) {
10689
- const group = document.createElement('div'); group.className = 'parts'; group.setAttribute('role', 'group'); group.setAttribute('aria-label', `What of ${name} it gets`);
10690
- const has = { terminal: rights.includes('x'), chat: rights.includes('r') };
10691
- const boxes = {};
10692
- for (const [part, text] of [['terminal', 'Terminal'], ['chat', 'Chat']]) {
10693
- const label = document.createElement('label'); label.className = 'check'; label.dataset.part = part;
10694
- const box = document.createElement('input'); box.type = 'checkbox'; box.checked = has[part];
10695
- box.addEventListener('change', () => {
10696
- const next = { ...has, [part]: box.checked };
10697
- /* The last one ticked stays ticked, and says why. */
10698
- if (!next.terminal && !next.chat) { box.checked = true; need.hidden = false; return; }
10699
- const chat = next.chat ? (has.chat ? rights.replace('x', '') : 'rw') : '';
10700
- onChange(`${chat}${next.terminal ? 'x' : ''}`);
10701
- });
10702
- boxes[part] = box;
10703
- label.append(box, ` ${text}`); group.append(label);
10704
- }
10705
- /* The one left ticked cannot be unticked: marked so, and said when tried. */
10706
- for (const [part, other] of [['terminal', 'chat'], ['chat', 'terminal']]) if (has[part] && !has[other]) boxes[part].setAttribute('aria-disabled', 'true');
10707
- const need = document.createElement('small'); need.className = 'hint parts-need'; need.hidden = true; need.textContent = 'At least one. To give nothing, take it back.';
10708
- group.append(need);
10709
- /* Its chat, given while it is off: said, so that nobody wonders why the device shows nothing. */
10710
- if (has.chat && !talksHere(name)) {
10711
- const off = document.createElement('small'); off.className = 'hint parts-off'; off.textContent = 'Its chat is off: the device waits until you turn it on (General).'; off.title = 'Given, and waiting: the device sees its chat once you turn it on, in This conversation, then General.';
10712
- group.append(off);
10713
- }
10714
- return group;
10699
+ function deviceRight(type, name, rights, onChange, { who = '', none = true } = {}) {
10700
+ const levels = KIT.rightsFor(type, 'device').filter((l) => none || l !== 'none');
10701
+ return KIT.RightsPicker({ key: type, holder: 'device', value: rights, levels, label: uiSaid('set-acc-what', 'What {who} may do with {name}', { who: who || 'it', name }), onChange });
10702
+ }
10703
+ /* Its chat given while it is off: said, so that nobody wonders why the device shows nothing. */
10704
+ function chatOff(type, name, rights) {
10705
+ if (type !== 'conversation' || !String(rights ?? '').includes('r') || talksHere(name)) return null;
10706
+ const off = el('small', 'hint parts-off', uiSaid('set-dev-chat-off', 'Chat off: the device waits'));
10707
+ off.title = 'Given, and waiting: the device sees its chat once you turn it on, in This conversation, then General.';
10708
+ return off;
10709
+ }
10710
+ /* One thing given, or to give, to a device: the asset it is (an AssetCard row), its right and gestures on its right. */
10711
+ function deviceThing(asset, { sub, actions = [], cls = '' } = {}) {
10712
+ const at = asset.indexOf(':'); const type = asset.slice(0, at); const name = asset.slice(at + 1);
10713
+ const row = KIT.AssetCard({ key: asset, kind: type, name, sub: sub ?? KIND_WORD[type] ?? type }, { tag: 'div', cls: `given-row dev-thing${cls ? ` ${cls}` : ''}`, actions });
10714
+ row.dataset.asset = asset;
10715
+ return row;
10716
+ }
10717
+ /* A right set from any of these places: given at its level, or taken back (« None »). */
10718
+ async function setDeviceRight(id, asset, level, stored) {
10719
+ if (level === 'none') await post(`${API}/devices/${id}/take`, { asset }).catch(() => {});
10720
+ else await post(`${API}/devices/${id}/give`, { asset, rights: stored }).catch(() => {});
10715
10721
  }
10716
- /* The right control of a thing given: a conversation in parts, a folder or a repository to read or write. */
10717
- const rightControl = (type, name, rights, onChange, label) => (type === 'conversation' ? partsControl(rights, onChange, name)
10718
- : type === 'folder' || type === 'git' ? levelControl(rights, onChange, label) : fixedRight(type));
10719
10722
 
10720
10723
  async function loadCatalog() {
10721
10724
  const things = await (await fetch(`${API}/resources`)).json().catch(() => []);
@@ -10763,13 +10766,12 @@
10763
10766
  const box = $('share-basket'); box.replaceChildren();
10764
10767
  for (const [asset, rights] of basket) {
10765
10768
  const [type, name] = asset.split(':');
10766
- const row = document.createElement('div'); row.className = 'folder'; row.dataset.asset = asset;
10767
- const label = document.createElement('b'); label.textContent = name;
10768
- const kind = document.createElement('small'); kind.className = 'hint'; kind.textContent = ` ${KIND_WORD[type] ?? type}`; label.append(kind);
10769
- const right = rightControl(type, name, rights, (value) => { basket.set(asset, value); paintCompose(); }, `What it may do with ${name}`);
10770
- const out = document.createElement('button'); out.type = 'button'; out.textContent = 'Remove'; out.setAttribute('aria-label', `Remove ${name} from what it will get`);
10769
+ const out = el('button', '', uiSaid('set-dev-remove', 'Remove')); out.type = 'button'; out.setAttribute('aria-label', `Remove ${name} from what it will get`);
10771
10770
  out.addEventListener('click', () => { basket.delete(asset); paintCompose(); });
10772
- row.append(label, right, out); box.append(row);
10771
+ const right = deviceRight(type, name, rights, (level, stored) => { basket.set(asset, stored); paintCompose(); }, { none: false });
10772
+ const row = deviceThing(asset, { actions: [right, out], cls: 'folder' });
10773
+ const off = chatOff(type, name, rights); if (off) row.append(off);
10774
+ box.append(row);
10773
10775
  }
10774
10776
  $('share-basket-title').textContent = `It will get (${basket.size})`;
10775
10777
  $('share-basket-empty').hidden = basket.size > 0;
@@ -10787,17 +10789,14 @@
10787
10789
  const order = (c) => `${c.near ? 0 : 1}${KINDS.findIndex(([k]) => k === c.type)}${c.name}`;
10788
10790
  for (const c of [...catalog].sort((a, b2) => order(a).localeCompare(order(b2)))) {
10789
10791
  if ((shareKind && c.type !== shareKind) || (wanted && !c.name.includes(wanted))) continue;
10790
- const row = document.createElement('div'); row.className = 'folder'; row.dataset.asset = c.asset;
10791
- const label = document.createElement('b'); label.textContent = c.name;
10792
- const kind = document.createElement('small'); kind.className = 'hint'; kind.textContent = ` ${KIND_WORD[c.type]}${c.near && c.type !== 'conversation' ? ', attached here' : ''}`; label.append(kind);
10793
- const plus = document.createElement('button'); plus.type = 'button';
10794
- if (basket.has(c.asset)) { plus.textContent = 'Added'; plus.disabled = true; }
10792
+ const plus = el('button'); plus.type = 'button';
10793
+ if (basket.has(c.asset)) { plus.textContent = uiSaid('set-dev-added', 'Added'); plus.disabled = true; }
10795
10794
  else {
10796
- plus.textContent = '+ Add'; plus.setAttribute('aria-label', `Add ${c.name}`);
10795
+ plus.textContent = uiSaid('set-dev-add', '+ Add'); plus.setAttribute('aria-label', `Add ${c.name}`);
10797
10796
  /* A conversation is given whole; anything else to read -- writing is asked for, never assumed. */
10798
10797
  plus.addEventListener('click', () => { basket.set(c.asset, c.type === 'conversation' ? partsFor(c.name) : 'r'); paintCompose(); });
10799
10798
  }
10800
- row.append(label, plus); add.append(row);
10799
+ add.append(deviceThing(c.asset, { sub: `${KIND_WORD[c.type]}${c.near && c.type !== 'conversation' ? ', attached here' : ''}`, actions: [plus], cls: 'folder' }));
10801
10800
  }
10802
10801
  sayChoice();
10803
10802
  }
@@ -10848,77 +10847,67 @@
10848
10847
  const ask = document.createElement('span'); ask.className = 'callout bad confirm'; ask.setAttribute('role', 'alertdialog'); ask.append(keep, sure);
10849
10848
  button.replaceWith(ask); sure.focus();
10850
10849
  }
10851
- function grantRow(device, g) {
10850
+ /* One thing a device has, its right set in place; « None » takes it back. */
10851
+ function deviceGrantRow(device, g) {
10852
10852
  const [type, name] = g.asset.split(':');
10853
- const row = document.createElement('div'); row.className = 'given-row'; row.dataset.asset = g.asset;
10854
- const label = document.createElement('span'); label.textContent = name;
10855
- const kind = document.createElement('small'); kind.className = 'hint'; kind.textContent = ` ${KIND_WORD[type] ?? type}`; label.append(kind);
10856
- const right = rightControl(type, name, g.rights, async (value) => { await post(`${API}/devices/${device.id}/give`, { asset: g.asset, rights: value }).catch(() => {}); await repaintDevices(); }, `What ${device.label} may do with ${name}`);
10857
- const take = document.createElement('button'); take.type = 'button'; take.textContent = 'Take back';
10858
- take.addEventListener('click', () => takeBack(take, `Take ${name} back from ${device.label}`, () => post(`${API}/devices/${device.id}/take`, { asset: g.asset })));
10859
- row.append(label, right, take);
10853
+ const right = deviceRight(type, name, g.rights, async (level, stored) => { await setDeviceRight(device.id, g.asset, level, stored); await repaintDevices(); if (level === 'none') void refresh(); }, { who: device.label });
10854
+ const row = deviceThing(g.asset, { actions: [right] });
10855
+ const off = chatOff(type, name, g.rights); if (off) row.append(off);
10860
10856
  return row;
10861
10857
  }
10862
10858
  let manageBy = 'device';
10863
- const deviceShut = new Set(); /* the device cards folded on this page */
10859
+ const deviceShut = new Set(); /* the device panels folded on this page */
10864
10860
  /* After a change to a device: its list (the Devices pane) or the map, drawn again. */
10865
10861
  async function repaintDevices() {
10866
10862
  if (place === 'access' && accessTab === 'devices') { await paintManage(); return; }
10867
10863
  await readDevices(); await links?.changed();
10868
10864
  }
10869
- /* One device's card: its name (renamed here), what it has, revoke it. On
10870
- the map's card of the device too (`onMap`: never folded). */
10871
- function deviceCard(device, only, onMap = false) {
10872
- const card = document.createElement('div'); card.className = 'device card'; card.dataset.device = device.id;
10873
- const name = document.createElement('b'); name.textContent = device.label;
10874
- const rename = document.createElement('button'); rename.type = 'button'; rename.textContent = 'Rename';
10875
- rename.addEventListener('click', () => {
10876
- const field = document.createElement('input'); field.value = device.label; field.maxLength = 40; field.setAttribute('aria-label', 'Name of this device');
10877
- const save = document.createElement('button'); save.type = 'button'; save.className = 'primary'; save.textContent = 'Save';
10878
- const done = async () => { if (field.value.trim()) await post(`${API}/devices/${device.id}/rename`, { label: field.value.trim() }).catch(() => {}); await repaintDevices(); };
10879
- save.addEventListener('click', () => { void done(); });
10880
- field.addEventListener('keydown', (event) => { if (event.key === 'Enter') void done(); event.stopPropagation(); });
10881
- name.replaceWith(field); rename.replaceWith(save); focusField(field); if (document.activeElement === field) field.select();
10882
- });
10883
- const seen = document.createElement('small'); seen.textContent = `${ago(device.seenAt)} · paired ${new Date(device.createdAt).toLocaleDateString()}`;
10884
- if (!device.seenAt) seen.className = 'warn';
10885
- const revoke = document.createElement('button'); revoke.type = 'button'; revoke.textContent = 'Revoke device';
10886
- revoke.addEventListener('click', () => takeBack(revoke, `Revoke ${device.label}: it loses all ${device.grants.length} thing${device.grants.length > 1 ? 's' : ''}, now`, () => post(`${API}/devices/${device.id}/revoke`)));
10887
- /* A card that folds, as the chapters of a conversation do: open unless folded on purpose. */
10865
+ const thingsSaid = (n) => (n === 1 ? uiSaid('set-dev-things-1', '1 thing') : uiSaid('set-dev-things-n', '{n} things', { n }));
10866
+ /* ONE DEVICE OPENED (07-11, assets step 6): the asset panel -- its head (its name, how many things, when it was
10867
+ last seen; folded here), what it has (each thing with its right, « None » takes it back), then + Give more,
10868
+ Rename, Revoke device (asked in place). On the map's card of the device too (`onMap`: never folded). */
10869
+ function devicePanel(device, only, onMap = false) {
10888
10870
  const shut = !onMap && deviceShut.has(device.id);
10889
- const fold = document.createElement('button'); fold.type = 'button'; fold.className = 'card-fold'; fold.textContent = shut ? 'Expand' : 'Collapse';
10890
- fold.setAttribute('aria-expanded', String(!shut)); fold.setAttribute('aria-label', `${shut ? 'Expand' : 'Collapse'} ${device.label}`);
10871
+ const fold = el('button', 'card-fold', shut ? uiSaid('set-dev-expand', 'Expand') : uiSaid('set-dev-collapse', 'Collapse')); fold.type = 'button';
10872
+ fold.setAttribute('aria-expanded', String(!shut)); fold.setAttribute('aria-label', `${fold.textContent} ${device.label}`);
10891
10873
  fold.addEventListener('click', () => { if (shut) deviceShut.delete(device.id); else deviceShut.add(device.id); void repaintDevices(); });
10892
- const count = document.createElement('small'); count.className = 'device-count'; count.textContent = `${device.grants.length} thing${device.grants.length === 1 ? '' : 's'}`;
10893
- const sub = document.createElement('span'); sub.className = 'device-sub'; sub.append(count, ' · ', seen);
10894
- const head = document.createElement('div'); head.className = 'device-head'; head.append(name, sub, fold);
10895
- const given = document.createElement('div'); given.className = 'given';
10874
+ const seen = uiSaid('set-dev-seen', '{ago} · paired {date}', { ago: ago(device.seenAt), date: new Date(device.createdAt).toLocaleDateString() });
10875
+ const given = el('div', 'given');
10896
10876
  const mine = only ? device.grants.filter((g) => g.asset === only) : device.grants;
10897
- for (const g of mine) given.append(grantRow(device, g));
10898
- /* Several chats given while off: said once for the card, not under each. */
10877
+ for (const g of mine) given.append(deviceGrantRow(device, g));
10878
+ /* Several chats given while off: said once for the panel, not under each. */
10899
10879
  const waiting = [...given.querySelectorAll('.parts-off')];
10900
10880
  if (waiting.length > 1) {
10901
10881
  for (const w of waiting) w.remove();
10902
- const once = document.createElement('p'); once.className = 'hint parts-off'; once.title = waiting[0].title;
10903
- once.textContent = `${waiting.length} chats are off: the device waits until you turn each on (the conversation's General).`;
10882
+ const once = el('p', 'hint parts-off', `${waiting.length} chats are off: the device waits until you turn each on (the conversation's General).`); once.title = waiting[0].title;
10904
10883
  given.prepend(once);
10905
10884
  }
10906
10885
  if (only && mine.length === 0) {
10907
- const row = document.createElement('div'); row.className = 'given-row'; row.dataset.asset = only;
10908
- const label = document.createElement('span'); label.textContent = accessFor;
10909
- const none = document.createElement('small'); none.className = 'hint'; none.textContent = ' not shared'; label.append(none);
10910
- const give = document.createElement('button'); give.type = 'button'; give.className = 'primary'; give.textContent = `Give ${accessFor}`;
10886
+ const give = el('button', 'primary', `Give ${accessFor}`); give.type = 'button';
10911
10887
  give.addEventListener('click', async () => { give.disabled = true; await post(`${API}/devices/${device.id}/give`, { asset: only, rights: partsFor(accessFor) }).catch(() => {}); await repaintDevices(); void refresh(); });
10912
- row.append(label, give); given.append(row);
10888
+ given.append(deviceThing(only, { sub: 'not shared', actions: [give] }));
10913
10889
  }
10914
10890
  /* What the filter leaves out is named, never only counted. */
10915
- if (only && device.grants.length > mine.length) { const rest = document.createElement('p'); rest.className = 'hint'; rest.textContent = `It also has: ${device.grants.filter((g) => g.asset !== only).map((g) => { const [type, what] = g.asset.split(':'); return `${what} (${KIND_WORD[type] ?? type})`; }).join(', ')}.`; given.append(rest); }
10916
- const more = document.createElement('button'); more.type = 'button'; more.textContent = '+ Give more';
10891
+ if (only && device.grants.length > mine.length) given.append(el('p', 'hint', `It also has: ${device.grants.filter((g) => g.asset !== only).map((g) => { const [type, what] = g.asset.split(':'); return `${what} (${KIND_WORD[type] ?? type})`; }).join(', ')}.`));
10892
+ const more = el('button', '', uiSaid('set-dev-more', '+ Give more')); more.type = 'button';
10917
10893
  more.addEventListener('click', () => { void openCompose([], device.id); });
10918
- const acts = document.createElement('div'); acts.className = 'action-row device-acts'; acts.append(more, rename, revoke);
10919
- const body = document.createElement('div'); body.className = 'card-body'; body.hidden = shut; body.append(given, acts);
10920
- card.append(head, body);
10921
- return card;
10894
+ const rename = el('button', '', uiSaid('set-dev-rename', 'Rename')); rename.type = 'button';
10895
+ const revoke = KIT.AssetDelete({ label: uiSaid('set-dev-revoke', 'Revoke device'), lost: uiSaid('set-dev-revoke-q', '{name} loses everything, now', { name: device.label }), yes: uiSaid('set-dev-revoke-yes', 'Revoke'),
10896
+ onYes: async () => { await post(`${API}/devices/${device.id}/revoke`).catch(() => {}); await repaintDevices(); void refresh(); } });
10897
+ const panel = KIT.AssetPanel({ key: `device:${device.id}`, kind: 'device', name: device.label, sub: `${thingsSaid(device.grants.length)} · ${seen}`, tone: device.seenAt ? '' : 'warn' },
10898
+ { cls: 'device-panel', actions: onMap ? [] : [fold], parts: [{ title: uiSaid('set-dev-has', 'What it has'), body: [given] }], foot: [more, rename, revoke] });
10899
+ panel.dataset.device = device.id;
10900
+ if (shut) for (const part of panel.children) if (!part.classList.contains('as-title')) part.hidden = true;
10901
+ rename.addEventListener('click', () => {
10902
+ const name = panel.querySelector('.kit-asset-name');
10903
+ const field = document.createElement('input'); field.value = device.label; field.maxLength = 40; field.setAttribute('aria-label', uiSaid('set-dev-name', 'Name of this device'));
10904
+ const save = el('button', 'primary', uiSaid('set-dev-save', 'Save')); save.type = 'button';
10905
+ const done = async () => { if (field.value.trim()) await post(`${API}/devices/${device.id}/rename`, { label: field.value.trim() }).catch(() => {}); await repaintDevices(); };
10906
+ save.addEventListener('click', () => { void done(); });
10907
+ field.addEventListener('keydown', (event) => { if (event.key === 'Enter') void done(); event.stopPropagation(); });
10908
+ name?.replaceWith(field); rename.replaceWith(save); focusField(field); if (document.activeElement === field) field.select();
10909
+ });
10910
+ return panel;
10922
10911
  }
10923
10912
  /* ---- voice boxes (boxes.mjs): pairing codes, the flash from this browser, the list ---- */
10924
10913
  /* THE WAKE WORD THAT LEARNS (wakelearn.mjs): normal, or each wake clip kept with its label to train a better one. */
@@ -11265,34 +11254,32 @@
11265
11254
  if (manageBy === 'device' || only) {
11266
11255
  rows.hidden = false; things.hidden = true;
11267
11256
  for (const device of [...sharedDevices].sort((a, b2) => String(b2.seenAt ?? '').localeCompare(String(a.seenAt ?? '')))) {
11268
- rows.append(deviceCard(device, only));
11257
+ rows.append(devicePanel(device, only));
11269
11258
  }
11270
11259
  return;
11271
11260
  }
11272
- /* By thing: who can see this folder? What nobody has is folded away. */
11261
+ /* By thing: who can see this folder? Each thing (an AssetCard), who has it (the kit's AccessList, devices only:
11262
+ each with its right, « None » takes it back). What nobody has is folded away, each with « Share… ». */
11273
11263
  await loadCatalog();
11274
11264
  const alone = [];
11275
11265
  for (const c of catalog) {
11276
11266
  const holders = devicesWith(c.asset);
11277
11267
  if (holders.length === 0) { alone.push(c); continue; }
11278
- const block = document.createElement('details'); block.className = 'thing'; block.dataset.asset = c.asset; block.open = true;
11279
- const top = document.createElement('summary'); top.textContent = `${c.name} - ${KIND_WORD[c.type]} - ${holders.length} device${holders.length > 1 ? 's' : ''}`;
11268
+ const block = el('details', 'thing'); block.dataset.asset = c.asset; block.open = true;
11269
+ const top = el('summary');
11270
+ top.append(KIT.AssetCard({ key: c.asset, kind: c.type, name: c.name, sub: `${KIND_WORD[c.type]} · ${holders.length === 1 ? uiSaid('set-device', '1 device') : uiSaid('set-devices', '{n} devices', { n: holders.length })}` }, { tag: 'span' }));
11280
11271
  block.append(top);
11281
- for (const device of holders) {
11282
- const row = grantRow(device, device.grants.find((g) => g.asset === c.asset));
11283
- row.firstChild.textContent = device.label;
11284
- block.append(row);
11285
- }
11272
+ const model = KIT.accessModel({ key: c.type, holders: holders.map((d) => ({ id: `device:${d.id}`, name: d.label, sub: ago(d.seenAt), value: d.grants.find((g) => g.asset === c.asset).rights })) });
11273
+ block.append(KIT.AccessList(model, { key: c.type, name: c.name, title: '', give: false,
11274
+ onChange: async (row, level, stored) => { await setDeviceRight(row.id.slice('device:'.length), c.asset, level, stored); await repaintDevices(); if (level === 'none') void refresh(); } }));
11286
11275
  things.append(block);
11287
11276
  }
11288
- const rest = document.createElement('details'); rest.className = 'thing';
11289
- const top = document.createElement('summary'); top.textContent = `Not shared (${alone.length})`; rest.append(top);
11277
+ const rest = el('details', 'thing');
11278
+ rest.append(el('summary', '', uiSaid('set-dev-not-shared', 'Not shared ({n})', { n: alone.length })));
11290
11279
  for (const c of alone) {
11291
- const row = document.createElement('div'); row.className = 'given-row';
11292
- const label = document.createElement('span'); label.textContent = `${c.name} - ${KIND_WORD[c.type]}`;
11293
- const give = document.createElement('button'); give.type = 'button'; give.textContent = 'Share...';
11280
+ const give = el('button', '', uiSaid('set-dev-share', 'Share…')); give.type = 'button';
11294
11281
  give.addEventListener('click', () => { void openCompose([[c.asset, c.type === 'conversation' ? partsFor(c.name) : 'r']]); });
11295
- row.append(label, give); rest.append(row);
11282
+ rest.append(deviceThing(c.asset, { actions: [give] }));
11296
11283
  }
11297
11284
  things.append(rest);
11298
11285
  }
@@ -11316,23 +11303,15 @@
11316
11303
  await readDevices();
11317
11304
  const box = $('share-here'); box.replaceChildren();
11318
11305
  const asset = `conversation:${current}`;
11319
- for (const device of devicesWith(asset)) {
11320
- const g = device.grants.find((x) => x.asset === asset);
11321
- const row = document.createElement('div'); row.className = 'given-row'; row.dataset.device = device.id;
11322
- const label = document.createElement('span'); label.textContent = device.label;
11323
- const right = partsControl(g.rights, async (value) => { await post(`${API}/devices/${device.id}/give`, { asset, rights: value }).catch(() => {}); await paintShareHere(); }, current);
11324
- const take = document.createElement('button'); take.type = 'button'; take.textContent = 'Take back';
11325
- take.addEventListener('click', () => {
11326
- const sure = document.createElement('button'); sure.type = 'button'; sure.className = 'danger'; sure.textContent = `Take ${current} back from ${device.label}`;
11327
- const keep = document.createElement('button'); keep.type = 'button'; keep.textContent = 'Keep';
11328
- keep.addEventListener('click', () => { void paintShareHere(); });
11329
- sure.addEventListener('click', async () => { await post(`${API}/devices/${device.id}/take`, { asset }).catch(() => {}); await paintShareHere(); void refresh(); });
11330
- const ask = document.createElement('span'); ask.className = 'callout bad confirm'; ask.setAttribute('role', 'alertdialog'); ask.append(keep, sure);
11331
- take.replaceWith(ask); sure.focus();
11332
- });
11333
- row.append(label, right, take); box.append(row);
11334
- }
11335
- $('share-here-none').hidden = box.children.length > 0;
11306
+ /* The devices that have it (the kit's AccessList): each with its parts, « None » takes it back. */
11307
+ const holders = devicesWith(asset).map((d) => ({ id: `device:${d.id}`, name: d.label, sub: ago(d.seenAt), value: d.grants.find((x) => x.asset === asset).rights }));
11308
+ if (holders.length) {
11309
+ box.append(KIT.AccessList(KIT.accessModel({ key: 'conversation', holders }), { key: 'conversation', name: current, title: '', give: false,
11310
+ onChange: async (row, level, stored) => { await setDeviceRight(row.id.slice('device:'.length), asset, level, stored); await paintShareHere(); if (level === 'none') void refresh(); } }));
11311
+ const off = chatOff('conversation', current, holders.find((h) => String(h.value).includes('r'))?.value);
11312
+ if (off) box.append(off);
11313
+ }
11314
+ $('share-here-none').hidden = holders.length > 0;
11336
11315
  }
11337
11316
  /* Back from choosing or from the code: where it was opened from, as Back and Escape. */
11338
11317
  const leaveCompose = () => { void refresh(); if (!nav.close('compose')) leavePlace(); };
package/web/device.html CHANGED
@@ -62,8 +62,16 @@
62
62
  </div>
63
63
  <div id="files" class="sheet" hidden>
64
64
  <div class="panel-top"><b>Files</b><button type="button" id="files-close" aria-label="Close" title="Close">&times;</button></div>
65
- <div id="files-tabs" class="choices" role="tablist" aria-label="Attached folders"></div>
66
- <p id="files-none" class="hint" hidden>No folder is attached to this conversation. Attach one with "Folders".</p>
65
+ <div id="files-tabs" class="choices" role="tablist" aria-label="Attached folders" hidden></div>
66
+ <!-- WHAT THIS DEVICE SEES (assets step 6): the owner's one asset list, scoped to the device, read by its rights;
67
+ one opened in the same asset panel, its files inside it. No New, no Give. -->
68
+ <div id="files-split">
69
+ <div id="files-side">
70
+ <div id="files-assets"></div>
71
+ <p id="files-none" class="hint" hidden>Nothing is shared with this device.</p>
72
+ </div>
73
+ <div id="files-panel">
74
+ <div id="files-body">
67
75
  <div class="files-bar">
68
76
  <div id="files-path" class="path"></div>
69
77
  <div id="files-views" class="levels" role="group" aria-label="Show as">
@@ -100,6 +108,9 @@
100
108
  </form>
101
109
  <div id="files-rows" data-view="list"></div>
102
110
  <p id="files-empty" class="hint" hidden>This folder is empty.</p>
111
+ </div>
112
+ </div>
113
+ </div>
103
114
  <div id="files-drop" hidden><p id="files-drop-text">Drop to upload here</p></div>
104
115
  </div>
105
116
  <!-- A file, shown over whatever is on the screen; closing it leaves you where you were. -->
package/web/kit.js CHANGED
@@ -688,9 +688,12 @@
688
688
  '': groups once one exists, else types
689
689
  query a name searched (any case) folded group ids folded shut kinds [[kind, title]] in order
690
690
  -> { view, canGroup, sections: [{ id, title, kind | group, items, holders, folded, drop, empty }], shown, total } */
691
- const scopeOf = (scope) => (String(scope ?? 'all').startsWith('conversation:') ? 'conversation' : 'all');
691
+ /* 'device:<pairing>' (07-11, assets step 6): what a device sees on /c/ -- read by its rights, nothing to make or give. */
692
+ const scopeOf = (scope) => { const s = String(scope ?? 'all'); return s.startsWith('conversation:') ? 'conversation' : s.startsWith('device:') ? 'device' : 'all'; };
692
693
  function assetListModel({ items = [], groups = [], scope = 'all', by = '', query = '', folded = [], kinds = [] } = {}) {
693
694
  const canGroup = scopeOf(scope) === 'all';
695
+ /* « + New » and « Give… »: the owner's (the menu, a conversation's tab), never a device's. */
696
+ const canNew = scopeOf(scope) !== 'device'; const canGive = canNew;
694
697
  const view = canGroup && (by === 'group' || (by !== 'type' && groups.length > 0)) ? 'group' : 'type';
695
698
  const q = String(query ?? '').trim().toLowerCase();
696
699
  const shown = q ? items.filter((i) => String(i.name ?? '').toLowerCase().includes(q)) : [...items];
@@ -720,7 +723,33 @@
720
723
  /* The loose ones stay a place to drag to (out of a group) while there are groups, even emptied. */
721
724
  if (loose.length || (!q && items.length)) sections.push({ id: 'group:', group: '', title: say('set-as-no-group', 'No group'), items: loose, drop: '', empty: loose.length ? '' : groups.length ? 'grouped' : 'nogroups' });
722
725
  }
723
- return { view, canGroup, sections, shown: shown.length, total: items.length };
726
+ return { view, canGroup, canNew, canGive, scope: scopeOf(scope), sections, shown: shown.length, total: items.length };
727
+ }
728
+
729
+ /* WHAT A DEVICE SEES (07-11, assets step 6), as the list's items: its conversation's work folder (kept here) and what
730
+ is attached to it, then what was given to the device itself; given its chat alone, only the latter. The larger of
731
+ two rights holds (given to write, attached to read). Each with its right as the device has it (a badge, read only).
732
+ shared [{ name, type, access }] given to the device (GET /c/api/<pairing>/me, `shared`)
733
+ resources [{ name, access, type }] attached to its conversation work 'kept' | 'disk'
734
+ chatOnly given the chat alone conversation its name */
735
+ function deviceItems({ shared = [], resources = [], work = '', chatOnly = false, conversation = '' } = {}) {
736
+ const kind = (t) => (t === 'mailbox' || t === 'git' ? t : 'folder');
737
+ const out = new Map();
738
+ const put = (r, via) => {
739
+ if (!r?.name) return;
740
+ const access = r.access === 'write' ? 'write' : 'read';
741
+ const was = out.get(r.name);
742
+ if (was) { if (access === 'write') was.access = 'write'; if (via === 'device') was.via = 'device'; return; }
743
+ out.set(r.name, { name: r.name, kind: kind(r.type), access, via });
744
+ };
745
+ if (!chatOnly) {
746
+ if (work === 'kept') put({ name: 'work', access: 'write' }, 'work');
747
+ for (const r of resources ?? []) put(r, 'conversation');
748
+ }
749
+ for (const r of shared ?? []) put(r, 'device');
750
+ return [...out.values()].map((i) => ({ key: `${i.kind}:${i.name}`, kind: i.kind, name: i.name, access: i.access, via: i.via,
751
+ right: { key: i.kind, level: i.access, holder: 'device' },
752
+ sub: i.via === 'work' ? say('set-dev-work', 'Where its tool works') : i.via === 'device' ? say('set-dev-given', 'Given to this device') : say('set-dev-with', 'With {name}').replace('{name}', conversation) }));
724
753
  }
725
754
 
726
755
  /* The list drawn from its model. A row is an AssetCard (`rowCls` added: the page's handle on it); `pinned`,
@@ -729,6 +758,7 @@
729
758
  when a search finds nothing. `after(a)`: a node drawn under a row (Give…: the chosen one's right). */
730
759
  function AssetList(model, { chosen = '', onOpen = () => {}, onFold = () => {}, onGroup = () => {}, rowCls = '', pinned = [], empty = '', noMatch = '', after = null } = {}) {
731
760
  const box = node('div', 'kit-aslist'); box.dataset.view = model.view;
761
+ if (model.scope) box.dataset.scope = model.scope;
732
762
  box.append(...pinned.filter(Boolean));
733
763
  const hints = {
734
764
  group: say('set-as-group-empty', 'Empty: drag an asset here'),
@@ -854,17 +884,20 @@
854
884
  /* The list drawn from its model. `onChange(row, level, stored)` sets one right (the picker waits on it); `kind`, the
855
885
  asset's own kind (a calendar's levels say what they mean); `actions`, more gestures under it (Share with a device…).
856
886
  Its old handles kept for the pages' tests: .as-chapter .as-add, .as-users, .as-picker, .given-row[data-conversation]. */
857
- function AccessList(model, { key = '', kind = '', name = '', levels = null, onChange = () => {}, actions = [] } = {}) {
887
+ /* `title`: '' draws no head (the page has its own: a device's thing, a conversation's Sharing); `give`: false, no
888
+ « + Give to… » (a device is shared from its own sheet). */
889
+ function AccessList(model, { key = '', kind = '', name = '', levels = null, onChange = () => {}, actions = [], title, give: giving = true } = {}) {
858
890
  const box = node('section', 'kit-access');
859
891
  const head = node('div', 'as-chapter kit-access-head');
860
- head.append(node('h3', '', say('set-acc-title', 'Who has it')));
892
+ const heading = title ?? say('set-acc-title', 'Who has it');
893
+ if (heading) head.append(node('h3', '', heading));
861
894
  const picker = node('div', 'as-users as-picker kit-access-pick'); picker.hidden = true;
862
895
  const give = node('button', 'as-add kit-access-give', '+'); give.type = 'button';
863
896
  const giveWord = say('set-acc-give', 'Give to…');
864
897
  give.setAttribute('aria-label', giveWord); give.title = giveWord; give.setAttribute('aria-expanded', 'false');
865
898
  give.addEventListener('click', () => { picker.hidden = !picker.hidden; give.setAttribute('aria-expanded', String(!picker.hidden)); });
866
- head.append(give);
867
- box.append(head);
899
+ if (giving) head.append(give);
900
+ if (head.children.length) box.append(head);
868
901
  const row = (r) => {
869
902
  const line = node('div', 'given-row kit-access-row');
870
903
  line.dataset[r.holder === 'device' ? 'device' : 'conversation'] = String(r.id).replace(/^[a-z]+:/, '');
@@ -898,9 +931,11 @@
898
931
  const said = { nobody: say('set-acc-nobody', 'No conversation uses it.'), everyone: '', noready: say('set-acc-noready', 'No conversation is ready.') }[model.said];
899
932
  if (said && !model.here) users.append(node('p', 'hint', said));
900
933
  if (users.children.length) box.append(users);
901
- for (const r of model.others) picker.append(row(r));
902
- if (!model.others.length) picker.append(node('p', 'hint', model.said === 'noready' ? say('set-acc-noready', 'No conversation is ready.') : say('set-acc-everyone', 'Every conversation has it.')));
903
- box.append(picker);
934
+ if (giving) {
935
+ for (const r of model.others) picker.append(row(r));
936
+ if (!model.others.length) picker.append(node('p', 'hint', model.said === 'noready' ? say('set-acc-noready', 'No conversation is ready.') : say('set-acc-everyone', 'Every conversation has it.')));
937
+ box.append(picker);
938
+ }
904
939
  const more = actions.filter(Boolean);
905
940
  if (more.length) { const acts = node('div', 'kit-access-acts'); acts.append(...more); box.append(acts); }
906
941
  return box;
@@ -1013,7 +1048,8 @@
1013
1048
  back { label?, onBack } the way back parts [node | { title, extra: [nodes], body: [nodes], cls }]
1014
1049
  foot [nodes] before [nodes] above the head (an ending: "✓ created")
1015
1050
  A part's title draws a chapter head (.as-chapter), its `extra` on its right. */
1016
- function AssetPanel(card, { back = null, parts = [], foot = [], before = [], cls = '' } = {}) {
1051
+ /* `actions`: gestures on its head's right (a device's fold). */
1052
+ function AssetPanel(card, { back = null, parts = [], foot = [], before = [], cls = '', actions = [] } = {}) {
1017
1053
  const root = node('div', `kit-aspanel${cls ? ` ${cls}` : ''}`);
1018
1054
  if (card?.key) root.dataset.asset = card.key;
1019
1055
  root.append(...before.filter(Boolean));
@@ -1023,7 +1059,7 @@
1023
1059
  b.addEventListener('click', () => back.onBack?.());
1024
1060
  top.append(b);
1025
1061
  }
1026
- top.append(AssetCard(card ?? {}, { size: 'head' }));
1062
+ top.append(AssetCard(card ?? {}, { size: 'head', actions }));
1027
1063
  root.append(top);
1028
1064
  for (const p of parts) {
1029
1065
  if (!p) continue;
@@ -1063,7 +1099,7 @@
1063
1099
 
1064
1100
  window.clavariumKit = {
1065
1101
  collectModel,
1066
- AssetIcon, AssetCard, Holders, MemberStack, memberStackModel, roomPatch, RightsBadge, RightsPicker, AssetList, assetListModel, AccessList, accessModel, AssetPanel, AssetDelete, AssetPicker, assetPickModel, RIGHT_SAME,
1102
+ AssetIcon, AssetCard, Holders, MemberStack, memberStackModel, roomPatch, RightsBadge, RightsPicker, AssetList, assetListModel, deviceItems, AccessList, accessModel, AssetPanel, AssetDelete, AssetPicker, assetPickModel, RIGHT_SAME,
1067
1103
  memberLevel, groupLevels, groupLevel, groupFanOut, skippedSaid, GroupChip,
1068
1104
  ASSET_ICONS, ASSET_KINDS, RIGHTS, RIGHT_LEVELS, MCP_WORDS, rightsKey, rightsFor, rightOf, storedOf, lightest, rightWord,
1069
1105
  shareSlices, ShareDonut, ShareKey,
package/web/style.css CHANGED
@@ -910,6 +910,17 @@ svg.lk-links path.temp { stroke: var(--k-pick); stroke-dasharray: 4 4; }
910
910
  .files-card b { overflow-wrap: anywhere; }
911
911
  #files-ask, #files-progress { position: sticky; top: 0; z-index: 2; box-shadow: 0 6px 18px rgba(0, 0, 0, .25); }
912
912
  #files-ask { border-color: var(--brass); }
913
+ /* A device's Files (/c/, assets step 6): the one asset list beside the asset opened; on a phone, one or the other. */
914
+ #files-split { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; min-width: 0; }
915
+ #files-side, #files-body { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
916
+ #files-panel { min-width: 0; }
917
+ #files-side .kit-aslist-head h3 { margin: 6px 0 2px; font-size: .75rem; text-transform: uppercase; letter-spacing: .04em; color: var(--ink2); }
918
+ #files[data-open="list"] #files-panel, #files[data-open="detail"] #files-side { display: none; }
919
+ @media (min-width: 720px) {
920
+ #files[data-many="true"] #files-split { grid-template-columns: minmax(220px, 300px) minmax(0, 1fr); align-items: start; }
921
+ #files[data-many="true"] #files-side, #files[data-many="true"] #files-panel { display: flex; flex-direction: column; }
922
+ #files-panel .as-back { display: none; }
923
+ }
913
924
  #files-name input { font: .875rem var(--sans); color: var(--ink); background: var(--bg); border: 1px solid var(--line); border-radius: 7px; padding: 8px 10px; min-width: 0; width: min(100%, 420px); }
914
925
  #files-name label { font-size: .75rem; color: var(--ink2); }
915
926
  .bar { height: 8px; border-radius: 999px; background: var(--line); overflow: hidden; }
@@ -2937,3 +2948,29 @@ button.kit-switch[aria-checked="true"]::after { left: 18px; background: var(--ok
2937
2948
  .tidy-wrap .lk-sheet { width: min(440px, 92vw); border: 1px solid var(--line); border-radius: 14px; padding: 16px; }
2938
2949
  .tidy-wrap .lk-sheet::before { display: none; }
2939
2950
  }
2951
+
2952
+ /* A DEVICE'S SHARING ON THE SAME PIECES (07-11, assets step 6): a device opened is the asset panel; each thing it has, or
2953
+ may get, an AssetCard row with its right (RightsPicker) on its right; by thing, the thing's row and who has it. */
2954
+ .device-panel { border: 1px solid var(--line); border-radius: 10px; background: var(--bar); padding: 10px 12px; margin-top: 8px; gap: 8px; }
2955
+ .device-panel .kit-aspanel-part .as-chapter h3 { margin: 0; font-size: .75rem; text-transform: uppercase; letter-spacing: .04em; color: var(--ink2); }
2956
+ .device-panel .given { display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
2957
+ .device-panel .given > * { border-top: 1px solid var(--line); }
2958
+ .device-panel .given > :first-child { border-top: 0; }
2959
+ .device-panel .given > .hint { margin: 0; padding: 8px 12px; }
2960
+ .device-panel input { min-height: 40px; font: .875rem var(--sans); color: var(--ink); background: var(--bg); border: 1px solid var(--line); border-radius: 7px; padding: 7px 9px; min-width: 0; flex: 1 1 auto; }
2961
+ .device-panel .kit-aspanel-foot { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
2962
+ .device-panel .kit-aspanel-foot button { min-height: 40px; }
2963
+ .kit-asset.dev-thing { flex-wrap: wrap; padding: 8px 12px; border-radius: 0; gap: 6px 10px; }
2964
+ .kit-asset.dev-thing .kit-asset-main { flex: 1 1 140px; }
2965
+ .kit-asset.dev-thing .kit-asset-side { flex: 0 1 auto; flex-wrap: wrap; justify-content: flex-end; margin-left: auto; }
2966
+ .kit-asset.dev-thing > .parts-off { flex-basis: 100%; }
2967
+ .kit-asset.dev-thing .kit-rights-pick button { min-height: 36px; }
2968
+ #share-basket, #share-add { display: flex; flex-direction: column; }
2969
+ #share-basket > .dev-thing, #share-add > .dev-thing { border-top: 1px solid var(--line); }
2970
+ .thing > summary .kit-asset { flex: 1 1 auto; padding: 4px 0; }
2971
+ .thing > .kit-access { padding: 0 12px 10px; }
2972
+ .thing > .kit-access .as-users { background: var(--panel); }
2973
+ .thing > .dev-thing { border-top: 1px solid var(--line); }
2974
+ #share-here .kit-access .given-row { padding: 10px 12px; }
2975
+ .kit-asset.dev-thing > .kit-asset-ic { flex: none; }
2976
+ .device-panel .kit-aspanel-foot.as-foot { flex-direction: row; justify-content: flex-start; align-items: center; }