From 6ddf19f9c8a9d66c3e38ce0cac9f0379597855b1 Mon Sep 17 00:00:00 2001 From: roble Date: Sat, 25 Apr 2026 21:58:06 +0100 Subject: [PATCH 1/8] feat: implement themes module with CRUD operations and service integration --- .github/workflows/test.yml | 2 +- CLAUDE.md | 2 +- Taskfile.yml | 4 ++-- app/Providers/RouteServiceProvider.php | 15 --------------- composer.json | 16 +++++++++++----- module.json | 15 --------------- routes/web.php | 16 +++++++++------- .../Console/Commands/ApplyThemeCommand.php | 0 {app => src}/Filament/ThemesPlugin.php | 0 .../Http/Controllers/ThemesController.php | 0 .../Providers/ThemesServiceProvider.php | 19 +++++++++---------- {app => src}/Services/ThemeService.php | 0 12 files changed, 33 insertions(+), 56 deletions(-) delete mode 100644 app/Providers/RouteServiceProvider.php delete mode 100644 module.json rename {app => src}/Console/Commands/ApplyThemeCommand.php (100%) rename {app => src}/Filament/ThemesPlugin.php (100%) rename {app => src}/Http/Controllers/ThemesController.php (100%) rename {app => src}/Providers/ThemesServiceProvider.php (74%) rename {app => src}/Services/ThemeService.php (100%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f588c15..6542a63 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,4 +13,4 @@ jobs: test: uses: saucebase-dev/saucebase/.github/workflows/test-module.yml@main with: - module: Themes + module: themes diff --git a/CLAUDE.md b/CLAUDE.md index b36739c..1f71309 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -242,7 +242,7 @@ ls modules/Themes/resources/themes/ php -d memory_limit=2048M artisan test --compact modules/Themes/tests/ # E2E -npx playwright test --project="@Themes*" +npx playwright test --project="@themes*" ``` Key test files: diff --git a/Taskfile.yml b/Taskfile.yml index 9df1742..7802227 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -9,11 +9,11 @@ tasks: test:e2e: desc: Run E2E tests for Themes module - cmd: npx playwright test --project="@Themes*" {{.CLI_ARGS}} + cmd: npx playwright test --project="@themes*" {{.CLI_ARGS}} interactive: true # ── Code Generation ──────────────────────────────────────────── types:generate: desc: Generate TypeScript types from PHP DTOs and enums - cmd: php artisan module:generate-types Themes + cmd: php artisan module:generate-types themes diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php deleted file mode 100644 index 165cb09..0000000 --- a/app/Providers/RouteServiceProvider.php +++ /dev/null @@ -1,15 +0,0 @@ -group(module_path('Themes', '/routes/web.php')); - } -} diff --git a/composer.json b/composer.json index 2ce03c1..108ebd8 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,9 @@ { "name": "saucebase/themes", - "description": "", + "description": "Themes module", "type": "saucebase-module", + "license": "proprietary", + "version": "1.2.2", "authors": [ { "name": "Saucebase", @@ -10,20 +12,24 @@ ], "extra": { "laravel": { - "providers": [], + "providers": [ + "Modules\\Themes\\Providers\\ThemesServiceProvider" + ], "aliases": {} } }, "autoload": { "psr-4": { - "Modules\\Themes\\": "app/", + "Modules\\Themes\\": "src/", "Modules\\Themes\\Database\\Factories\\": "database/factories/", - "Modules\\Themes\\Database\\Seeders\\": "database/seeders/" + "Modules\\Themes\\Database\\Seeders\\": "database/seeders/", + "Modules\\Themes\\Tests\\Support\\": "tests/Support/" } }, "autoload-dev": { "psr-4": { "Modules\\Themes\\Tests\\": "tests/" } - } + }, + "minimum-stability": "stable" } diff --git a/module.json b/module.json deleted file mode 100644 index 938ea17..0000000 --- a/module.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "Themes", - "alias": "themes", - "description": "Themes module", - "author": "Saucebase", - "version": "v1.2.2", - "keywords": [ - "themes" - ], - "priority": 0, - "providers": [ - "Modules\\Themes\\Providers\\ThemesServiceProvider" - ], - "files": [] -} diff --git a/routes/web.php b/routes/web.php index 2f79cb9..6ad5b22 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,12 +3,14 @@ use Illuminate\Support\Facades\Route; use Modules\Themes\Http\Controllers\ThemesController; -Route::post('/themes', [ThemesController::class, 'store']) - ->middleware('throttle:10,1') - ->name('themes.store'); +Route::middleware('web')->group(function (): void { + Route::post('/themes', [ThemesController::class, 'store']) + ->middleware('throttle:10,1') + ->name('themes.store'); -Route::put('/themes/{name}', [ThemesController::class, 'update']) - ->name('themes.update'); + Route::put('/themes/{name}', [ThemesController::class, 'update']) + ->name('themes.update'); -Route::delete('/themes/{name}', [ThemesController::class, 'destroy']) - ->name('themes.destroy'); + Route::delete('/themes/{name}', [ThemesController::class, 'destroy']) + ->name('themes.destroy'); +}); diff --git a/app/Console/Commands/ApplyThemeCommand.php b/src/Console/Commands/ApplyThemeCommand.php similarity index 100% rename from app/Console/Commands/ApplyThemeCommand.php rename to src/Console/Commands/ApplyThemeCommand.php diff --git a/app/Filament/ThemesPlugin.php b/src/Filament/ThemesPlugin.php similarity index 100% rename from app/Filament/ThemesPlugin.php rename to src/Filament/ThemesPlugin.php diff --git a/app/Http/Controllers/ThemesController.php b/src/Http/Controllers/ThemesController.php similarity index 100% rename from app/Http/Controllers/ThemesController.php rename to src/Http/Controllers/ThemesController.php diff --git a/app/Providers/ThemesServiceProvider.php b/src/Providers/ThemesServiceProvider.php similarity index 74% rename from app/Providers/ThemesServiceProvider.php rename to src/Providers/ThemesServiceProvider.php index c470735..a373ccc 100644 --- a/app/Providers/ThemesServiceProvider.php +++ b/src/Providers/ThemesServiceProvider.php @@ -9,17 +9,16 @@ class ThemesServiceProvider extends ModuleServiceProvider { - protected string $name = 'Themes'; - - protected string $nameLower = 'themes'; - - protected array $providers = [ - RouteServiceProvider::class, - ]; + public function boot(): void + { + parent::boot(); - protected array $commands = [ - ApplyThemeCommand::class, - ]; + if ($this->app->runningInConsole()) { + $this->commands([ + ApplyThemeCommand::class, + ]); + } + } protected function shareInertiaData(): void { diff --git a/app/Services/ThemeService.php b/src/Services/ThemeService.php similarity index 100% rename from app/Services/ThemeService.php rename to src/Services/ThemeService.php From 4eaa2781d51cb1178ae1549900dc050ac81acdb6 Mon Sep 17 00:00:00 2001 From: roble Date: Sat, 25 Apr 2026 22:00:01 +0100 Subject: [PATCH 2/8] fix: improve sidebar sync detection to prevent overwriting values on load --- resources/js/components/ThemePanel.vue | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/resources/js/components/ThemePanel.vue b/resources/js/components/ThemePanel.vue index 377160d..1e14567 100644 --- a/resources/js/components/ThemePanel.vue +++ b/resources/js/components/ThemePanel.vue @@ -230,14 +230,16 @@ function populateFieldsFromTheme(theme: Theme): void { field.value = raw; } } - // Apply sidebar sync now so originalValues captures the synced state, - // preventing a false "live edits" indicator on fresh load. - if (sidebarSynced.value) { - applySidebarSync(); - for (const f of fields) { - if (f.key.startsWith('sidebar') && f.type === 'color') - fieldSynced[f.key] = true; - } + // Detect actual sidebar sync state from loaded values — never force-apply sync. + // This prevents sidebar values from being overwritten with surface values on load. + const allSidebarSynced = SIDEBAR_SYNC_MAP.every(([sidebarKey, sourceKey]) => { + const s = fields.find((f) => f.key === sidebarKey); + const src = fields.find((f) => f.key === sourceKey); + return s?.value !== '' && src?.value !== '' && s?.value === src?.value; + }); + for (const f of fields) { + if (f.key.startsWith('sidebar') && f.type === 'color') + fieldSynced[f.key] = allSidebarSynced; } originalValues.value = Object.fromEntries( fields.map((f) => [f.key, f.value]), @@ -649,7 +651,7 @@ const SIDEBAR_SYNC_MAP: [string, string][] = [ ['sidebar-ring', 'ring'], ]; -const sidebarSynced = ref(true); +const sidebarSynced = ref(false); function applySidebarSync(): void { for (const [sidebarKey, sourceKey] of SIDEBAR_SYNC_MAP) { From d33a80daeebc78efdeb0b8782a8ba6b191e716ed Mon Sep 17 00:00:00 2001 From: roble Date: Wed, 29 Apr 2026 21:35:53 +0100 Subject: [PATCH 3/8] fix: update file paths in documentation to reflect new directory structure --- CLAUDE.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1f71309..422945c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,9 +112,9 @@ documentElement inline styles ← set by applyThemeVars() when a theme is activ |------|------| | `resources/themes/*.json` | Theme definitions — committed source of truth | | `resources/css/theme.css` | (in `resources/css/`, not inside module) — baked CSS output | -| `app/Console/Commands/ApplyThemeCommand.php` | Patches theme.css from JSON; writes `:root` and `.dark` blocks | -| `app/Providers/ThemesServiceProvider.php` | Discovers themes, parses JSON, shares via Inertia | -| `app/Http/Controllers/ThemesController.php` | REST API for save/update/delete of user themes | +| `src/Console/Commands/ApplyThemeCommand.php` | Patches theme.css from JSON; writes `:root` and `.dark` blocks | +| `src/Providers/ThemesServiceProvider.php` | Discovers themes, parses JSON, shares via Inertia | +| `src/Http/Controllers/ThemesController.php` | REST API for save/update/delete of user themes | | `resources/js/fields.ts` | Canonical list of all editable fields with type, vars, constraints | | `resources/js/utils/theme.ts` | Core utilities: `applyThemeVars`, `computeShadows`, `computeRadiusScale`, `computeTrackingScale`, font loading | | `resources/js/components/ThemePanel.vue` | Full visual editor — field rendering, per-field mode sync, save dropdown | From b48db65ba6947adde2a6c387a213fb133b9f045f Mon Sep 17 00:00:00 2001 From: roble Date: Wed, 29 Apr 2026 21:36:14 +0100 Subject: [PATCH 4/8] feat: add database seeding task for Themes module --- Taskfile.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Taskfile.yml b/Taskfile.yml index 7802227..d1d61d2 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -12,6 +12,12 @@ tasks: cmd: npx playwright test --project="@themes*" {{.CLI_ARGS}} interactive: true + # ── Database ────────────────────────────────────────────────── + + db:seed: + desc: Seed the Themes module database + cmd: php artisan modules:seed --module=themes + # ── Code Generation ──────────────────────────────────────────── types:generate: From 8fdf46867acf92defb2eeab286476f21dc1fd599 Mon Sep 17 00:00:00 2001 From: roble Date: Fri, 22 May 2026 21:48:05 +0100 Subject: [PATCH 5/8] feat: add ThemePicker component and theme management utilities - Implemented ThemePicker.vue for selecting themes with search functionality. - Created fields.ts to define theme-related fields and their properties. - Developed theme.ts for managing theme application, including font loading and CSS variable handling. --- resources/js/app.ts | 10 +--------- resources/js/vue/app.ts | 9 +++++++++ resources/js/{ => vue}/components/ColorInput.vue | 0 .../js/{ => vue}/components/ColorPickerPopover.vue | 0 resources/js/{ => vue}/components/DialogCommand.vue | 0 resources/js/{ => vue}/components/DialogSave.vue | 0 resources/js/{ => vue}/components/FontPicker.vue | 0 resources/js/{ => vue}/components/LinkToggle.vue | 0 resources/js/{ => vue}/components/SearchInput.vue | 0 resources/js/{ => vue}/components/SliderInput.vue | 0 .../js/{ => vue}/components/TailwindColorPicker.vue | 0 resources/js/{ => vue}/components/ThemeColorSwatch.vue | 0 resources/js/{ => vue}/components/ThemePanel.vue | 0 resources/js/{ => vue}/components/ThemePicker.vue | 0 resources/js/{ => vue}/fields.ts | 0 resources/js/{ => vue}/utils/theme.ts | 0 16 files changed, 10 insertions(+), 9 deletions(-) create mode 100644 resources/js/vue/app.ts rename resources/js/{ => vue}/components/ColorInput.vue (100%) rename resources/js/{ => vue}/components/ColorPickerPopover.vue (100%) rename resources/js/{ => vue}/components/DialogCommand.vue (100%) rename resources/js/{ => vue}/components/DialogSave.vue (100%) rename resources/js/{ => vue}/components/FontPicker.vue (100%) rename resources/js/{ => vue}/components/LinkToggle.vue (100%) rename resources/js/{ => vue}/components/SearchInput.vue (100%) rename resources/js/{ => vue}/components/SliderInput.vue (100%) rename resources/js/{ => vue}/components/TailwindColorPicker.vue (100%) rename resources/js/{ => vue}/components/ThemeColorSwatch.vue (100%) rename resources/js/{ => vue}/components/ThemePanel.vue (100%) rename resources/js/{ => vue}/components/ThemePicker.vue (100%) rename resources/js/{ => vue}/fields.ts (100%) rename resources/js/{ => vue}/utils/theme.ts (100%) diff --git a/resources/js/app.ts b/resources/js/app.ts index 13179b5..3043f15 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -1,9 +1 @@ -import { registerGlobalComponent } from '@/lib/globalComponents'; -import '../css/app.css'; -import ThemePanel from './components/ThemePanel.vue'; - -export function setup() { - registerGlobalComponent('top', ThemePanel); -} - -export function afterMount() {} +export * from './vue/app'; diff --git a/resources/js/vue/app.ts b/resources/js/vue/app.ts new file mode 100644 index 0000000..4d2ca36 --- /dev/null +++ b/resources/js/vue/app.ts @@ -0,0 +1,9 @@ +import { registerGlobalComponent } from '@/lib/globalComponents'; +import '@modules/themes/resources/css/app.css'; +import ThemePanel from './components/ThemePanel.vue'; + +export function setup() { + registerGlobalComponent('top', ThemePanel); +} + +export function afterMount() {} diff --git a/resources/js/components/ColorInput.vue b/resources/js/vue/components/ColorInput.vue similarity index 100% rename from resources/js/components/ColorInput.vue rename to resources/js/vue/components/ColorInput.vue diff --git a/resources/js/components/ColorPickerPopover.vue b/resources/js/vue/components/ColorPickerPopover.vue similarity index 100% rename from resources/js/components/ColorPickerPopover.vue rename to resources/js/vue/components/ColorPickerPopover.vue diff --git a/resources/js/components/DialogCommand.vue b/resources/js/vue/components/DialogCommand.vue similarity index 100% rename from resources/js/components/DialogCommand.vue rename to resources/js/vue/components/DialogCommand.vue diff --git a/resources/js/components/DialogSave.vue b/resources/js/vue/components/DialogSave.vue similarity index 100% rename from resources/js/components/DialogSave.vue rename to resources/js/vue/components/DialogSave.vue diff --git a/resources/js/components/FontPicker.vue b/resources/js/vue/components/FontPicker.vue similarity index 100% rename from resources/js/components/FontPicker.vue rename to resources/js/vue/components/FontPicker.vue diff --git a/resources/js/components/LinkToggle.vue b/resources/js/vue/components/LinkToggle.vue similarity index 100% rename from resources/js/components/LinkToggle.vue rename to resources/js/vue/components/LinkToggle.vue diff --git a/resources/js/components/SearchInput.vue b/resources/js/vue/components/SearchInput.vue similarity index 100% rename from resources/js/components/SearchInput.vue rename to resources/js/vue/components/SearchInput.vue diff --git a/resources/js/components/SliderInput.vue b/resources/js/vue/components/SliderInput.vue similarity index 100% rename from resources/js/components/SliderInput.vue rename to resources/js/vue/components/SliderInput.vue diff --git a/resources/js/components/TailwindColorPicker.vue b/resources/js/vue/components/TailwindColorPicker.vue similarity index 100% rename from resources/js/components/TailwindColorPicker.vue rename to resources/js/vue/components/TailwindColorPicker.vue diff --git a/resources/js/components/ThemeColorSwatch.vue b/resources/js/vue/components/ThemeColorSwatch.vue similarity index 100% rename from resources/js/components/ThemeColorSwatch.vue rename to resources/js/vue/components/ThemeColorSwatch.vue diff --git a/resources/js/components/ThemePanel.vue b/resources/js/vue/components/ThemePanel.vue similarity index 100% rename from resources/js/components/ThemePanel.vue rename to resources/js/vue/components/ThemePanel.vue diff --git a/resources/js/components/ThemePicker.vue b/resources/js/vue/components/ThemePicker.vue similarity index 100% rename from resources/js/components/ThemePicker.vue rename to resources/js/vue/components/ThemePicker.vue diff --git a/resources/js/fields.ts b/resources/js/vue/fields.ts similarity index 100% rename from resources/js/fields.ts rename to resources/js/vue/fields.ts diff --git a/resources/js/utils/theme.ts b/resources/js/vue/utils/theme.ts similarity index 100% rename from resources/js/utils/theme.ts rename to resources/js/vue/utils/theme.ts From 789d8fb57f09f0cdc7c5002369baf647ef6b5d5a Mon Sep 17 00:00:00 2001 From: roble Date: Sat, 23 May 2026 10:50:55 +0100 Subject: [PATCH 6/8] feat: update color mode handling to use appearance storage key and add related tests --- resources/js/vue/components/ThemePanel.vue | 5 +++-- resources/js/vue/components/ThemePicker.vue | 2 +- tests/e2e/index.spec.ts | 20 ++++++++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/resources/js/vue/components/ThemePanel.vue b/resources/js/vue/components/ThemePanel.vue index 1e14567..da06220 100644 --- a/resources/js/vue/components/ThemePanel.vue +++ b/resources/js/vue/components/ThemePanel.vue @@ -37,7 +37,7 @@ import type { FieldState, Font, Theme } from '../types'; import { useDialog } from '@/composables/useDialog'; import { useHttp, usePage } from '@inertiajs/vue3'; -import { useDark } from '@vueuse/core'; +import { useColorMode } from '@vueuse/core'; import { trans } from 'laravel-vue-i18n'; import { toast } from 'vue-sonner'; import ColorInput from './ColorInput.vue'; @@ -75,7 +75,8 @@ const http = useHttp({ dark: {} as Record, }, }); -const isDark = useDark(); +const colorMode = useColorMode({ storageKey: 'appearance' }); +const isDark = computed(() => colorMode.value === 'dark'); const { confirm } = useDialog(); const themesEnabled = computed(() => page.props?.themes != null); diff --git a/resources/js/vue/components/ThemePicker.vue b/resources/js/vue/components/ThemePicker.vue index 3b47a4e..10c1481 100644 --- a/resources/js/vue/components/ThemePicker.vue +++ b/resources/js/vue/components/ThemePicker.vue @@ -17,7 +17,7 @@ const props = defineProps<{ options: Theme[]; }>(); -const { system, store } = useColorMode(); +const { system, store } = useColorMode({ storageKey: 'appearance' }); const colorMode = computed(() => store.value === 'auto' ? system.value : store.value, diff --git a/tests/e2e/index.spec.ts b/tests/e2e/index.spec.ts index 804357f..fcd634a 100644 --- a/tests/e2e/index.spec.ts +++ b/tests/e2e/index.spec.ts @@ -227,4 +227,24 @@ test.describe('Theme panel', () => { await expect(colorInput).toHaveValue('#aabbcc'); }); + + test('color mode dark button writes to appearance localStorage key, not vueuse-dark', async ({ page }) => { + await page.getByTestId('theme-panel-trigger').click(); + await switchColorMode(page, 'dark'); + + const stored = await page.evaluate(() => localStorage.getItem('appearance')); + expect(stored).toBe('dark'); + + const wrongKey = await page.evaluate(() => localStorage.getItem('vueuse-dark')); + expect(wrongKey).toBeNull(); + }); + + test('color mode dark button sets appearance cookie for server-side persistence', async ({ page }) => { + await page.getByTestId('theme-panel-trigger').click(); + await switchColorMode(page, 'dark'); + + const cookies = await page.context().cookies(); + const appearanceCookie = cookies.find((c) => c.name === 'appearance'); + expect(appearanceCookie?.value).toBe('dark'); + }); }); From 59c0cddcb0603edc31519126a3be2dd4b0eaeaeb Mon Sep 17 00:00:00 2001 From: roble Date: Wed, 27 May 2026 19:34:19 +0100 Subject: [PATCH 7/8] refactor: improve code formatting and readability in ThemePanel, ThemePicker, fields, and theme utility files --- resources/js/vue/components/ThemePanel.vue | 29 ++++++++++++++------- resources/js/vue/components/ThemePicker.vue | 2 +- resources/js/vue/fields.ts | 9 ++++--- resources/js/vue/utils/theme.ts | 15 ++++++----- 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/resources/js/vue/components/ThemePanel.vue b/resources/js/vue/components/ThemePanel.vue index da06220..dd6b046 100644 --- a/resources/js/vue/components/ThemePanel.vue +++ b/resources/js/vue/components/ThemePanel.vue @@ -93,7 +93,9 @@ const fontOptions = computed>(() => ({ const defaultThemeId = computed(() => themes.value[0]?.id ?? 'default'); const selectedThemeId = ref( - (typeof localStorage !== 'undefined' ? localStorage.getItem(THEME_STORAGE_KEY) : null) ?? defaultThemeId.value, + (typeof localStorage !== 'undefined' + ? localStorage.getItem(THEME_STORAGE_KEY) + : null) ?? defaultThemeId.value, ); const currentTheme = computed( @@ -176,7 +178,8 @@ const fields = reactive( themeFields().map((f) => ({ ...f, value: '' })), ); -const uniqueGroups = themeFields().filter((f) => f.group) +const uniqueGroups = themeFields() + .filter((f) => f.group) .map((f) => f.group!) .filter((g, i, arr) => arr.findIndex((x) => x.name === g.name) === i); @@ -233,11 +236,15 @@ function populateFieldsFromTheme(theme: Theme): void { } // Detect actual sidebar sync state from loaded values — never force-apply sync. // This prevents sidebar values from being overwritten with surface values on load. - const allSidebarSynced = SIDEBAR_SYNC_MAP.every(([sidebarKey, sourceKey]) => { - const s = fields.find((f) => f.key === sidebarKey); - const src = fields.find((f) => f.key === sourceKey); - return s?.value !== '' && src?.value !== '' && s?.value === src?.value; - }); + const allSidebarSynced = SIDEBAR_SYNC_MAP.every( + ([sidebarKey, sourceKey]) => { + const s = fields.find((f) => f.key === sidebarKey); + const src = fields.find((f) => f.key === sourceKey); + return ( + s?.value !== '' && src?.value !== '' && s?.value === src?.value + ); + }, + ); for (const f of fields) { if (f.key.startsWith('sidebar') && f.type === 'color') fieldSynced[f.key] = allSidebarSynced; @@ -845,10 +852,14 @@ const dialogCommandOpen = ref(false); {{ $t('Add your own flavor') }}

- +