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.
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/settingswords.mjs +11 -3
- package/web/app.js +126 -132
- package/web/kit.js +145 -24
- package/web/style.css +25 -30
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
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
package/server/settingswords.mjs
CHANGED
|
@@ -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-
|
|
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.',
|
|
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,
|
|
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',
|
|
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
|
|
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
|
-
/*
|
|
3992
|
-
|
|
3993
|
-
function
|
|
3994
|
-
const
|
|
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
|
|
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
|
-
|
|
4008
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4015
|
-
const
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
const
|
|
4029
|
-
|
|
4030
|
-
const
|
|
4031
|
-
const
|
|
4032
|
-
const
|
|
4033
|
-
const
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
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.
|
|
4093
|
-
|
|
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
|
|
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
|
|
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) {
|
|
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
|
|
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 (
|
|
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) {
|
|
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
|
|
435
|
-
|
|
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 [
|
|
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) :
|
|
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
|
-
|
|
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
|
|
718
|
-
(`onFold(id)`), its
|
|
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 ? '▸' : '▾')
|
|
749
|
+
toggle.append(node('span', 'kit-aslist-chev', s.folded ? '▸' : '▾'));
|
|
740
750
|
toggle.addEventListener('click', () => onFold(s.group));
|
|
741
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
/*
|
|
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-
|
|
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
|
-
|
|
1176
|
-
.as-
|
|
1177
|
-
.as-
|
|
1178
|
-
.as-
|
|
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
|
-
/*
|
|
1199
|
-
.
|
|
1200
|
-
.
|
|
1201
|
-
.
|
|
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
|
-
|
|
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-
|
|
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; }
|