Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ jobs:
test:
uses: saucebase-dev/saucebase/.github/workflows/test-module.yml@main
with:
module: Themes
module: themes
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2025 Saucebase
Copyright (c) 2026 Saucebase

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
10 changes: 8 additions & 2 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,17 @@ 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

# ── Database ──────────────────────────────────────────────────

db:seed:
desc: Seed the Themes module database
cmd: php artisan modules:seed --module=themes

# ── 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
15 changes: 0 additions & 15 deletions app/Providers/RouteServiceProvider.php

This file was deleted.

16 changes: 11 additions & 5 deletions composer.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{
"name": "saucebase/themes",
"description": "",
"description": "Themes module",
"type": "saucebase-module",
"license": "proprietary",
"version": "1.2.2",
Comment on lines +3 to +6
"authors": [
{
"name": "Saucebase",
Expand All @@ -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"
}
15 changes: 0 additions & 15 deletions module.json

This file was deleted.

10 changes: 1 addition & 9 deletions resources/js/app.ts
Original file line number Diff line number Diff line change
@@ -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';
9 changes: 9 additions & 0 deletions resources/js/vue/app.ts
Original file line number Diff line number Diff line change
@@ -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() {}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -75,7 +75,8 @@ const http = useHttp({
dark: {} as Record<string, string>,
},
});
const isDark = useDark();
const colorMode = useColorMode({ storageKey: 'appearance' });
const isDark = computed(() => colorMode.value === 'dark');
const { confirm } = useDialog();

const themesEnabled = computed(() => page.props?.themes != null);
Expand All @@ -92,7 +93,9 @@ const fontOptions = computed<Record<string, Font[]>>(() => ({
const defaultThemeId = computed(() => themes.value[0]?.id ?? 'default');

const selectedThemeId = ref<string>(
(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<Theme | null>(
Expand Down Expand Up @@ -175,7 +178,8 @@ const fields = reactive<FieldState[]>(
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);

Expand Down Expand Up @@ -230,14 +234,20 @@ 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]),
Expand Down Expand Up @@ -649,7 +659,7 @@ const SIDEBAR_SYNC_MAP: [string, string][] = [
['sidebar-ring', 'ring'],
];

const sidebarSynced = ref<boolean | null>(true);
const sidebarSynced = ref<boolean | null>(false);

function applySidebarSync(): void {
for (const [sidebarKey, sourceKey] of SIDEBAR_SYNC_MAP) {
Expand Down Expand Up @@ -842,10 +852,14 @@ const dialogCommandOpen = ref(false);
{{ $t('Add your own flavor') }}
</p>
</div>
<ThemeSelector class="order-4 sm:order-3 w-full sm:w-auto" inline hide-device />
<ThemeSelector
class="order-4 w-full sm:order-3 sm:w-auto"
inline
hide-device
/>
<button
data-testid="theme-panel-close"
class="order-3 sm:order-4 text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring rounded-md p-1 transition-colors focus-visible:ring-2 focus-visible:outline-none"
class="text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring order-3 rounded-md p-1 transition-colors focus-visible:ring-2 focus-visible:outline-none sm:order-4"
:aria-label="$t('Close theme panel')"
@click="sheetOpen = false"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -63,7 +63,7 @@ function select(id: string) {
<PopoverTrigger as-child>
<button
data-testid="theme-picker-trigger"
class="border-border bg-input hover:bg-input/70 hover:text-input focus-visible:ring-ring flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-sm shadow-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
class="border-border bg-input hover:bg-input/70 hover:text-input focus-visible:ring-ring flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-sm shadow-[0_2px_8px_rgba(0,0,0,0.12),0_1px_3px_rgba(0,0,0,0.08)] transition-colors focus-visible:ring-2 focus-visible:outline-none dark:shadow-[0_2px_8px_rgba(0,0,0,0.35),0_1px_3px_rgba(0,0,0,0.2)]"
>
<ThemeColorSwatch
:preview="currentTheme.preview"
Expand Down
9 changes: 5 additions & 4 deletions resources/js/fields.ts → resources/js/vue/fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { trans } from 'laravel-vue-i18n';
import type { FieldGroup, ThemeField } from './types';

export const themeFields = (): ThemeField[] => {

const BRAND_GROUP: FieldGroup = { name: trans('Brand'), syncable: true };
const SURFACES_GROUP: FieldGroup = {
name: trans('Surfaces'),
Expand All @@ -24,7 +23,10 @@ export const themeFields = (): ThemeField[] => {
};
const SHAPE_GROUP: FieldGroup = { name: trans('Shape'), collapsed: true };
const SHADOW_GROUP: FieldGroup = { name: trans('Shadow'), collapsed: true };
const SIDEBAR_GROUP: FieldGroup = { name: trans('Sidebar'), collapsed: true };
const SIDEBAR_GROUP: FieldGroup = {
name: trans('Sidebar'),
collapsed: true,
};
const CHART_GROUP: FieldGroup = {
name: trans('Chart'),
collapsed: true,
Expand Down Expand Up @@ -374,7 +376,6 @@ export const themeFields = (): ThemeField[] => {
group: CHART_GROUP,
},
];

};

export default themeFields;
export default themeFields;
15 changes: 8 additions & 7 deletions resources/js/utils/theme.ts → resources/js/vue/utils/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ export function applyThemeVars(theme: Theme | null, isDark: boolean): void {
themeFieldsList.filter((f) => f.type === 'font').flatMap((f) => f.vars),
);


// Clear all inline CSS vars so stylesheet defaults can take over when theme is null
const toRemove: string[] = [];
for (let i = 0; i < el.style.length; i++) {
Expand Down Expand Up @@ -94,7 +93,9 @@ export function applyThemeVars(theme: Theme | null, isDark: boolean): void {
// remain consistent regardless of which mode is active
Object.entries(lightVars).forEach(([key, value]) => {
const nonColorVars = new Set(
themeFieldsList.filter((f) => f.type !== 'color').flatMap((f) => f.vars),
themeFieldsList
.filter((f) => f.type !== 'color')
.flatMap((f) => f.vars),
);

if (!nonColorVars.has(key)) return;
Expand All @@ -114,8 +115,8 @@ export function applyThemeVars(theme: Theme | null, isDark: boolean): void {
if (shadowColor) {
const opacity = parseFloat(
modeVars['--shadow-opacity'] ??
lightVars['--shadow-opacity'] ??
'0.2',
lightVars['--shadow-opacity'] ??
'0.2',
);
const blur = parseFloat(lightVars['--shadow-blur'] ?? '30');
const spread = parseFloat(lightVars['--shadow-spread'] ?? '-10');
Expand Down Expand Up @@ -236,9 +237,9 @@ export function computeRadiusScale(
px === 0
? [key, radiusValue]
: [
key,
`calc(${radiusValue} ${px > 0 ? '+' : '-'} ${Math.abs(px)}px)`,
],
key,
`calc(${radiusValue} ${px > 0 ? '+' : '-'} ${Math.abs(px)}px)`,
],
),
);
}
Expand Down
16 changes: 9 additions & 7 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
File renamed without changes.
Loading
Loading