From 7c164609553913a7496978ef44f03e7b09d47172 Mon Sep 17 00:00:00 2001 From: Thomas Kpenou Date: Thu, 18 Jun 2026 17:38:41 -0400 Subject: [PATCH 1/8] fix(main-app): restore app mount + refresh execution/sidebar/welcome UI FaviconManager still used the Vuex API (this.$store.state...) after the Pinia migration, so this.$store was undefined and the thrown error broke the entire main-app mount. Switch it to useExecutionsStore(). This is the only remaining Vuex reference in the codebase. UI refresh (increment 1): - Replace the rainbow image banner behind the script header with a clean surface (--script-header-background -> background color). - Execute/Stop buttons: drop the full-width flex stretch; compact, left-aligned with a gap, schedule button pushed right. - Welcome panel: clearer type hierarchy and constrained logo. - Sidebar: remove the underline on the server-name link. Co-Authored-By: Claude Opus 4.8 --- web-src/src/assets/css/shared.css | 2 +- .../main-app/components/AppWelcomePanel.vue | 15 +++++++++++++-- .../src/main-app/components/FaviconManager.vue | 3 ++- .../src/main-app/components/MainAppSidebar.vue | 1 + .../components/scripts/script-view.vue | 18 +++++------------- 5 files changed, 22 insertions(+), 17 deletions(-) diff --git a/web-src/src/assets/css/shared.css b/web-src/src/assets/css/shared.css index b82faea7..42763c86 100644 --- a/web-src/src/assets/css/shared.css +++ b/web-src/src/assets/css/shared.css @@ -74,7 +74,7 @@ --background-color-level-16dp: var(--background-color); --background-color-disabled: rgba(0, 0, 0, 0.12); /* disabled button */ - --script-header-background: url('../titleBackground_small.jpg') center left / cover no-repeat; + --script-header-background: var(--background-color); --login-header-background: url('../titleBackground_login.jpg') center / cover no-repeat; --separator-color: #DDDDDD; /* borders between components */ diff --git a/web-src/src/main-app/components/AppWelcomePanel.vue b/web-src/src/main-app/components/AppWelcomePanel.vue index bf21d990..9835a1ba 100644 --- a/web-src/src/main-app/components/AppWelcomePanel.vue +++ b/web-src/src/main-app/components/AppWelcomePanel.vue @@ -56,11 +56,22 @@ export default { overflow: hidden; } +.welcome-panel img { + width: 72px; + height: 72px; + opacity: 0.85; +} + .welcome-text { - margin-top: 15px; + margin-top: 16px; + font-size: 18px; + line-height: 1.5; + color: var(--font-color-main); } .welcome-cookie-text { - margin-top: 8px; + margin-top: 6px; + font-size: 14px; + color: var(--font-color-medium); } \ No newline at end of file diff --git a/web-src/src/main-app/components/FaviconManager.vue b/web-src/src/main-app/components/FaviconManager.vue index 43dcbbdb..8035eac3 100644 --- a/web-src/src/main-app/components/FaviconManager.vue +++ b/web-src/src/main-app/components/FaviconManager.vue @@ -5,6 +5,7 @@ diff --git a/web-src/src/common/utils/theme.js b/web-src/src/common/utils/theme.js new file mode 100644 index 00000000..22dcd63a --- /dev/null +++ b/web-src/src/common/utils/theme.js @@ -0,0 +1,55 @@ +/** + * Light/dark theme management. + * + * Keeps two things in sync: + * - the `theme-dark` class on , which switches the custom CSS variables + * defined in assets/css/shared.css + * - the active Vuetify theme (scriptServer / scriptServerDark) + * + * The preference is persisted in localStorage; when none is stored the OS + * `prefers-color-scheme` is used. + */ +import vuetify from '@/common/vuetifyPlugin' + +const STORAGE_KEY = 'script_server_theme' + +function prefersDark() { + return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) +} + +export function getStoredTheme() { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored === 'dark' || stored === 'light') { + return stored + } + } catch (e) { + // localStorage may be unavailable (private mode, blocked) — fall through + } + return null +} + +export function isDarkActive() { + return document.documentElement.classList.contains('theme-dark') +} + +export function applyTheme(name) { + const dark = name === 'dark' + document.documentElement.classList.toggle('theme-dark', dark) + vuetify.theme.global.name.value = dark ? 'scriptServerDark' : 'scriptServer' +} + +export function initTheme() { + applyTheme(getStoredTheme() || (prefersDark() ? 'dark' : 'light')) +} + +export function toggleTheme() { + const next = isDarkActive() ? 'light' : 'dark' + try { + localStorage.setItem(STORAGE_KEY, next) + } catch (e) { + // ignore persistence failures, the in-page toggle still applies + } + applyTheme(next) + return next +} diff --git a/web-src/src/common/vuetifyPlugin.js b/web-src/src/common/vuetifyPlugin.js index 654476a9..fa030e81 100644 --- a/web-src/src/common/vuetifyPlugin.js +++ b/web-src/src/common/vuetifyPlugin.js @@ -34,6 +34,19 @@ export default createVuetify({ background: '#FFFFFF', surface: '#FFFFFF' } + }, + scriptServerDark: { + dark: true, + colors: { + // Teal accent is kept — it reads well on dark surfaces + primary: '#26a69a', + 'primary-darken-1': '#00796B', + secondary: '#26a69a', + error: '#FF6B6B', + // Mirror the dark --background-color / --surface-color + background: '#1a1a1a', + surface: '#242424' + } } } }, diff --git a/web-src/src/main-app/components/MainAppSidebar.vue b/web-src/src/main-app/components/MainAppSidebar.vue index 1eae4f76..c5ce139b 100644 --- a/web-src/src/main-app/components/MainAppSidebar.vue +++ b/web-src/src/main-app/components/MainAppSidebar.vue @@ -22,6 +22,8 @@ + + @@ -45,6 +47,7 @@ diff --git a/web-src/src/main-app/components/scripts/ScriptListGroup.vue b/web-src/src/main-app/components/scripts/ScriptListGroup.vue index 426bbb7e..fcf0f42e 100644 --- a/web-src/src/main-app/components/scripts/ScriptListGroup.vue +++ b/web-src/src/main-app/components/scripts/ScriptListGroup.vue @@ -1,7 +1,7 @@ @@ -25,6 +30,7 @@ import DocumentTitleManager from './components/DocumentTitleManager'; import FaviconManager from './components/FaviconManager'; import MainAppSidebar from './components/MainAppSidebar'; import MainAppContent from './components/scripts/MainAppContent'; +import ScriptTabs from './components/scripts/ScriptTabs'; import {usePageStore} from '@/main-app/stores/page' import {useAuthStore} from '@/common/stores/auth' import {useServerConfigStore} from '@/main-app/stores/serverConfig' @@ -37,6 +43,7 @@ export default { AppLayout, MainAppSidebar, MainAppContent, + ScriptTabs, AppWelcomePanel, DocumentTitleManager, FaviconManager @@ -69,4 +76,16 @@ export default { h1, h2, h3, h4, h5, h6 { margin: 0; } + +.content-with-tabs { + height: 100%; + display: flex; + flex-direction: column; + min-height: 0; +} + +.content-with-tabs > .tabbed-content { + flex: 1 1 0; + min-height: 0; +} \ No newline at end of file diff --git a/web-src/src/main-app/components/scripts/ScriptTabs.vue b/web-src/src/main-app/components/scripts/ScriptTabs.vue new file mode 100644 index 00000000..28161bbe --- /dev/null +++ b/web-src/src/main-app/components/scripts/ScriptTabs.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/web-src/src/main-app/index.js b/web-src/src/main-app/index.js index 85ddf880..b2017001 100644 --- a/web-src/src/main-app/index.js +++ b/web-src/src/main-app/index.js @@ -17,6 +17,7 @@ import {useScriptsStore} from '@/main-app/stores/scripts'; import {useScriptConfigStore} from '@/main-app/stores/scriptConfig'; import {useScriptSetupStore} from '@/main-app/stores/scriptSetup'; import {useExecutionsStore} from '@/main-app/stores/executions'; +import {useScriptTabsStore} from '@/main-app/stores/scriptTabs'; const pinia = createPinia() const app = createApp(MainApp) @@ -36,7 +37,18 @@ const scriptsStore = useScriptsStore() const scriptConfigStore = useScriptConfigStore() const scriptSetupStore = useScriptSetupStore() -watch(() => scriptsStore.selectedScript, (selectedScript) => { +watch(() => scriptsStore.selectedScript, (selectedScript, previousScript) => { + const tabsStore = useScriptTabsStore() + + // Snapshot the form values of the tab we're leaving so they can be restored + // when the user comes back to it. + if (!isNull(previousScript)) { + tabsStore.saveValues(previousScript, scriptSetupStore.parameterValues) + } + if (!isNull(selectedScript)) { + tabsStore.openTab(selectedScript) + } + scriptSetupStore.reset() useScriptConfigStore().reloadScript(selectedScript) useExecutionsStore().selectScript({selectedScript}) @@ -56,6 +68,15 @@ watch(() => scriptConfigStore.parameters, (parameters) => { const scriptConfig = scriptConfigStore.scriptConfig const scriptName = scriptConfig ? scriptConfig.name : null scriptSetupStore.initFromParameters({scriptName, parameters, scriptConfig}) + + // Restore the previously-entered values for this tab, once. consumeValues + // deletes them so the parameter echo from reloadModel doesn't loop here. + if (!isNull(scriptName)) { + const savedValues = useScriptTabsStore().consumeValues(scriptName) + if (savedValues && Object.keys(savedValues).length > 0) { + scriptSetupStore.reloadModel({values: savedValues, forceAllowedValues: false, scriptName}) + } + } }) axiosInstance.interceptors.response.use( diff --git a/web-src/src/main-app/stores/scriptTabs.js b/web-src/src/main-app/stores/scriptTabs.js new file mode 100644 index 00000000..60205037 --- /dev/null +++ b/web-src/src/main-app/stores/scriptTabs.js @@ -0,0 +1,90 @@ +import {defineStore} from 'pinia' +import clone from 'lodash/clone' +import {isNull} from '@/common/utils/common' + +const STORAGE_KEY = 'script_server_open_tabs' + +function loadOpenTabs() { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw) { + const parsed = JSON.parse(raw) + if (Array.isArray(parsed)) { + return parsed.filter(name => typeof name === 'string') + } + } + } catch (e) { + // localStorage unavailable (private mode, jsdom) — start empty + } + return [] +} + +/** + * In-app tabs: keeps the list of scripts the user has opened, plus a per-tab + * snapshot of the parameter form values so switching tabs preserves work in + * progress. The open-tab list is persisted to localStorage; the snapshotted + * values are session-only (executions reconnect on reload via executions.init). + */ +export const useScriptTabsStore = defineStore('scriptTabs', { + state: () => ({ + openTabs: loadOpenTabs(), + tabValues: {} + }), + + actions: { + openTab(scriptName) { + if (isNull(scriptName)) { + return + } + if (!this.openTabs.includes(scriptName)) { + this.openTabs.push(scriptName) + this._persist() + } + }, + + // Removes a tab. Returns the script name that should become active when + // the closed tab was the active one (the neighbour), or null if no tabs + // remain. + closeTab(scriptName) { + const index = this.openTabs.indexOf(scriptName) + if (index === -1) { + return null + } + + this.openTabs.splice(index, 1) + delete this.tabValues[scriptName] + this._persist() + + if (this.openTabs.length === 0) { + return null + } + return this.openTabs[Math.min(index, this.openTabs.length - 1)] + }, + + saveValues(scriptName, values) { + if (isNull(scriptName)) { + return + } + this.tabValues[scriptName] = clone(values) + }, + + // Returns the saved values for a tab and removes them, so the caller can + // restore them exactly once (avoids re-triggering on parameter echoes). + consumeValues(scriptName) { + const values = this.tabValues[scriptName] + if (isNull(values)) { + return null + } + delete this.tabValues[scriptName] + return values + }, + + _persist() { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(this.openTabs)) + } catch (e) { + // ignore persistence failures, tabs still work in-session + } + } + } +}) diff --git a/web-src/tests/unit/main-app/stores/scriptTabs_test.js b/web-src/tests/unit/main-app/stores/scriptTabs_test.js new file mode 100644 index 00000000..43b2e025 --- /dev/null +++ b/web-src/tests/unit/main-app/stores/scriptTabs_test.js @@ -0,0 +1,132 @@ +import {createPinia, setActivePinia} from 'pinia'; +import {useScriptTabsStore} from '@/main-app/stores/scriptTabs'; + +function inMemoryLocalStorage() { + const data = {}; + return { + getItem: (k) => (k in data ? data[k] : null), + setItem: (k, v) => { + data[k] = String(v); + }, + removeItem: (k) => { + delete data[k]; + }, + _data: data + }; +} + +describe('Test scriptTabs store', function () { + let store; + + beforeEach(function () { + vi.stubGlobal('localStorage', inMemoryLocalStorage()); + setActivePinia(createPinia()); + store = useScriptTabsStore(); + }); + + afterEach(function () { + vi.unstubAllGlobals(); + }); + + describe('openTab', function () { + it('adds a tab', function () { + store.openTab('scriptA'); + expect(store.openTabs).toEqual(['scriptA']); + }); + + it('does not duplicate an already-open tab', function () { + store.openTab('scriptA'); + store.openTab('scriptA'); + expect(store.openTabs).toEqual(['scriptA']); + }); + + it('ignores null', function () { + store.openTab(null); + expect(store.openTabs).toEqual([]); + }); + + it('persists to localStorage', function () { + store.openTab('scriptA'); + store.openTab('scriptB'); + expect(JSON.parse(localStorage.getItem('script_server_open_tabs'))) + .toEqual(['scriptA', 'scriptB']); + }); + }); + + describe('closeTab', function () { + beforeEach(function () { + store.openTab('a'); + store.openTab('b'); + store.openTab('c'); + }); + + it('removes the tab', function () { + store.closeTab('b'); + expect(store.openTabs).toEqual(['a', 'c']); + }); + + it('returns the neighbour at the same index', function () { + expect(store.closeTab('b')).toBe('c'); + }); + + it('returns the previous tab when closing the last one', function () { + expect(store.closeTab('c')).toBe('b'); + }); + + it('returns null when no tabs remain', function () { + store.closeTab('a'); + store.closeTab('b'); + expect(store.closeTab('c')).toBeNull(); + }); + + it('returns null for an unknown tab', function () { + expect(store.closeTab('unknown')).toBeNull(); + }); + + it('drops saved values for the closed tab', function () { + store.saveValues('b', {x: 1}); + store.closeTab('b'); + expect(store.consumeValues('b')).toBeNull(); + }); + }); + + describe('saveValues / consumeValues', function () { + it('stores a clone, not the live reference', function () { + const values = {x: 1}; + store.saveValues('a', values); + values.x = 2; + expect(store.consumeValues('a')).toEqual({x: 1}); + }); + + it('returns null when nothing is saved', function () { + expect(store.consumeValues('a')).toBeNull(); + }); + + it('consumes the values (one-shot)', function () { + store.saveValues('a', {x: 1}); + expect(store.consumeValues('a')).toEqual({x: 1}); + expect(store.consumeValues('a')).toBeNull(); + }); + + it('ignores a null script name', function () { + store.saveValues(null, {x: 1}); + expect(store.consumeValues(null)).toBeNull(); + }); + }); + + describe('restore from localStorage', function () { + it('loads previously persisted tabs on init', function () { + localStorage.setItem('script_server_open_tabs', JSON.stringify(['x', 'y'])); + setActivePinia(createPinia()); + const restored = useScriptTabsStore(); + expect(restored.openTabs).toEqual(['x', 'y']); + }); + + it('falls back to empty when storage is malformed', function () { + localStorage.setItem('script_server_open_tabs', 'not json'); + setActivePinia(createPinia()); + const restored = useScriptTabsStore(); + expect(restored.openTabs).toEqual([]); + }); + }); +}); From f8ddd6d1cad93b9814393824aff0296445f1f33a Mon Sep 17 00:00:00 2001 From: Thomas Kpenou Date: Fri, 19 Jun 2026 08:35:19 -0400 Subject: [PATCH 8/8] docs(readme): document UI refresh, dark mode and in-app tabs Also gitignore the local .claude/ tooling directory (machine-local settings and dev launch configs). Co-Authored-By: Claude Opus 4.8 --- .gitignore | 4 ++++ README.md | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/.gitignore b/.gitignore index 414c574d..9bae33f5 100644 --- a/.gitignore +++ b/.gitignore @@ -92,3 +92,7 @@ e2e_venv/ web-src/tests/e2e/.run/ web-src/playwright-report/ web-src/test-results/ + +# Claude personal context (not shared) +CLAUDE.local.md +.claude/ diff --git a/README.md b/README.md index 8191a2c2..3bd278e4 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,25 @@ ## What's new in this fork +### 2026-06-19 — UI refresh, dark mode and in-app tabs + +A visual refresh of the main app, a dark theme, and the ability to keep several +scripts open at once. + +- **Dark mode**: a light/dark toggle (top-right of the sidebar, admin header, and + login page) that persists in `localStorage` and otherwise follows the OS + `prefers-color-scheme`. The Vuetify theme is injected into the shared theme + helper, so the Vuetify-less login page reuses the same preference without + bundling Vuetify. +- **Refreshed main app**: server badge + persistent full-width search in the + sidebar, folder groups shown as labelled sections, smaller script names, a play + icon on the Execute button, and a GitHub-style dark console for script output. + A modern system-UI font stack replaces the heavier Roboto look. +- **In-app tabs**: opening scripts now adds them to a persistent tab bar so you + can switch between several without leaving the app. Each tab keeps its entered + parameter values, and running executions stay alive when you switch tabs. Open + tabs are persisted to `localStorage`. + ### 2026-06-17 — Code coverage measurement Both test suites now report coverage and upload it to [Codecov](https://codecov.io/gh/knep/script-server) under separate `python` / `frontend` flags.