From 194edee6dea4ed9c1292468767ddbdcc81b092e8 Mon Sep 17 00:00:00 2001 From: Cedric Date: Mon, 5 Oct 2026 11:03:01 +0200 Subject: [PATCH] Settings: Save and Cancel buttons instead of saving each change Changes made in the Settings window show at once but are only kept with the Save button, placed under the tab labels; it takes the accent color while changes are waiting. Cancel goes back to the saved settings, and closing the window (or the page) with unsaved changes asks first. Tags are now sent as a whole list (PUT /api/tags, nothing saved when one is invalid, the error gives its position) and "Restore default tags" shows GET /api/tags/defaults until Save. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.fr.md | 9 +- README.md | 7 +- api.go | 37 +++++ tags.go | 45 ++++++ tags_test.go | 35 +++++ web/app.js | 401 ++++++++++++++++++++++++++++++++++--------------- web/index.html | 10 ++ web/style.css | 28 +++- 8 files changed, 442 insertions(+), 130 deletions(-) diff --git a/README.fr.md b/README.fr.md index 2aa181f..6d0505b 100644 --- a/README.fr.md +++ b/README.fr.md @@ -386,8 +386,13 @@ En ligne de commande : `curl -o logs.csv 'localhost:8080/api/export.csv?q=error& ## Paramètres -L'icône en forme d'engrenage ouvre les paramètres, organisés en onglets. Tout, sauf les tags de -couleur, est mémorisé par navigateur. +L'icône en forme d'engrenage ouvre les paramètres, organisés en onglets. Les modifications +s'affichent tout de suite mais ne sont conservées qu'avec le bouton **Valider**, sous les libellés +des onglets : il prend la couleur d'accent tant que des modifications sont en attente. **Annuler** +revient aux paramètres enregistrés, et fermer la fenêtre avec des modifications non validées +demande confirmation avant de les abandonner. Localisation et Affichage sont mémorisés par +navigateur ; les tags de couleur et les sources de logs sont enregistrés sur le serveur. La purge +et la réinitialisation des colonnes agissent immédiatement. - **Localisation et langue** - *Langue* : anglais ou français. diff --git a/README.md b/README.md index 1b7d7e3..7b928c9 100644 --- a/README.md +++ b/README.md @@ -351,8 +351,11 @@ From the command line: `curl -o logs.csv 'localhost:8080/api/export.csv?q=error& ## Settings -The gear icon opens the settings, organized in tabs. Everything except color tags is -remembered per browser. +The gear icon opens the settings, organized in tabs. Changes show at once but are only kept +with the **Save** button, under the tab labels: it takes the accent color while changes are +waiting. **Cancel** goes back to the saved settings, and closing the window with unsaved changes +asks before dropping them. Localization and Appearance are remembered per browser; color tags +and log sources are saved on the server. Purging and resetting column widths act at once. - **Localization** - *Language*: English or French. diff --git a/api.go b/api.go index d961e35..5bd2de5 100644 --- a/api.go +++ b/api.go @@ -40,6 +40,8 @@ func (a *API) Routes(mux *http.ServeMux) { mux.HandleFunc("GET /api/dbstats", a.dbStats) mux.HandleFunc("GET /api/tags", a.listTags) mux.HandleFunc("POST /api/tags", a.createTag) + mux.HandleFunc("PUT /api/tags", a.replaceTags) + mux.HandleFunc("GET /api/tags/defaults", a.defaultTags) mux.HandleFunc("POST /api/tags/reset", a.resetTags) mux.HandleFunc("GET /api/presets", a.listPresets) mux.HandleFunc("PUT /api/tags/{id}", a.updateTag) @@ -531,6 +533,41 @@ func (a *API) purge(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"task_id": id}) } +// PUT /api/tags [tags]: replaces the whole list. On an invalid tag, "index" +// gives its position and nothing is saved. +func (a *API) replaceTags(w http.ResponseWriter, r *http.Request) { + var in []Tag + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil { + writeErr(w, http.StatusBadRequest, err) + return + } + tags, err := a.tags.Replace(in) + var le *tagListError + switch { + case errors.As(err, &le): + body := map[string]any{"error": err.Error(), "index": le.index} + var ce *codedError + if errors.As(err, &ce) { + body["code"] = ce.code + if ce.detail != "" { + body["detail"] = ce.detail + } + } + writeJSON(w, http.StatusBadRequest, body) + case errors.Is(err, errTooManyTags): + writeErr(w, http.StatusBadRequest, err) + case err != nil: + writeErr(w, http.StatusInternalServerError, err) + default: + writeJSON(w, http.StatusOK, tags) + } +} + +// GET /api/tags/defaults: the tags "Restore default tags" shows before saving. +func (a *API) defaultTags(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, defaultTags()) +} + func (a *API) resetTags(w http.ResponseWriter, r *http.Request) { tags, err := a.tags.Reset() if err != nil { diff --git a/tags.go b/tags.go index f6e225b..0735f9d 100644 --- a/tags.go +++ b/tags.go @@ -217,6 +217,51 @@ func (s *TagStore) Delete(id string) error { return errTagNotFound } +// tagListError tells which tag of a list (PUT /api/tags) is invalid. +type tagListError struct { + index int + err error +} + +func (e *tagListError) Error() string { return e.err.Error() } +func (e *tagListError) Unwrap() error { return e.err } + +// Replace saves a whole list at once (the Save button of the settings). A tag +// whose id is known keeps its code; the others get a new id and a free code. +// Nothing is saved when a tag is invalid: the error gives its position. +func (s *TagStore) Replace(in []Tag) ([]Tag, error) { + if len(in) > 99 { + return nil, errTooManyTags + } + s.mu.Lock() + defer s.mu.Unlock() + codes := map[string]string{} + for _, t := range s.tags { + codes[t.ID] = t.Code + } + out := make([]Tag, len(in)) + seen := map[string]bool{} + for i, t := range in { + if err := t.validate(); err != nil { + return nil, &tagListError{index: i, err: err} + } + if c, ok := codes[t.ID]; ok && !seen[t.ID] { + t.Code = c + } else { + t.ID, t.Code = newID(), "" + } + seen[t.ID] = true + out[i] = t + } + for i := range out { + if out[i].Code == "" { + out[i].Code = freeCode(out) + } + } + s.tags = out + return append([]Tag(nil), out...), s.save() +} + func (s *TagStore) Reset() ([]Tag, error) { s.mu.Lock() defer s.mu.Unlock() diff --git a/tags_test.go b/tags_test.go index 57a62f7..8d9a7c4 100644 --- a/tags_test.go +++ b/tags_test.go @@ -1,6 +1,7 @@ package main import ( + "errors" "os" "path/filepath" "slices" @@ -52,3 +53,37 @@ func TestTagCodes(t *testing.T) { t.Fatalf("reloaded code = %q, want 07", got) } } + +func TestTagReplace(t *testing.T) { + s, err := LoadTagStore(filepath.Join(t.TempDir(), "tags.json")) + if err != nil { + t.Fatal(err) + } + // Defaults: warning 01, error 02. Keep error, drop warning, add two tags + // (one with an unknown id, one repeating a known id). + got, err := s.Replace([]Tag{ + {ID: "new1", Pattern: "a", Color: "#000000"}, + {ID: "error", Code: "42", Pattern: "error", Color: "#111111", Enabled: true}, + {ID: "error", Pattern: "b", Color: "#000000"}, + }) + if err != nil { + t.Fatal(err) + } + codes := []string{got[0].Code, got[1].Code, got[2].Code} + if want := []string{"01", "02", "03"}; !slices.Equal(codes, want) { + t.Fatalf("codes = %v, want %v", codes, want) + } + if got[1].ID != "error" || got[0].ID == "new1" || got[2].ID == "error" || got[1].Color != "#111111" { + t.Fatalf("ids/fields = %+v", got) + } + + // An invalid tag saves nothing and gives its position. + _, err = s.Replace([]Tag{{Pattern: "ok", Color: "#000000"}, {Pattern: "(", Regex: true, Color: "#000000"}}) + var le *tagListError + if !errors.As(err, &le) || le.index != 1 { + t.Fatalf("err = %v, want tagListError at 1", err) + } + if n := len(s.List()); n != 3 { + t.Fatalf("list after a failed Replace has %d tags, want 3", n) + } +} diff --git a/web/app.js b/web/app.js index 77d8fbe..e4d7935 100644 --- a/web/app.js +++ b/web/app.js @@ -40,13 +40,16 @@ const I18N = { copyMsg: 'Copy message', copyJson: 'Copy as JSON', copied: 'Copied', language: 'Language', tagsTitle: 'Color tags', - tagsHelp: 'Keywords are highlighted in messages. Changes are saved automatically. When tags overlap, the one highest in the list wins.', + tagsHelp: 'Keywords are highlighted in messages. Changes apply with the Save button. When tags overlap, the one highest in the list wins.', addTag: '+ Add tag', resetTags: 'Restore default tags', optWhole: 'whole word', optWholeTitle: 'Only color whole words (avoids "ok" in "token")', optCaseTitle: 'Match case', optRegexTitle: 'Regular expression', optActive: 'active', optActiveTitle: 'Enable this tag', colorAria: 'Color', keyword: 'keyword', preview: 'preview', del: 'Delete', noTags: 'No tags.', newTag: 'new', + saveSettings: 'Save', cancelSettings: 'Cancel', + settingsClean: 'No pending changes', settingsDirty: 'Unsaved changes', settingsSaved: 'Settings saved', + unsavedConfirm: 'Some changes have not been saved. Discard them?', confirmDelete: (p) => `Delete tag "${p}"?`, confirmReset: 'Replace all tags with the defaults (warning, error)?', presetAria: 'Add a preset', presetPick: '+ Preset…', @@ -268,13 +271,16 @@ const I18N = { copyMsg: 'Copier le message', copyJson: 'Copier en JSON', copied: 'Copié', language: 'Langue', tagsTitle: 'Tags de couleur', - tagsHelp: 'Les mots-clés sont surlignés dans les messages. Les modifications sont enregistrées automatiquement. En cas de chevauchement, le tag le plus haut dans la liste l\'emporte.', + tagsHelp: 'Les mots-clés sont surlignés dans les messages. Les modifications s\'appliquent avec le bouton Valider. En cas de chevauchement, le tag le plus haut dans la liste l\'emporte.', addTag: '+ Ajouter un tag', resetTags: 'Rétablir les tags par défaut', optWhole: 'mot entier', optWholeTitle: 'Ne colorer que les mots entiers (évite « ok » dans « token »)', optCaseTitle: 'Respecter la casse', optRegexTitle: 'Expression régulière', optActive: 'actif', optActiveTitle: 'Activer ce tag', colorAria: 'Couleur', keyword: 'mot-clé', preview: 'aperçu', del: 'Supprimer', noTags: 'Aucun tag.', newTag: 'nouveau', + saveSettings: 'Valider', cancelSettings: 'Annuler', + settingsClean: 'Aucune modification en attente', settingsDirty: 'Modifications non validées', settingsSaved: 'Paramètres enregistrés', + unsavedConfirm: 'Des modifications n\'ont pas été validées. Les abandonner ?', confirmDelete: (p) => `Supprimer le tag « ${p} » ?`, confirmReset: 'Remplacer tous les tags par les tags par défaut (warning, error) ?', presetAria: 'Ajouter un préréglage', presetPick: '+ Préréglage…', @@ -585,7 +591,7 @@ function apiError(res, text, data) { if (data && data.code && I18N[lang]['err_' + data.code]) { msg = t('err_' + data.code) + (data.detail ? (lang === 'fr' ? ' : ' : ': ') + data.detail : ''); } - return new Error(msg); + return Object.assign(new Error(msg), { data }); } function toast(msg) { @@ -618,6 +624,26 @@ const state = { }; const recOf = new WeakMap(); // .row element -> record +// Settings window: changes are shown at once but only saved by the Save button. +const settings = { + saved: null, // browser preferences as saved (see localPrefs) + tags: null, // tags as saved on the server + pending: {}, // source changes waiting for Save, by source + orig: {}, // server values of the pending fields, to drop a change set back + saving: false, +}; + +// Records a source change for Save; a field set back to its saved value is dropped. +function pend(key, change, current) { + const orig = settings.orig[key] ??= { ...current }; + const p = { ...settings.pending[key], ...change }; + for (const k of Object.keys(p)) if (p[k] === orig[k]) delete p[k]; + if (Object.keys(p).length) settings.pending[key] = p; + else { delete settings.pending[key]; delete settings.orig[key]; } +} +// A source state refreshed from the server, with the unsaved changes over it. +const withPending = (key, s) => (s && settings.pending[key] ? { ...s, ...settings.pending[key] } : s); + const list = $('#list'); /* ================= Language ================= */ @@ -634,7 +660,6 @@ function applyLang() { function setLang(next) { if (next === lang || !I18N[next]) return; lang = next; - store.set('lang', lang); applyLang(); syncControls(); setConn(...state.conn); @@ -670,11 +695,12 @@ function effectiveTheme() { if (th) return th; return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } -// mode: 'system' (follow the OS), 'light' or 'dark'. -function setTheme(mode) { +// mode: 'system' (follow the OS), 'light' or 'dark'. In Settings the theme is +// only stored by the Save button (persist = false). +function setTheme(mode, persist = true) { if (mode === 'light' || mode === 'dark') document.documentElement.dataset.theme = mode; else { delete document.documentElement.dataset.theme; mode = 'system'; } - store.set('theme', mode); + if (persist) store.set('theme', mode); renderThemeSwitch(); } @@ -689,7 +715,7 @@ function renderThemeSwitch() { $('#themeBtn').addEventListener('click', () => setTheme(effectiveTheme() === 'dark' ? 'light' : 'dark')); $('#themeSwitch').addEventListener('click', (ev) => { const b = ev.target.closest('[data-theme-opt]'); - if (b) setTheme(b.dataset.themeOpt); + if (b) setTheme(b.dataset.themeOpt, false); }); /* ================= Log font and size ================= */ @@ -780,20 +806,15 @@ $('#sizeSwitch').addEventListener('click', (ev) => { const b = ev.target.closest('[data-size]'); if (!b) return; applyLogSize(b.dataset.size); - store.set('logSize', ui.size); renderInterface(); }); $('#densitySwitch').addEventListener('click', (ev) => { const b = ev.target.closest('[data-density]'); if (!b) return; applyLogDensity(b.dataset.density); - store.set('logDensity', ui.density); renderInterface(); }); -$('#fontSelect').addEventListener('change', (ev) => { - applyLogFont(ev.target.value); - store.set('logFont', ui.font); -}); +$('#fontSelect').addEventListener('change', (ev) => applyLogFont(ev.target.value)); /* ================= Settings tabs ================= */ @@ -1747,7 +1768,6 @@ function renderHistoSettings() { function setHistoPref(k, v) { if (!HISTO_OPTS[k] || !HISTO_OPTS[k].includes(v)) return; histoPrefs[k] = v; - store.set('histo.' + k, v); renderHistoSettings(); if (k === 'step') refreshHisto(); else if (k === 'refresh') { scheduleHisto(); if (v !== 'off') refreshHisto(); } @@ -1958,8 +1978,6 @@ function updateTimePreview() { function onTimePrefsChange() { timePrefs.tz = $('#tzSelect').value; timePrefs.fmt = $('#fmtSelect').value; - store.set('tz', timePrefs.tz); - store.set('timefmt', timePrefs.fmt); updateTimePreview(); renderList(); renderPeriod(); @@ -2027,7 +2045,7 @@ document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') setExpor let syslogState = null; async function loadSyslog() { - try { syslogState = await api('/api/syslog'); } catch { syslogState = null; } + try { syslogState = withPending('syslog', await api('/api/syslog')); } catch { syslogState = null; } renderSyslog(); } @@ -2066,12 +2084,10 @@ function renderSyslog() { } } -async function configureSyslog(change) { +function configureSyslog(change) { const s = syslogState || { enabled: true, udp: true, tcp: true }; - const body = { enabled: s.enabled, udp: s.udp, tcp: s.tcp, ...change }; - syslogState = { ...s, ...body }; - renderSyslog(); - try { syslogState = await api('/api/syslog', { method: 'PUT', body }); } catch (e) { toast(e.message); } + pend('syslog', change, s); + syslogState = { ...s, ...change }; renderSyslog(); } @@ -2087,7 +2103,7 @@ $('#syslogProtos').addEventListener('click', (ev) => { let hostState = null; async function loadHost() { - try { hostState = await api('/api/hostlogs'); } catch { hostState = null; } + try { hostState = withPending('host', await api('/api/hostlogs')); } catch { hostState = null; } renderHost(); } @@ -2114,12 +2130,10 @@ function renderHost() { status.className = 'docker-status ' + cls; } -async function configureHost(enabled) { +function configureHost(enabled) { + pend('host', { enabled }, hostState || {}); hostState = { ...(hostState || {}), enabled }; renderHost(); - try { hostState = await api('/api/hostlogs', { method: 'PUT', body: { enabled } }); } catch (e) { toast(e.message); } - renderHost(); - setTimeout(loadHost, 1500); // the first scan runs in the background } $('#hostEnabled').addEventListener('change', (ev) => configureHost(ev.target.checked)); @@ -2129,7 +2143,7 @@ $('#hostEnabled').addEventListener('change', (ev) => configureHost(ev.target.che let dmesgState = null; async function loadDmesg() { - try { dmesgState = await api('/api/dmesg'); } catch { dmesgState = null; } + try { dmesgState = withPending('dmesg', await api('/api/dmesg')); } catch { dmesgState = null; } renderDmesg(); } @@ -2153,12 +2167,10 @@ function renderDmesg() { status.className = 'docker-status ' + cls; } -async function configureDmesg(enabled) { +function configureDmesg(enabled) { + pend('dmesg', { enabled }, dmesgState || {}); dmesgState = { ...(dmesgState || {}), enabled }; renderDmesg(); - try { dmesgState = await api('/api/dmesg', { method: 'PUT', body: { enabled } }); } catch (e) { toast(e.message); } - renderDmesg(); - setTimeout(loadDmesg, 1500); // the device is opened in the background } $('#dmesgEnabled').addEventListener('change', (ev) => configureDmesg(ev.target.checked)); @@ -2168,7 +2180,7 @@ $('#dmesgEnabled').addEventListener('change', (ev) => configureDmesg(ev.target.c let auditState = null; async function loadAudit() { - try { auditState = await api('/api/audit'); } catch { auditState = null; } + try { auditState = withPending('audit', await api('/api/audit')); } catch { auditState = null; } renderAudit(); } @@ -2195,13 +2207,10 @@ function renderAudit() { status.className = 'docker-status ' + cls; } -async function configureAudit(change) { - const body = { enabled: auditState?.enabled || false, path: auditState?.path || '', ...change }; - auditState = { ...(auditState || {}), ...body }; +function configureAudit(change) { + pend('audit', change, { enabled: false, path: '', ...auditState }); + auditState = { ...(auditState || {}), ...change }; renderAudit(); - try { auditState = await api('/api/audit', { method: 'PUT', body }); } catch (e) { toast(e.message); } - renderAudit(); - setTimeout(loadAudit, 1500); // the file is read in the background } $('#auditEnabled').addEventListener('change', (ev) => configureAudit({ enabled: ev.target.checked })); @@ -2212,7 +2221,7 @@ $('#auditPath').addEventListener('change', (ev) => configureAudit({ path: ev.tar let selfState = null; async function loadSelfLog() { - try { selfState = await api('/api/selflog'); } catch { selfState = null; } + try { selfState = withPending('selflog', await api('/api/selflog')); } catch { selfState = null; } renderSelfLog(); } @@ -2228,11 +2237,9 @@ function renderSelfLog() { status.className = 'docker-status ' + (s.enabled ? 'good' : 'muted'); } -async function configureSelfLog(change) { - const body = { enabled: selfState?.enabled ?? true, min_level: selfState?.min_level || 'warning', ...change }; - selfState = { ...(selfState || {}), ...body }; - renderSelfLog(); - try { selfState = await api('/api/selflog', { method: 'PUT', body }); } catch (e) { toast(e.message); } +function configureSelfLog(change) { + pend('selflog', change, { enabled: true, min_level: 'warning', ...selfState }); + selfState = { ...(selfState || {}), ...change }; renderSelfLog(); } @@ -2247,9 +2254,18 @@ let fileDrafts = []; // rows added here, saved once they have a name and a pat let fileSeq = 0; const fileProbes = new Map(); // path -> result of /api/files/detect (format found, last line) -async function loadFiles() { - try { filesState = await api('/api/files'); } catch { filesState = null; } - renderFiles(); +const FILE_FIELDS = ['id', 'name', 'path', 'format', 'regex', 'host', 'enabled']; + +// full: rebuild the rows (after Save or Cancel). +async function loadFiles(full) { + let fresh; + try { fresh = await api('/api/files'); } catch { filesState = null; renderFiles(full); return; } + if (settings.pending.files && filesState) { + // Unsaved edits: keep the rows as edited, with the server status of each file. + const byId = new Map(fresh.sources.map((x) => [x.id, x])); + filesState = { ...fresh, sources: filesState.sources.map((x) => ({ ...byId.get(x.id), ...Object.fromEntries(FILE_FIELDS.map((k) => [k, x[k]])) })) }; + } else filesState = fresh; + renderFiles(full); } // Status line of a row: the check of the path just typed, else the state @@ -2330,18 +2346,10 @@ function fileRowOf(key) { async function saveFiles() { const ready = fileDrafts.filter((d) => d.name.trim() && d.path.trim()); - const sources = filesState.sources.concat(ready).map(({ id, name, path, format, regex, host, enabled }) => ({ id: id || '', name, path, format, regex, host, enabled })); - try { - filesState = await api('/api/files', { method: 'PUT', body: { sources } }); - fileDrafts = fileDrafts.filter((d) => !ready.includes(d)); - - } catch (e) { - toast(e.message); - await loadFiles(); - return; - } - renderFiles(); + filesState = await api('/api/files', { method: 'PUT', body: { sources } }); + fileDrafts = fileDrafts.filter((d) => !ready.includes(d)); + renderFiles(true); setTimeout(loadFiles, 1500); // the files are read in the background } @@ -2362,7 +2370,7 @@ $('#fileList').addEventListener('change', (ev) => { if (f === 'format') row.querySelector('.fr-regex').hidden = s.format !== 'regex'; if (f === 'format' && s.format === 'regex' && !s.regex) { row.querySelector('.fr-regex').focus(); return; } if (s.draft && !(s.name && s.path)) return; - saveFiles(); + settings.pending.files = true; }); $('#fileList').addEventListener('click', (ev) => { @@ -2375,7 +2383,8 @@ $('#fileList').addEventListener('click', (ev) => { return; } filesState.sources = filesState.sources.filter((s) => s.id !== key); - saveFiles(); + settings.pending.files = true; + renderFiles(true); }); $('#addFile').addEventListener('click', () => { @@ -2393,9 +2402,18 @@ const LOCK_ICON = '
+
+
+ + +

+
+
diff --git a/web/style.css b/web/style.css index 52ac9ba..e06aa1b 100644 --- a/web/style.css +++ b/web/style.css @@ -544,11 +544,24 @@ dialog.settings { dialog.settings .dlg { display: flex; flex-direction: column; height: 100%; padding: 0; } dialog.settings .dlg > header { padding: 14px 16px 14px 22px; border-bottom: 1px solid var(--border); } .set-body { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(176px, max-content) minmax(0, 1fr); } -.set-nav { - display: flex; flex-direction: column; gap: 2px; - padding: 12px 10px; border-right: 1px solid var(--border); +.set-side { + display: flex; flex-direction: column; min-height: 0; overflow-y: auto; + border-right: 1px solid var(--border); background: color-mix(in srgb, var(--panel-2) 50%, transparent); } +.set-nav { display: flex; flex-direction: column; gap: 2px; padding: 12px 10px; } +/* Save / Cancel, under the section labels: Save takes the accent color while changes wait. */ +.set-actions { + display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 10px 12px; padding-top: 12px; + border-top: 1px solid var(--border); +} +.set-actions .btn { flex: 1; justify-content: center; } +.set-actions .btn.save { color: var(--muted); } +.set-actions .btn svg { width: 16px; height: 16px; } +.set-actions.dirty .btn.save { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 0 0 3px var(--accent-soft); } +.set-actions.dirty .btn.save:hover { filter: brightness(1.08); } +.set-state { flex-basis: 100%; margin: 2px 2px 0; font-size: 12px; color: var(--muted); } +.set-actions.dirty .set-state { color: var(--accent); font-weight: 550; } .set-nav button { display: flex; align-items: center; gap: 10px; height: 38px; padding: 0 12px; border: 0; border-radius: 9px; @@ -831,10 +844,11 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2 .field-grid { grid-template-columns: 1fr; gap: 4px; } dialog.settings { height: calc(100vh - 24px); width: calc(100vw - 16px); } .set-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); } - .set-nav { - display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 6px; - border-right: 0; border-bottom: 1px solid var(--border); - } + .set-side { border-right: 0; border-bottom: 1px solid var(--border); overflow: visible; } + .set-nav { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 6px; } + .set-actions { margin: 0 6px 6px; padding-top: 6px; align-items: center; } + .set-actions .btn { flex: 0 0 auto; height: 30px; } + .set-state { flex: 1; flex-basis: auto; margin: 0; text-align: right; } .set-nav button { flex-direction: column; justify-content: center; height: auto; padding: 6px 2px; gap: 3px; font-size: 11.5px; text-align: center;