summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-07 01:50:14 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-07 01:50:14 +0200
commit375419c041a4523614b3a1b98fa54fb5acb1b8bf (patch)
tree51086cd1e29a3f78f352dc2080847392a42fc37a /packages
parent435f54b382004de28196aa44c9b1d2c7368ae212 (diff)
downloadmeshbay-375419c041a4523614b3a1b98fa54fb5acb1b8bf.tar.gz
fix(client): Save in the app panes was invisible, and Chat's never landed
Two defects behind one report — "you cannot always click Save, you do not notice, and it does not work". **It did not work, for Chat, systematically.** `_dispatch` resolves an admin ack against the pending request and returns, which is right for an op whose caller already knows the value it chose. Chat's pane calls `transport.setChatDirectory` itself, so nothing told `group-page` anything: the node saved it, every *other* connected client learned it from the broadcast, and the one that asked went on showing an unsaved-looking draft. Clicking Save again just re-sent it. Same shape as the root-ack bug, in a different message — so the set is now `BROADCAST_ACK_TYPES`, named for the property that makes it true, and covers `chat_directory`, `chat_link_preview` and `app_directories`. **You could not notice, because Save was not visibly a button.** It carried `btn btn-small btn-secondary`, and there is no `.btn` rule in the stylesheet at all — so it took `.btn-secondary`: no background, a transparent border, dim grey text. Enabled it already looked like a disabled control; disabled it was the same thing at 40% opacity. Measured on a real page: enabled is now accent on white, disabled is grey text on a plain border, and an inert one says why ("No changes to save") rather than leaving the reader to guess what the pane counts as a change. Videos' second Save — the TMDB one — is the same control, because two Save buttons in one pane that do not look alike is worse than either looking wrong. Verified by driving the real pane in Electron through the whole cycle: inert, pick a folder, live, save, and the node's answer coming back to disable it again. Four earlier readings said the enabled button was transparent; all four were taken inside python's directory-listing page, which the app never runs in — my scaffolding, not the code. Hub suite only: the node package is untouched by this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css30
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js38
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js13
-rw-r--r--packages/meshbay-hub/tests/test_app_settings_plugin.py72
-rw-r--r--packages/meshbay-hub/tests/test_upload_controls_hidden.py13
18 files changed, 174 insertions, 23 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js
index 68569b9..5a15074 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js
@@ -45,11 +45,14 @@ function ChatSettings({ roots, dirs, settings, saveDirectories, transport, signF
value=${directory} disabled=${busy || noWritable}
onChange=${setDirectory} />
- <button class="btn btn-small btn-secondary" style="margin-top:4px"
- disabled=${busy || !dirty}
+ <div class="app-save-row">
+ <button class="app-save" disabled=${busy || !dirty}
onClick=${() => run(() => transport.setChatDirectory(directory, signFn))}>
${busy ? t('settings_app.saving') : t('settings_app.save')}
</button>
+ ${!busy && !dirty && html`
+ <span class="app-save-why">${t('settings_app.no_changes')}</span>`}
+ </div>
<div class="settings-row" style="margin-top:12px">
<${ToggleSwitch} checked=${linkPreview} disabled=${busy}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index d05d3a3..d47762e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -840,6 +840,7 @@ export default {
'folder_tree.read_only_blocked': 'Nur lesen — kein Schreiben möglich',
'settings_app.save': 'Speichern',
'settings_app.saving': 'Wird gespeichert…',
+ 'settings_app.no_changes': 'Keine Änderungen zu speichern',
'settings_app.disabled_hint': 'Schalten Sie diese App ein, um sie zu konfigurieren.',
'settings_app.video_directories_label': 'Video-Ordner',
'settings_app.video_directories_hint': 'Wo die Filme und Serien dieser Gruppe liegen. Nichts außerhalb erscheint im Videos-Tab.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 6dbb70f..932aad3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -628,6 +628,7 @@ export default {
'folder_tree.read_only_blocked': 'Read-only — cannot be written to',
'settings_app.save': 'Save',
'settings_app.saving': 'Saving…',
+ 'settings_app.no_changes': 'No changes to save',
'settings_app.disabled_hint': 'Turn this app on to configure it.',
'settings_app.video_directories_label': 'Video folders',
'settings_app.video_directories_hint': 'Where this group\'s films and shows live. Nothing outside them appears in the Videos tab.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index aa950cc..ca912a4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -836,6 +836,7 @@ export default {
'folder_tree.read_only_blocked': 'Solo lectura: no se puede escribir',
'settings_app.save': 'Guardar',
'settings_app.saving': 'Guardando…',
+ 'settings_app.no_changes': 'Sin cambios que guardar',
'settings_app.disabled_hint': 'Activa esta aplicación para configurarla.',
'settings_app.video_directories_label': 'Carpetas de vídeo',
'settings_app.video_directories_hint': 'Dónde están las películas y series de este grupo. Nada fuera de ellas aparece en la pestaña Vídeos.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index daa9f94..ccd9e25 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -854,6 +854,7 @@ export default {
'folder_tree.read_only_blocked': 'Lecture seule — écriture impossible',
'settings_app.save': 'Enregistrer',
'settings_app.saving': 'Enregistrement…',
+ 'settings_app.no_changes': 'Aucune modification à enregistrer',
'settings_app.disabled_hint': 'Activez cette application pour la configurer.',
'settings_app.video_directories_label': 'Dossiers vidéo',
'settings_app.video_directories_hint': 'Où vivent les films et séries de ce groupe. Rien en dehors n\'apparaît dans l\'onglet Vidéos.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 364c26c..c1c3d52 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -850,6 +850,7 @@ export default {
'folder_tree.read_only_blocked': 'Sola lettura: non vi si può scrivere',
'settings_app.save': 'Salva',
'settings_app.saving': 'Salvataggio…',
+ 'settings_app.no_changes': 'Nessuna modifica da salvare',
'settings_app.disabled_hint': 'Attiva questa applicazione per configurarla.',
'settings_app.video_directories_label': 'Cartelle video',
'settings_app.video_directories_hint': 'Dove si trovano film e serie di questo gruppo. Nulla al di fuori compare nella scheda Video.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index f778fc4..e5392a3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -834,6 +834,7 @@ export default {
'folder_tree.read_only_blocked': '読み取り専用 — 書き込みできません',
'settings_app.save': '保存',
'settings_app.saving': '保存中…',
+ 'settings_app.no_changes': '保存する変更はありません',
'settings_app.disabled_hint': 'このアプリを有効にすると設定できます。',
'settings_app.video_directories_label': '動画フォルダー',
'settings_app.video_directories_hint': 'このグループの映画や番組がある場所です。それ以外は動画タブに表示されません。',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 6e6842e..09e3e8d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -852,6 +852,7 @@ export default {
'folder_tree.read_only_blocked': 'Alleen-lezen — kan niet worden beschreven',
'settings_app.save': 'Opslaan',
'settings_app.saving': 'Opslaan…',
+ 'settings_app.no_changes': 'Geen wijzigingen om op te slaan',
'settings_app.disabled_hint': 'Zet deze app aan om hem in te stellen.',
'settings_app.video_directories_label': 'Videomappen',
'settings_app.video_directories_hint': 'Waar de films en series van deze groep staan. Niets daarbuiten verschijnt op het tabblad Video\'s.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index c8cbd16..34847e1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -878,6 +878,7 @@ export default {
'folder_tree.read_only_blocked': 'Tylko do odczytu — nie można zapisywać',
'settings_app.save': 'Zapisz',
'settings_app.saving': 'Zapisywanie…',
+ 'settings_app.no_changes': 'Brak zmian do zapisania',
'settings_app.disabled_hint': 'Włącz tę aplikację, aby ją skonfigurować.',
'settings_app.video_directories_label': 'Foldery wideo',
'settings_app.video_directories_hint': 'Gdzie znajdują się filmy i seriale tej grupy. Nic poza nimi nie pojawi się w zakładce Wideo.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 6eb2242..fc211c9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -837,6 +837,7 @@ export default {
'folder_tree.read_only_blocked': 'Somente leitura — não é possível gravar',
'settings_app.save': 'Salvar',
'settings_app.saving': 'Salvando…',
+ 'settings_app.no_changes': 'Nenhuma alteração para salvar',
'settings_app.disabled_hint': 'Ative este aplicativo para configurá-lo.',
'settings_app.video_directories_label': 'Pastas de vídeo',
'settings_app.video_directories_hint': 'Onde ficam os filmes e séries deste grupo. Nada fora delas aparece na aba Vídeos.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 044611e..9db7cc7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -821,6 +821,7 @@ export default {
'folder_tree.read_only_blocked': '只读 — 无法写入',
'settings_app.save': '保存',
'settings_app.saving': '正在保存…',
+ 'settings_app.no_changes': '没有需要保存的更改',
'settings_app.disabled_hint': '启用该应用后即可配置。',
'settings_app.video_directories_label': '视频文件夹',
'settings_app.video_directories_hint': '该群组的影片和剧集所在位置。其外的内容不会出现在「视频」标签页。',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js
index 01174b1..a3b02d9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js
@@ -38,11 +38,14 @@ function MusicSettings({ roots, dirs, settings, saveDirectories, transport, sign
value=${directories} disabled=${busy}
onChange=${setDirectories} />
- <button class="btn btn-small btn-secondary" style="margin-top:4px"
- disabled=${busy || !dirsDirty}
+ <div class="app-save-row">
+ <button class="app-save" disabled=${busy || !dirsDirty}
onClick=${() => run(() => saveDirectories(directories))}>
${busy ? t('settings_app.saving') : t('settings_app.save')}
</button>
+ ${!busy && !dirsDirty && html`
+ <span class="app-save-why">${t('settings_app.no_changes')}</span>`}
+ </div>
<h4 class="app-settings-sub">${t('settings_node.musicbrainz_title')}</h4>
<p class="settings-hint">${t('settings_node.musicbrainz_hint')}</p>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js
index 81e6cc1..a241eea 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js
@@ -32,11 +32,14 @@ function PhotoSettings({ roots, dirs, settings, saveDirectories }) {
value=${directories} disabled=${busy}
onChange=${setDirectories} />
- <button class="btn btn-small btn-secondary" style="margin-top:4px"
- disabled=${busy || !dirty}
+ <div class="app-save-row">
+ <button class="app-save" disabled=${busy || !dirty}
onClick=${() => run(() => saveDirectories(directories))}>
${busy ? t('settings_app.saving') : t('settings_app.save')}
</button>
+ ${!busy && !dirty && html`
+ <span class="app-save-why">${t('settings_app.no_changes')}</span>`}
+ </div>
${msg && html`<p class="settings-hint">${msg}</p>`}
</div>
`;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 9c0d869..bf4e8e1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -1271,6 +1271,36 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
/* One app's settings pane, inside its section. */
.app-settings > .settings-row:first-child { margin-top: 0; }
+/* The Save button of an app's settings pane.
+ *
+ * It was `btn btn-small btn-secondary`, and there is no `.btn` rule at all —
+ * so it inherited `.btn-secondary`: no background, a transparent border, dim
+ * grey text. Enabled, it already looked like a disabled control; disabled, it
+ * was the same thing at 40% opacity. "You cannot always click Save, you do not
+ * notice, and it does not work" was one sentence describing all of that.
+ *
+ * It looks like a button now, and a disabled one looks disabled. */
+.app-save {
+ display: inline-flex; align-items: center; gap: 6px;
+ height: 30px; padding: 0 14px; margin-top: 4px;
+ border: 1px solid var(--accent); border-radius: 6px;
+ background: var(--accent); color: var(--accent-text);
+ font-family: inherit; font-size: 0.83em; cursor: pointer;
+}
+.app-save:hover:not(:disabled) { background: var(--accent-hover); }
+/* No transition on the enabled/disabled swap. It animates a colour change that
+ coincides with a text-colour change, which reads as a flicker — and it made
+ the state genuinely hard to observe: a measurement taken during it reports a
+ transparent background on a button that is not transparent. */
+.app-save:disabled {
+ background: none; color: var(--text-dim);
+ border-color: var(--border); opacity: 1; cursor: default;
+}
+/* Why it is inert, beside it — a disabled control that explains itself is the
+ difference between "nothing changed" and "this is broken". */
+.app-save-row { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
+.app-save-why { font-size: 0.82em; color: var(--text-dim); }
+
.app-settings-sub {
margin: 18px 0 4px; font-size: 0.9em; font-weight: 600;
padding-top: 12px; border-top: 1px solid var(--border);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 3acfd20..179292e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -72,13 +72,35 @@ function _aborted() {
// change anything — `op` is already on every admin_challenge, and this
// list is what lets a response two steps later be tied back to the right
// one.
-// The acks whose payload is state no caller could have predicted: they carry
-// the node's whole roots table back. See the note where they are dispatched.
-const ROOT_ACK_TYPES = new Set([
+// Acks the node *broadcasts* to everyone in the group, which the requester
+// therefore also has to be handed.
+//
+// `_dispatch` resolves an admin ack against the pending request and returns,
+// which is right for an op whose caller already knows the value it chose. It is
+// wrong for these: every *other* connected client learns the change from the
+// broadcast, and the one that asked for it is the only one that does not,
+// because its own request swallowed its copy. Found twice — first on the root
+// table, then on Chat's directory, where it meant the pane went on showing an
+// unsaved-looking draft after a save that had worked.
+const BROADCAST_ACK_TYPES = new Set([
'root_update_ack', 'root_eject_ack', 'root_plug_ack',
'root_add_ack', 'root_remove_ack',
+ 'app_directories_ack', 'chat_directory_ack', 'chat_link_preview_ack',
]);
+/** Hand a broadcast ack to the callback that would have had it from a peer. */
+function _replayBroadcast(transport, msg) {
+ if (msg.type === 'app_directories_ack' && transport._onAppDirectories) {
+ transport._onAppDirectories(msg.app, msg.directories || []);
+ } else if (msg.type === 'chat_directory_ack' && transport._onChatDirectory) {
+ transport._onChatDirectory(msg.path || '');
+ } else if (msg.type === 'chat_link_preview_ack' && transport._onChatLinkPreview) {
+ transport._onChatLinkPreview(Boolean(msg.enabled));
+ } else if (transport._onRootsChanged) {
+ transport._onRootsChanged(msg);
+ }
+}
+
const ADMIN_OP_TYPES = new Set([
'tmdb_override', 'tmdb_rematch', 'tmdb_config', 'tmdb_enabled', 'video_root', 'audio_root',
'photo_roots',
@@ -2349,9 +2371,7 @@ class MeshBayTransport {
// name it settled on. Returning here left the operator who clicked
// Eject as the one client that never saw it happen, while every
// other peer got the broadcast. So this one type is handed on.
- if (ROOT_ACK_TYPES.has(msg.type) && this._onRootsChanged) {
- this._onRootsChanged(msg);
- }
+ if (BROADCAST_ACK_TYPES.has(msg.type)) _replayBroadcast(this, msg);
return;
}
}
@@ -2486,8 +2506,10 @@ class MeshBayTransport {
// A root's flags changed, or one was ejected, plugged, added or removed.
// Broadcast by the node to every peer, so everyone's table updates without
// waiting for the next index_sync.
- if (ROOT_ACK_TYPES.has(msg.type) && this._onRootsChanged) {
- this._onRootsChanged(msg);
+ if (msg.type === 'root_update_ack' || msg.type === 'root_eject_ack'
+ || msg.type === 'root_plug_ack' || msg.type === 'root_add_ack'
+ || msg.type === 'root_remove_ack') {
+ if (this._onRootsChanged) this._onRootsChanged(msg);
}
// The operator's node is scanning — never the entries themselves, just
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js
index b2d5015..86e47ec 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js
@@ -56,11 +56,14 @@ function VideoSettings({ roots, dirs, settings, saveDirectories, transport, sign
value=${directories} disabled=${busy}
onChange=${setDirectories} />
- <button class="btn btn-small btn-secondary" style="margin-top:4px"
- disabled=${busy || !dirsDirty}
+ <div class="app-save-row">
+ <button class="app-save" disabled=${busy || !dirsDirty}
onClick=${() => run(() => saveDirectories(directories))}>
${busy ? t('settings_app.saving') : t('settings_app.save')}
</button>
+ ${!busy && !dirsDirty && html`
+ <span class="app-save-why">${t('settings_app.no_changes')}</span>`}
+ </div>
<h4 class="app-settings-sub">${t('settings_node.tmdb_title')}</h4>
<p class="settings-hint">${t('settings_node.tmdb_hint')}</p>
@@ -108,7 +111,11 @@ function VideoSettings({ roots, dirs, settings, saveDirectories, transport, sign
<p class="settings-hint">${t('settings_node.tmdb_language_hint')}</p>
</div>
- <button class="btn btn-small btn-secondary" disabled=${busy}
+ ${/* The same control as the one above it. Two Save buttons in one pane
+ that do not look alike is worse than either looking wrong. This one
+ is never inert: the language always has a value to send, and an
+ empty token means "leave the stored one alone", not "no change". */''}
+ <button class="app-save" disabled=${busy}
onClick=${() => run(async () => {
// `undefined` for the token means "leave the stored one alone",
// which is not the same as `''` — that clears it. The input starts
diff --git a/packages/meshbay-hub/tests/test_app_settings_plugin.py b/packages/meshbay-hub/tests/test_app_settings_plugin.py
index 6a768a9..8a095af 100644
--- a/packages/meshbay-hub/tests/test_app_settings_plugin.py
+++ b/packages/meshbay-hub/tests/test_app_settings_plugin.py
@@ -320,3 +320,75 @@ def test_a_refusal_does_not_look_like_a_footnote():
assert "role=" in block and "alert" in block, (
"a refusal that appears after a click has to be announced, not just "
"drawn")
+
+
+# ── Saving, and being able to tell ──────────────────────────────────────────
+
+PANE_FLAGS = {"chat-app-settings.js": "dirty",
+ "video-app-settings.js": "dirsDirty",
+ "music-app-settings.js": "dirsDirty",
+ "photos-app-settings.js": "dirty"}
+
+
+@pytest.mark.parametrize("pane", sorted(PANE_FLAGS))
+def test_save_is_a_button_and_not_dim_text(pane):
+ """
+ It was `btn btn-small btn-secondary`, and there is no `.btn` rule in the
+ stylesheet at all — so it took `.btn-secondary`: no background, a
+ transparent border, dim grey text. Enabled it already looked like a
+ disabled control, and disabled it was that at 40% opacity.
+
+ "You cannot always click Save, you do not notice, and it does not work" is
+ one sentence describing all of that.
+ """
+ source = (STATIC / pane).read_text(encoding="utf-8")
+ assert 'class="app-save"' in source, f"{pane}'s Save is not the shared control"
+ assert "btn-secondary" not in source, (
+ f"{pane}'s Save is still styled as dim text")
+
+
+@pytest.mark.parametrize("pane", sorted(PANE_FLAGS))
+def test_an_inert_save_says_why(pane):
+ """
+ A disabled control that explains itself is the difference between "nothing
+ changed" and "this is broken". Without it the reader has to know what the
+ pane counts as a change.
+ """
+ source = (STATIC / pane).read_text(encoding="utf-8")
+ assert "app-save-why" in source and "settings_app.no_changes" in source
+
+
+def test_the_disabled_state_is_visually_distinct():
+ css = (STATIC / "style.css").read_text(encoding="utf-8")
+ rule = css[css.index(".app-save {"):]
+ rule = rule[:rule.index(".app-save-row")]
+ assert "var(--accent)" in rule, "an enabled Save has no fill"
+ disabled = rule[rule.index(".app-save:disabled"):]
+ assert "background: none" in disabled and "--text-dim" in disabled, (
+ "disabled differs from enabled by opacity alone, which is what made "
+ "it unreadable")
+
+
+def test_a_saved_setting_reaches_the_page_that_renders_the_pane():
+ """
+ Why Chat was the systematic case.
+
+ `_dispatch` resolves an admin ack against the pending request and returns —
+ right for an op whose caller already knows the value it chose. Chat's pane
+ calls `transport.setChatDirectory` itself, so nothing told `group-page`
+ anything: the node saved it, every *other* connected client learned it from
+ the broadcast, and the one that asked went on showing an unsaved-looking
+ draft. Clicking Save again just re-sent it.
+ """
+ transport = TRANSPORT.read_text(encoding="utf-8")
+ block = transport[transport.index("const BROADCAST_ACK_TYPES"):]
+ block = block[:block.index("]);") + 3]
+ for ack in ("chat_directory_ack", "chat_link_preview_ack",
+ "app_directories_ack"):
+ assert ack in block, f"{ack} is swallowed by its own request"
+
+ assert "_replayBroadcast" in transport
+ replay = transport[transport.index("function _replayBroadcast"):]
+ replay = replay[:replay.index("\n}") + 2]
+ for cb in ("_onChatDirectory", "_onChatLinkPreview", "_onAppDirectories"):
+ assert cb in replay, f"{cb} is never called for the requester"
diff --git a/packages/meshbay-hub/tests/test_upload_controls_hidden.py b/packages/meshbay-hub/tests/test_upload_controls_hidden.py
index bdba373..f6f476e 100644
--- a/packages/meshbay-hub/tests/test_upload_controls_hidden.py
+++ b/packages/meshbay-hub/tests/test_upload_controls_hidden.py
@@ -212,16 +212,17 @@ def test_the_notice_also_answers_the_operators_own_request():
caused it.
Every other admin ack can be resolved and dropped, because its caller
- already knows what it asked for and updates local state from that. The root
- acks carry a whole table only the node can compute — availability, the name
- it settled on, the eject a failed plug left in place — so resolving one
- without handing it on left the operator who clicked Eject as the only
- client that never saw it happen.
+ already knows what it asked for and updates local state from that. These
+ are the ones the node *broadcasts*: every other connected client learns the
+ change from it, and the one that asked is the only one that does not,
+ because its own request swallowed its copy. Found on the root table, then
+ again on Chat's directory — where it meant the pane went on showing an
+ unsaved-looking draft after a save that had worked.
"""
transport = TRANSPORT.read_text(encoding="utf-8")
block = transport[transport.index("msg.type.endsWith('_ack')"):]
block = block[:block.index("_uploaders")]
- assert "ROOT_ACK_TYPES" in block and "_onRootsChanged" in block, (
+ assert "BROADCAST_ACK_TYPES" in block and "_replayBroadcast" in block, (
"the initiating client resolves the ack and learns nothing from it")