From 5dd0000ca533dcd91e482cc625aaa3c17aa5afa6 Mon Sep 17 00:00:00 2001 From: roble Date: Fri, 11 Sep 2026 11:05:18 +0100 Subject: [PATCH 1/9] feat: add branding assets and update app views - Added new SVG logo for light background: `logo-on-light.svg`. - Updated app views for both React and Vue to use new branding logic: - Replaced general settings icon retrieval with brand-specific methods. - Updated title and description to use brand properties. - Introduced end-to-end tests for branding assets to ensure correct loading of icons and titles. - Removed outdated dashboard and general settings tests that were reliant on mutable state. --- public/images/icon-on-dark.svg | 23 ++++++ public/images/icon-on-light.svg | 25 ++++++ public/images/logo-on-dark.svg | 64 +++++++++++++++ public/images/logo-on-light.svg | 68 ++++++++++++++++ .../saucebase/stack/react/views/app.blade.php | 28 ++++--- stubs/saucebase/stack/vue/views/app.blade.php | 28 ++++--- tests/e2e/branding.spec.ts | 61 +++++++++++++++ tests/e2e/dashboard.spec.ts | 13 ---- tests/e2e/general-settings.spec.ts | 78 ------------------- 9 files changed, 267 insertions(+), 121 deletions(-) create mode 100644 public/images/icon-on-dark.svg create mode 100644 public/images/icon-on-light.svg create mode 100644 public/images/logo-on-dark.svg create mode 100644 public/images/logo-on-light.svg create mode 100644 tests/e2e/branding.spec.ts delete mode 100644 tests/e2e/dashboard.spec.ts delete mode 100644 tests/e2e/general-settings.spec.ts diff --git a/public/images/icon-on-dark.svg b/public/images/icon-on-dark.svg new file mode 100644 index 00000000..f128cd65 --- /dev/null +++ b/public/images/icon-on-dark.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/icon-on-light.svg b/public/images/icon-on-light.svg new file mode 100644 index 00000000..2eb3e910 --- /dev/null +++ b/public/images/icon-on-light.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/logo-on-dark.svg b/public/images/logo-on-dark.svg new file mode 100644 index 00000000..370da617 --- /dev/null +++ b/public/images/logo-on-dark.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/logo-on-light.svg b/public/images/logo-on-light.svg new file mode 100644 index 00000000..427a7fcb --- /dev/null +++ b/public/images/logo-on-light.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/stubs/saucebase/stack/react/views/app.blade.php b/stubs/saucebase/stack/react/views/app.blade.php index 7407bfd5..be92b879 100644 --- a/stubs/saucebase/stack/react/views/app.blade.php +++ b/stubs/saucebase/stack/react/views/app.blade.php @@ -1,21 +1,19 @@ -@inject('generalSettings', Saucebase\Core\Settings\GeneralSettings::class) ($appearance ?? 'system') == 'dark'])> - {{-- A configured icon replaces the shipped set outright. Sizes are omitted - deliberately: one uploaded image is not a size variant of anything, and - claiming 32x32 for a 512px file makes the browser scale the wrong one. --}} - @if ($generalSettings->siteIconUrl()) - - - @else - - - - @endif + {{-- The icon is always set: it falls back to the artwork core ships, so there is + no unconfigured case to branch on. Light and dark are chosen by the browser + rather than the server, because `appearance` may be `system` — which only + the client can resolve. + + No `sizes`: one image is not a size variant of anything, and claiming 32x32 + for a 512px file makes the browser scale the wrong one. --}} + + + {{-- Detect system dark mode and apply before page renders --}} @@ -43,9 +41,9 @@ {{-- Fallback head elements, rendered only when SSR is inactive. The client component adopts them via the matching data-inertia keys. --}} - {{ $generalSettings->site_name }} - @if ($generalSettings->site_description) - + {{ $brand->site_name }} + @if ($brand->site_description) + @endif diff --git a/stubs/saucebase/stack/vue/views/app.blade.php b/stubs/saucebase/stack/vue/views/app.blade.php index 5b5afb20..89d56395 100644 --- a/stubs/saucebase/stack/vue/views/app.blade.php +++ b/stubs/saucebase/stack/vue/views/app.blade.php @@ -1,21 +1,19 @@ -@inject('generalSettings', Saucebase\Core\Settings\GeneralSettings::class) ($appearance ?? 'system') == 'dark'])> - {{-- A configured icon replaces the shipped set outright. Sizes are omitted - deliberately: one uploaded image is not a size variant of anything, and - claiming 32x32 for a 512px file makes the browser scale the wrong one. --}} - @if ($generalSettings->siteIconUrl()) - - - @else - - - - @endif + {{-- The icon is always set: it falls back to the artwork core ships, so there is + no unconfigured case to branch on. Light and dark are chosen by the browser + rather than the server, because `appearance` may be `system` — which only + the client can resolve. + + No `sizes`: one image is not a size variant of anything, and claiming 32x32 + for a 512px file makes the browser scale the wrong one. --}} + + + {{-- Detect system dark mode and apply before page renders --}} @@ -42,9 +40,9 @@ {{-- Fallback head elements, rendered only when SSR is inactive. The client component adopts them via the matching data-inertia keys. --}} - {{ $generalSettings->site_name }} - @if ($generalSettings->site_description) - + {{ $brand->site_name }} + @if ($brand->site_description) + @endif diff --git a/tests/e2e/branding.spec.ts b/tests/e2e/branding.spec.ts new file mode 100644 index 00000000..453e5747 --- /dev/null +++ b/tests/e2e/branding.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from '@playwright/test'; + +/** + * The brand as a browser actually sees it. + * + * Deliberately read-only. An earlier version of this file wrote to the `settings` table + * to prove that a configured name reached the frontend — which made every other spec + * flaky, because Playwright runs spec files in parallel workers and those settings are + * global to the application. `describe.serial` does not help: it orders tests within one + * file, not across files. + * + * That coverage was not lost, it moved: core's GeneralSettingsTest asserts the same + * thing against Inertia's props, where it costs nothing and cannot race. What is left + * here is what only a browser can answer — that the artwork is reachable and the head + * offers the right variant for the right colour scheme. + */ +test.describe('Branding', () => { + const assets = [ + 'logo-on-light', + 'logo-on-dark', + 'icon-on-light', + 'icon-on-dark', + ]; + + for (const asset of assets) { + test(`ships ${asset}.svg`, async ({ request }) => { + const response = await request.get(`/images/${asset}.svg`); + + // The settings default to these paths, so a 404 here is a broken image on + // every page rather than a missing test fixture. + expect(response.status(), `/images/${asset}.svg`).toBe(200); + expect(response.headers()['content-type']).toContain( + 'image/svg+xml', + ); + }); + } + + test('offers a light and a dark favicon', async ({ page }) => { + await page.goto('/'); + + await expect( + page.locator('link[rel="icon"]:not([media])'), + ).toHaveAttribute('href', '/images/icon-on-light.svg'); + + // The server cannot pick: `appearance` may be `system`, which only the client + // resolves. `media` hands the choice to the browser. + await expect( + page.locator( + 'link[rel="icon"][media="(prefers-color-scheme: dark)"]', + ), + ).toHaveAttribute('href', '/images/icon-on-dark.svg'); + }); + + test('titles the page with the configured site name', async ({ page }) => { + await page.goto('/'); + + // No tagline is set by default, so the name stands alone. Asserting the shipped + // value rather than writing one keeps this spec safe to run beside any other. + await expect(page).toHaveTitle('Saucebase'); + }); +}); diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts deleted file mode 100644 index 8f46e6fa..00000000 --- a/tests/e2e/dashboard.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { expect, test } from '@e2e/fixtures'; - -test.describe('Dashboard page', () => { - test('responds successfully when navigating to dashboard', async ({ - page, - }) => { - const response = await page.goto('/dashboard'); - - expect(response, 'Expected a navigation response').toBeTruthy(); - // Note: This may redirect to login if not authenticated - // Adjust expectations based on your auth requirements - }); -}); diff --git a/tests/e2e/general-settings.spec.ts b/tests/e2e/general-settings.spec.ts deleted file mode 100644 index 864247f4..00000000 --- a/tests/e2e/general-settings.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { expect, test } from '@e2e/fixtures'; - -const setSetting = ( - laravel: { query: (sql: string, bindings: string[]) => Promise }, - name: string, - payload: string, -) => - laravel.query('UPDATE settings SET payload = ? WHERE name = ?', [ - payload, - name, - ]); - -test.describe('General Settings branding', () => { - test.describe.configure({ mode: 'serial' }); - - test('keeps the shipped name watermark two-toned', async ({ page }) => { - await page.goto('/'); - - const watermark = page.getByTestId('footer-watermark'); - - await expect(watermark).toHaveText('Saucebase'); - await expect(watermark.locator('span')).toHaveCount(2); - }); - - test('shares configured branding with the public frontend', async ({ - page, - laravel, - }) => { - await setSetting(laravel, 'site_name', '"Acme Platform"'); - await setSetting(laravel, 'site_tagline', '"Everything you need"'); - await setSetting( - laravel, - 'site_description', - '"The Acme customer platform."', - ); - - try { - await page.goto('/'); - - await expect(page).toHaveTitle( - 'Everything you need - Acme Platform', - ); - await expect(page.getByTestId('footer-app-name')).toContainText( - 'Acme Platform', - ); - await expect(page.getByTestId('footer-watermark')).toHaveText( - 'Acme Platform', - ); - await expect( - page.getByTestId('footer-watermark').locator('span'), - ).toHaveCount(1); - await expect(page.getByTestId('app-description')).toHaveAttribute( - 'content', - 'The Acme customer platform.', - ); - } finally { - await setSetting(laravel, 'site_name', '"Saucebase"'); - await setSetting(laravel, 'site_tagline', 'null'); - await setSetting(laravel, 'site_description', 'null'); - } - }); - - test('falls back to the site name alone when no tagline is set', async ({ - page, - laravel, - }) => { - await setSetting(laravel, 'site_name', '"Acme Platform"'); - await setSetting(laravel, 'site_tagline', 'null'); - - try { - await page.goto('/'); - - await expect(page).toHaveTitle('Acme Platform'); - } finally { - await setSetting(laravel, 'site_name', '"Saucebase"'); - } - }); -}); From 03a9d0ee8a1d86ba1541440e7ad52290bb7d7cbe Mon Sep 17 00:00:00 2001 From: roble Date: Fri, 11 Sep 2026 11:25:32 +0100 Subject: [PATCH 2/9] feat: update AppLogo component to support new icon variant and adjust branding in various components --- resources/js/react/components/AppBrand.tsx | 2 +- resources/js/react/components/AppLogo.tsx | 349 +++---------------- resources/js/react/components/Footer.tsx | 13 +- resources/js/react/components/Header.tsx | 2 +- resources/js/react/layouts/CardLayout.tsx | 2 +- resources/js/settings.ts | 14 +- resources/js/vue/components/AppBrand.vue | 2 +- resources/js/vue/components/AppLogo.vue | 384 +++------------------ resources/js/vue/components/Footer.vue | 6 +- resources/js/vue/components/Header.vue | 2 +- resources/js/vue/layouts/CardLayout.vue | 2 +- tests/e2e/branding.spec.ts | 50 ++- 12 files changed, 163 insertions(+), 665 deletions(-) diff --git a/resources/js/react/components/AppBrand.tsx b/resources/js/react/components/AppBrand.tsx index 9b1e8f14..a1041288 100644 --- a/resources/js/react/components/AppBrand.tsx +++ b/resources/js/react/components/AppBrand.tsx @@ -23,7 +23,7 @@ export default function AppBrand() {
- +
= { sm: 'h-8', md: 'h-12', lg: 'h-16', @@ -27,295 +15,58 @@ const wordmarkSizeClasses = { xxl: 'h-30', }; -const textSizeClasses = { - sm: 'text-xl', - md: 'text-2xl', - lg: 'text-3xl', - xl: 'text-4xl', - xxl: 'text-6xl', -}; - -const subtitleSizeClasses: Record = { - xs: 'text-xs', - sm: 'text-sm', - md: 'text-base', - lg: 'text-lg', - xl: 'text-xl', - xxl: 'text-2xl', +const squares: Record = { + sm: 'w-8', + md: 'w-12', + lg: 'w-16', + xl: 'w-20', + xxl: 'w-30', }; +/** + * The application's brand, as artwork. + * + * Two variants, because two shapes of slot exist: the wide lockup, which carries the + * name inside the artwork, and the square mark, for holes too narrow to read a name in — + * a collapsed sidebar, a workspace row. + * + * Nothing here renders text. The name is in the image. + */ export default function AppLogo({ size = 'md', - showText = false, - centered = false, - variant = 'brand', - showSubtitle = false, - subtitleSize, + variant = 'logo', }: AppLogoProps) { - const [isDark, setIsDark] = useState(() => - document.documentElement.classList.contains('dark'), - ); - - useEffect(() => { - const observer = new MutationObserver(() => { - setIsDark(document.documentElement.classList.contains('dark')); - }); - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ['class'], - }); - return () => observer.disconnect(); - }, []); - - const primaryFill = isDark - ? 'url(#logo-dark-bottom-grad)' - : 'url(#logo-primary-grad)'; - const secondaryFill = isDark - ? 'url(#logo-dark-top-grad)' - : 'url(#logo-secondary-grad)'; - - /** - * The application's identity, and the only thing this component reads. - * - * Anything may stand behind it — the settings a self-hosted install configures, or a - * module that resolves them per request — so the logo never learns which of those is - * answering. - */ const brand = useSettings().general; - - /** - * A wordmark replaces the mark and the name together, but only where there is room - * for one. In a collapsed sidebar it would be illegible, so the icon wins there - * regardless of the preference. - * - * A brand with only a wordmark still gets it used, since the alternative is showing - * somebody else's mark. + const isIcon = variant === 'icon'; + + // Wide artwork keeps its height and lets the width follow; a mark is square. + const classes = isIcon + ? `${heights[size]} ${squares[size]} object-contain` + : `${heights[size]} w-auto max-w-full object-contain`; + + const onLight = isIcon + ? brand.site_icon_on_light + : brand.site_logo_on_light; + const onDark = isIcon ? brand.site_icon_on_dark : brand.site_logo_on_dark; + + /* + * Both variants render and CSS picks. Resolving the theme in JavaScript would flash + * on hydration: `appearance` may be `system`, which the server cannot answer, so SSR + * would embed the light artwork and swap it on the client. The inline script in + * app.blade.php sets `.dark` before first paint, so CSS is right from frame one. */ - const useWordmark = Boolean( - showText && - brand.site_logo && - (brand.prefer_logo || !brand.site_icon), - ); - - /** - * The square slot: the icon by preference, the wordmark squeezed in if that is all - * there is, and otherwise the mark that ships with the application. - */ - const markSrc = brand.site_icon ?? brand.site_logo ?? null; - - /** - * The shipped name is drawn in two tones, which is the mark's other half. An install - * that has renamed itself gets its own name rendered plainly — colouring somebody - * else's word on a seam we chose would read as a bug. - */ - const isShippedName = brand.site_name.toLowerCase() === 'saucebase'; - - /** - * The configured tagline, falling back to the shipped one only while the shipped name - * is still in place. An install that renamed itself and set no tagline gets none, - * rather than our slogan under its own name. - */ - const subtitle = - brand.site_tagline ?? (isShippedName ? 'the recipe that works' : null); - - const logoAlt = `${brand.site_name} logo`; - return ( -
- {/* A brand with a wordmark and room to show it is named by the image. */} - {useWordmark && brand.site_logo ? ( - {brand.site_name} - ) : markSrc ? ( - /* The square slot: the configured icon, or the mark that ships. */ -
- {logoAlt} -
- ) : ( -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- )} - - {/* Skipped when a wordmark is already showing the name. */} - {showText && !useWordmark && ( -
-

- {/* The two tones belong to the shipped name. Anything else is - the install's own, and is rendered as written. */} - {isShippedName ? ( - <> - - sauce - - - base - - - ) : ( - brand.site_name - )} -

- {showSubtitle && subtitle && ( -

- {subtitle} -

- )} -
- )} -
+ <> + {brand.site_name} + {brand.site_name} + ); } diff --git a/resources/js/react/components/Footer.tsx b/resources/js/react/components/Footer.tsx index 063a53dc..d6a8bd76 100644 --- a/resources/js/react/components/Footer.tsx +++ b/resources/js/react/components/Footer.tsx @@ -58,16 +58,9 @@ export default function Footer() { className="text-center text-[21vw] leading-none font-black -tracking-widest select-none" data-testid="footer-watermark" > - {settings.general.site_name === 'Saucebase' ? ( - <> - Sauce - base - - ) : ( - - {settings.general.site_name} - - )} + + {settings.general.site_name} +

diff --git a/resources/js/react/components/Header.tsx b/resources/js/react/components/Header.tsx index 1d47f2bd..a67785bd 100644 --- a/resources/js/react/components/Header.tsx +++ b/resources/js/react/components/Header.tsx @@ -84,7 +84,7 @@ export default function Header() { href="/" className="flex shrink-0 items-center transition-opacity hover:opacity-80" > - + {/* Centered navigation */} diff --git a/resources/js/react/layouts/CardLayout.tsx b/resources/js/react/layouts/CardLayout.tsx index abb794ea..0462838d 100644 --- a/resources/js/react/layouts/CardLayout.tsx +++ b/resources/js/react/layouts/CardLayout.tsx @@ -64,7 +64,7 @@ export default function CardLayout({
- +
diff --git a/resources/js/settings.ts b/resources/js/settings.ts index 114350da..0a373405 100644 --- a/resources/js/settings.ts +++ b/resources/js/settings.ts @@ -14,9 +14,17 @@ export type Settings = { site_name: string; site_tagline: string | null; site_description: string | null; - site_icon: string | null; - site_logo: string | null; - prefer_logo: boolean; + /** + * Always a URL, never null: these fall back to the artwork core ships, so + * nothing rendering them needs an "unconfigured" branch. + * + * The suffix names the background the asset sits on, not the colour of its + * ink — `on_dark` is the light-coloured artwork. + */ + site_logo_on_light: string; + site_logo_on_dark: string; + site_icon_on_light: string; + site_icon_on_dark: string; }; [domain: string]: unknown; }; diff --git a/resources/js/vue/components/AppBrand.vue b/resources/js/vue/components/AppBrand.vue index a241505d..01738f57 100644 --- a/resources/js/vue/components/AppBrand.vue +++ b/resources/js/vue/components/AppBrand.vue @@ -26,7 +26,7 @@ const settings = useSettings();
- +
import { useSettings } from '@/composables/useSettings'; -import { useColorMode } from '@vueuse/core'; import { computed } from 'vue'; -const colorMode = useColorMode({ storageKey: 'appearance' }); - /** - * The application's identity, and the only thing this component reads. + * The application's brand, as artwork. + * + * Two variants, because two shapes of slot exist: the wide lockup, which carries the + * name inside the artwork, and the square mark, for holes too narrow to read a name in — + * a collapsed sidebar, a workspace row. * - * Anything may stand behind it — the settings a self-hosted install configures, or a - * module that resolves them per request — so the logo never learns which of those is - * answering. + * Nothing here renders text. The name is in the image. */ -const settings = useSettings(); -const brand = computed(() => settings.value.general); - -const primaryFill = computed(() => - colorMode.value === 'dark' - ? 'url(#logo-dark-bottom-grad)' - : 'url(#logo-primary-grad)', -); -const secondaryFill = computed(() => - colorMode.value === 'dark' - ? 'url(#logo-dark-top-grad)' - : 'url(#logo-secondary-grad)', +const brand = computed(() => useSettings().value.general); + +const props = withDefaults( + defineProps<{ + size?: Size; + variant?: 'logo' | 'icon'; + }>(), + { size: 'md', variant: 'logo' }, ); -const sizeClasses = { - sm: 'h-8 w-8', - md: 'h-12 w-12', - lg: 'h-16 w-16', - xl: 'h-20 w-20', - xxl: 'h-30 w-30', -}; +type Size = 'sm' | 'md' | 'lg' | 'xl' | 'xxl'; -// A wordmark is wide, so it keeps the height and drops the square width. -const wordmarkSizeClasses = { +const heights: Record = { sm: 'h-8', md: 'h-12', lg: 'h-16', @@ -43,316 +31,50 @@ const wordmarkSizeClasses = { xxl: 'h-30', }; -const props = defineProps<{ - size?: 'sm' | 'md' | 'lg' | 'xl' | 'xxl'; - showText?: boolean; - centered?: boolean; - variant?: 'brand' | 'light'; - showSubtitle?: boolean; - subtitleSize?: 'xs' | 'sm' | 'md' | 'xl' | 'xxl'; -}>(); +const squares: Record = { + sm: 'w-8', + md: 'w-12', + lg: 'w-16', + xl: 'w-20', + xxl: 'w-30', +}; -/** - * A wordmark replaces the mark and the name together, but only where there is room for - * one. In a collapsed sidebar it would be illegible, so the icon wins there regardless - * of the preference. - * - * A brand with only a wordmark still gets it used, since the alternative is showing - * somebody else's mark. - */ -const useWordmark = computed( - () => - Boolean(props.showText) && - Boolean(brand.value.site_logo) && - (brand.value.prefer_logo || !brand.value.site_icon), -); +const isIcon = computed(() => props.variant === 'icon'); -/** - * The square slot: the icon by preference, the wordmark squeezed in if that is all - * there is, and otherwise the mark that ships with the application. - */ -const markSrc = computed( - () => brand.value.site_icon ?? brand.value.site_logo ?? null, +// Wide artwork keeps its height and lets the width follow; a mark is square. +const classes = computed(() => + isIcon.value + ? `${heights[props.size]} ${squares[props.size]} object-contain` + : `${heights[props.size]} w-auto max-w-full object-contain`, ); -/** - * The shipped name is drawn in two tones, which is the mark's other half. An install - * that has renamed itself gets its own name rendered plainly — colouring somebody - * else's word on a seam we chose would read as a bug. - */ -const isShippedName = computed( - () => brand.value.site_name.toLowerCase() === 'saucebase', +const onLight = computed(() => + isIcon.value + ? brand.value.site_icon_on_light + : brand.value.site_logo_on_light, ); - -/** - * The configured tagline, falling back to the shipped one only while the shipped name - * is still in place. An install that renamed itself and set no tagline gets none, rather - * than our slogan under its own name. - */ -const subtitle = computed( - () => - brand.value.site_tagline ?? - (isShippedName.value ? 'the recipe that works' : null), +const onDark = computed(() => + isIcon.value + ? brand.value.site_icon_on_dark + : brand.value.site_logo_on_dark, ); - -const textSizeClasses = { - sm: 'text-xl', - md: 'text-2xl', - lg: 'text-3xl', - xl: 'text-4xl', - xxl: 'text-6xl', -}; - -const subtitleSizeClasses = { - xs: 'text-xs', - sm: 'text-sm', - md: 'text-base', - lg: 'text-lg', - xl: 'text-xl', - xxl: 'text-2xl', -}; - -const logoAlt = computed(() => `${brand.value.site_name} logo`); diff --git a/resources/js/vue/components/Footer.vue b/resources/js/vue/components/Footer.vue index 82433b7f..1c0a5558 100644 --- a/resources/js/vue/components/Footer.vue +++ b/resources/js/vue/components/Footer.vue @@ -58,11 +58,7 @@ const settings = useSettings(); class="text-center text-[min(21vw,400px)] leading-none font-black -tracking-widest select-none" data-testid="footer-watermark" > - - + {{ settings.general.site_name }}

diff --git a/resources/js/vue/components/Header.vue b/resources/js/vue/components/Header.vue index 7c357636..22a2fe0c 100644 --- a/resources/js/vue/components/Header.vue +++ b/resources/js/vue/components/Header.vue @@ -87,7 +87,7 @@ onBeforeUnmount(() => { href="/" class="flex shrink-0 items-center transition-opacity hover:opacity-80" > - + diff --git a/resources/js/vue/layouts/CardLayout.vue b/resources/js/vue/layouts/CardLayout.vue index 63d6c0fe..e9654dc7 100644 --- a/resources/js/vue/layouts/CardLayout.vue +++ b/resources/js/vue/layouts/CardLayout.vue @@ -44,7 +44,7 @@ withDefaults(
- +
diff --git a/tests/e2e/branding.spec.ts b/tests/e2e/branding.spec.ts index 453e5747..9a07186a 100644 --- a/tests/e2e/branding.spec.ts +++ b/tests/e2e/branding.spec.ts @@ -1,18 +1,11 @@ import { expect, test } from '@playwright/test'; /** - * The brand as a browser actually sees it. + * The brand as a browser sees it. * - * Deliberately read-only. An earlier version of this file wrote to the `settings` table - * to prove that a configured name reached the frontend — which made every other spec - * flaky, because Playwright runs spec files in parallel workers and those settings are - * global to the application. `describe.serial` does not help: it orders tests within one - * file, not across files. - * - * That coverage was not lost, it moved: core's GeneralSettingsTest asserts the same - * thing against Inertia's props, where it costs nothing and cannot race. What is left - * here is what only a browser can answer — that the artwork is reachable and the head - * offers the right variant for the right colour scheme. + * This spec must not write to settings. Playwright runs spec files in parallel workers + * against one live application, so a write here changes what every other spec sees — + * and `describe.serial` orders tests within a file, not across files. */ test.describe('Branding', () => { const assets = [ @@ -58,4 +51,39 @@ test.describe('Branding', () => { // value rather than writing one keeps this spec safe to run beside any other. await expect(page).toHaveTitle('Saucebase'); }); + + test('renders the wide lockup, swapping variants by colour scheme', async ({ + page, + }) => { + await page.emulateMedia({ colorScheme: 'light' }); + await page.goto('/'); + + const onLight = page.locator('img[src="/images/logo-on-light.svg"]'); + const onDark = page.locator('img[src="/images/logo-on-dark.svg"]'); + + // Both are in the DOM; CSS decides which one is painted. Resolving the theme in + // JavaScript instead would flash on hydration, because `appearance: system` is + // not something the server can answer. + await expect(onLight).toBeVisible(); + await expect(onDark).toBeHidden(); + + await page.emulateMedia({ colorScheme: 'dark' }); + await page.reload(); + + await expect(onDark).toBeVisible(); + await expect(onLight).toBeHidden(); + }); + + test('draws the name as artwork, not as text beside a mark', async ({ + page, + }) => { + await page.goto('/'); + + // The lockup carries the name itself. An inline here would mean the old + // component came back, and with it the hardcoded two-tone "sauce"/"base" split + // that only ever rendered correctly for one brand. + await expect( + page.locator('img[src="/images/logo-on-light.svg"]'), + ).toHaveAttribute('alt', 'Saucebase'); + }); }); From c7f39416d2f14168e33f0f054a4015ad437216a6 Mon Sep 17 00:00:00 2001 From: roble Date: Fri, 11 Sep 2026 11:53:22 +0100 Subject: [PATCH 3/9] Refactor code structure for improved readability and maintainability --- public/images/icon-on-dark.svg | 16 ++++++++------ public/images/logo-on-dark.svg | 39 ++++++++++++++++++++++------------ 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/public/images/icon-on-dark.svg b/public/images/icon-on-dark.svg index f128cd65..5e2dddc5 100644 --- a/public/images/icon-on-dark.svg +++ b/public/images/icon-on-dark.svg @@ -8,16 +8,18 @@ - - - - - - - + + + + + + + + + diff --git a/public/images/logo-on-dark.svg b/public/images/logo-on-dark.svg index 370da617..51c189c5 100644 --- a/public/images/logo-on-dark.svg +++ b/public/images/logo-on-dark.svg @@ -8,13 +8,20 @@ - + - - + + - - + + + + + + + + + @@ -24,35 +31,35 @@ - + - + - + - + - + - + - + - + - + @@ -61,4 +68,8 @@ + + + + From e1a04e2cb00d7e4ca47c284bace59413c0aaf8a3 Mon Sep 17 00:00:00 2001 From: roble Date: Fri, 11 Sep 2026 12:11:24 +0100 Subject: [PATCH 4/9] fix: update meta description retrieval in app views for better consistency --- stubs/saucebase/stack/react/views/app.blade.php | 4 ++-- stubs/saucebase/stack/vue/views/app.blade.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/stubs/saucebase/stack/react/views/app.blade.php b/stubs/saucebase/stack/react/views/app.blade.php index be92b879..8ff2de6a 100644 --- a/stubs/saucebase/stack/react/views/app.blade.php +++ b/stubs/saucebase/stack/react/views/app.blade.php @@ -42,8 +42,8 @@ component adopts them via the matching data-inertia keys. --}} {{ $brand->site_name }} - @if ($brand->site_description) - + @if ($description = $brand->metaDescription()) + @endif diff --git a/stubs/saucebase/stack/vue/views/app.blade.php b/stubs/saucebase/stack/vue/views/app.blade.php index 89d56395..56e4175e 100644 --- a/stubs/saucebase/stack/vue/views/app.blade.php +++ b/stubs/saucebase/stack/vue/views/app.blade.php @@ -41,8 +41,8 @@ component adopts them via the matching data-inertia keys. --}} {{ $brand->site_name }} - @if ($brand->site_description) - + @if ($description = $brand->metaDescription()) + @endif From 147ff38b953f4b61d9008548c56b46d54abc6c0f Mon Sep 17 00:00:00 2001 From: roble Date: Fri, 11 Sep 2026 17:05:04 +0100 Subject: [PATCH 5/9] fix: update namespace imports for Navigation and Section to use Saucebase\Core --- stubs/saucebase/recipes/basic/routes/navigation.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stubs/saucebase/recipes/basic/routes/navigation.php b/stubs/saucebase/recipes/basic/routes/navigation.php index b001499a..e3e81ef3 100644 --- a/stubs/saucebase/recipes/basic/routes/navigation.php +++ b/stubs/saucebase/recipes/basic/routes/navigation.php @@ -1,7 +1,7 @@ Date: Fri, 11 Sep 2026 17:46:11 +0100 Subject: [PATCH 6/9] chore: update dependencies in composer.json and composer.lock for improved compatibility --- composer.json | 20 ++++------- composer.lock | 91 ++++++++++++++++++++++++--------------------------- 2 files changed, 48 insertions(+), 63 deletions(-) diff --git a/composer.json b/composer.json index a682df0e..797e24e5 100644 --- a/composer.json +++ b/composer.json @@ -6,21 +6,21 @@ "license": "MIT", "require": { "php": "^8.4", - "laravel/framework": "^13.30.1", + "laravel/framework": "^13.31.0", "laravel/tinker": "^3.0.2", - "saucebase/core": "*" + "saucebase/core": "^1.0" }, "require-dev": { "barryvdh/laravel-ide-helper": "^3.7", "fakerphp/faker": "^1.24.1", "kkomelin/laravel-translatable-string-exporter": "^1.26", - "larastan/larastan": "^3.11", - "laravel/boost": "^2.7.1", + "larastan/larastan": "^3.12", + "laravel/boost": "^2.8.1", "laravel/pail": "^1.2.7", - "laravel/pint": "^1.30.5", + "laravel/pint": "^1.32.1", "mockery/mockery": "^1.6.15", "nunomaduro/collision": "^8.9.5", - "phpunit/phpunit": "^12.5.34", + "phpunit/phpunit": "^12.5.35", "saucebase/laravel-playwright": "^1.1" }, "suggest": { @@ -126,14 +126,6 @@ "saas boilerplate" ], "repositories": [ - { - "type": "path", - "url": "../packages/*", - "canonical": false, - "options": { - "symlink": true - } - }, { "type": "path", "url": "modules/*", diff --git a/composer.lock b/composer.lock index 607b2515..1fec9141 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c2270dda5abc5024f5143b8d7d7815d0", + "content-hash": "22ffbad8e2dfd850e08c04fdc3a3a1b3", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -2924,16 +2924,16 @@ }, { "name": "inertiajs/inertia-laravel", - "version": "v3.3.3", + "version": "v3.3.4", "source": { "type": "git", "url": "https://github.com/inertiajs/inertia-laravel.git", - "reference": "42dccee77d7df6b2e152965434a89cafc35a2961" + "reference": "15fb5a7b2f984780ff968d9da3787aef6138326d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/42dccee77d7df6b2e152965434a89cafc35a2961", - "reference": "42dccee77d7df6b2e152965434a89cafc35a2961", + "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/15fb5a7b2f984780ff968d9da3787aef6138326d", + "reference": "15fb5a7b2f984780ff968d9da3787aef6138326d", "shasum": "" }, "require": { @@ -2990,9 +2990,9 @@ ], "support": { "issues": "https://github.com/inertiajs/inertia-laravel/issues", - "source": "https://github.com/inertiajs/inertia-laravel/tree/v3.3.3" + "source": "https://github.com/inertiajs/inertia-laravel/tree/v3.3.4" }, - "time": "2026-09-04T13:56:39+00:00" + "time": "2026-09-11T13:57:53+00:00" }, { "name": "inertiaui/modal", @@ -6839,11 +6839,17 @@ }, { "name": "saucebase/core", - "version": "0.1.0", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/saucebase-dev/core.git", + "reference": "c8e7188d98f95bdf6a3c0cda732ee893f8043e7d" + }, "dist": { - "type": "path", - "url": "../packages/core", - "reference": "2d66a0fbe99b280678f5a7c01fc3ace065e1b70b" + "type": "zip", + "url": "https://api.github.com/repos/saucebase-dev/core/zipball/c8e7188d98f95bdf6a3c0cda732ee893f8043e7d", + "reference": "c8e7188d98f95bdf6a3c0cda732ee893f8043e7d", + "shasum": "" }, "require": { "cviebrock/eloquent-sluggable": "^13.0.1", @@ -6868,6 +6874,7 @@ "tightenco/ziggy": "^2.6.4" }, "require-dev": { + "larastan/larastan": "^3.11", "laravel/pint": "^1.30.5", "orchestra/testbench": "^11.0", "phpunit/phpunit": "^12.5.34" @@ -6884,29 +6891,14 @@ } }, "autoload": { - "psr-4": { - "Saucebase\\Core\\": "src/" - }, "files": [ "src/Helpers/helpers.php" - ] - }, - "autoload-dev": { + ], "psr-4": { - "Saucebase\\Core\\Tests\\": "tests/" + "Saucebase\\Core\\": "src/" } }, - "scripts": { - "test": [ - "phpunit" - ], - "lint": [ - "pint" - ], - "lint:test": [ - "pint --test" - ] - }, + "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], @@ -6926,10 +6918,11 @@ "saas", "saucebase" ], - "transport-options": { - "symlink": true, - "relative": true - } + "support": { + "issues": "https://github.com/saucebase-dev/core/issues", + "source": "https://github.com/saucebase-dev/core/tree/v1.0.0" + }, + "time": "2026-09-11T16:27:28+00:00" }, { "name": "saucebase/module-installer", @@ -12126,16 +12119,16 @@ }, { "name": "larastan/larastan", - "version": "v3.11.0", + "version": "v3.12.0", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "9baa74074f17cc70feaef31616a06ea0faeebba5" + "reference": "ab26fdf90d84877061388109e1e251f67ca3ff7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/9baa74074f17cc70feaef31616a06ea0faeebba5", - "reference": "9baa74074f17cc70feaef31616a06ea0faeebba5", + "url": "https://api.github.com/repos/larastan/larastan/zipball/ab26fdf90d84877061388109e1e251f67ca3ff7a", + "reference": "ab26fdf90d84877061388109e1e251f67ca3ff7a", "shasum": "" }, "require": { @@ -12203,7 +12196,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v3.11.0" + "source": "https://github.com/larastan/larastan/tree/v3.12.0" }, "funding": [ { @@ -12211,20 +12204,20 @@ "type": "github" } ], - "time": "2026-09-01T16:35:48+00:00" + "time": "2026-09-10T09:06:54+00:00" }, { "name": "laravel/boost", - "version": "v2.8.0", + "version": "v2.8.1", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "ac0b51fbaf3cc19453aef7a1b188bce19a1ec049" + "reference": "820bc93b7826c456ca591cfecca463b4dc794a72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/ac0b51fbaf3cc19453aef7a1b188bce19a1ec049", - "reference": "ac0b51fbaf3cc19453aef7a1b188bce19a1ec049", + "url": "https://api.github.com/repos/laravel/boost/zipball/820bc93b7826c456ca591cfecca463b4dc794a72", + "reference": "820bc93b7826c456ca591cfecca463b4dc794a72", "shasum": "" }, "require": { @@ -12277,7 +12270,7 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-09-08T17:07:40+00:00" + "time": "2026-09-10T15:25:39+00:00" }, { "name": "laravel/mcp", @@ -12435,16 +12428,16 @@ }, { "name": "laravel/pint", - "version": "v1.32.0", + "version": "v1.32.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "dea4e95f0f1722b8fec9eaabc8fdd676d71e8841" + "reference": "006bd7825eb01daf92959d060b89cd9514fe4253" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/dea4e95f0f1722b8fec9eaabc8fdd676d71e8841", - "reference": "dea4e95f0f1722b8fec9eaabc8fdd676d71e8841", + "url": "https://api.github.com/repos/laravel/pint/zipball/006bd7825eb01daf92959d060b89cd9514fe4253", + "reference": "006bd7825eb01daf92959d060b89cd9514fe4253", "shasum": "" }, "require": { @@ -12501,7 +12494,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-09-09T16:26:47+00:00" + "time": "2026-09-10T10:47:55+00:00" }, { "name": "laravel/roster", From 4f9892bbceb2335baf9aa4dc15e5b488b413e3a4 Mon Sep 17 00:00:00 2001 From: roble Date: Fri, 11 Sep 2026 20:44:40 +0100 Subject: [PATCH 7/9] feat: update Taskfile to include PHP and E2E test tasks --- Taskfile.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Taskfile.yml b/Taskfile.yml index 1b470054..5e01d696 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -118,4 +118,5 @@ tasks: cmds: - task: lint - task: analyse - - task: test + - task: test:php + - task: test:e2e From f837ac90f875b8e10692179cff9043dbe44b4cd0 Mon Sep 17 00:00:00 2001 From: roble Date: Sat, 12 Sep 2026 16:35:31 +0100 Subject: [PATCH 8/9] feat: implement theme transition animations and update logo sizes for improved UI consistency --- resources/css/transitions.css | 28 +++++ resources/js/react/components/AppBrand.tsx | 21 ++-- resources/js/react/components/AppLogo.tsx | 4 +- resources/js/react/components/Header.tsx | 19 ++- .../js/react/components/ThemeSelector.tsx | 28 ++--- resources/js/react/hooks/useTheme.ts | 108 +++++++++++------- resources/js/vue/components/AppBrand.vue | 22 ++-- resources/js/vue/components/AppLogo.vue | 4 +- resources/js/vue/components/Header.vue | 15 ++- resources/js/vue/components/ThemeSelector.vue | 93 ++++++++------- 10 files changed, 203 insertions(+), 139 deletions(-) diff --git a/resources/css/transitions.css b/resources/css/transitions.css index dfb9ed3b..db0801a1 100644 --- a/resources/css/transitions.css +++ b/resources/css/transitions.css @@ -22,6 +22,34 @@ mix-blend-mode: normal; } +/* + * The new theme is revealed by a circle growing from the option that was clicked. + * The selector sets the three custom properties and the attribute; keeping the + * animation here rather than in a component keeps Vue and React on one copy. + */ +@keyframes theme-circle-reveal { + 0% { + clip-path: circle(0% at var(--theme-reveal-x) var(--theme-reveal-y)); + } + 40% { + clip-path: circle(40% at var(--theme-reveal-x) var(--theme-reveal-y)); + } + 100% { + clip-path: circle( + var(--theme-reveal-radius) at var(--theme-reveal-x) + var(--theme-reveal-y) + ); + } +} + +html[data-theme-reveal]::view-transition-group(root) { + animation: none; +} + +html[data-theme-reveal]::view-transition-new(root) { + animation: theme-circle-reveal 1500ms cubic-bezier(0.25, 1, 0.3, 1) both; +} + /* Default: new theme layer on top (for light to dark) */ ::view-transition-old(root) { z-index: 1; diff --git a/resources/js/react/components/AppBrand.tsx b/resources/js/react/components/AppBrand.tsx index a1041288..59fc1ac5 100644 --- a/resources/js/react/components/AppBrand.tsx +++ b/resources/js/react/components/AppBrand.tsx @@ -3,7 +3,6 @@ import { SidebarMenuButton, SidebarMenuItem, } from '@/components/ui/sidebar'; -import { useSettings } from '@/hooks/useSettings'; import { Link } from '@inertiajs/react'; import AppLogo from './AppLogo'; @@ -13,26 +12,22 @@ import AppLogo from './AppLogo'; * What fills the `sidebar-brand` slot when no module claims it. A module with something * better to put there registers over it, so core never needs to know whether any * particular module is installed. + * + * The wide logo already carries the name; the square icon takes over when the sidebar + * collapses to icons. */ export default function AppBrand() { - const settings = useSettings(); - return ( -
+ + + + -
-
- - {settings.general.site_name} - -
+
diff --git a/resources/js/react/components/AppLogo.tsx b/resources/js/react/components/AppLogo.tsx index 38dc8e1e..87641dc6 100644 --- a/resources/js/react/components/AppLogo.tsx +++ b/resources/js/react/components/AppLogo.tsx @@ -9,7 +9,7 @@ interface AppLogoProps { const heights: Record = { sm: 'h-8', - md: 'h-12', + md: 'h-10', lg: 'h-16', xl: 'h-20', xxl: 'h-30', @@ -17,7 +17,7 @@ const heights: Record = { const squares: Record = { sm: 'w-8', - md: 'w-12', + md: 'w-10', lg: 'w-16', xl: 'w-20', xxl: 'w-30', diff --git a/resources/js/react/components/Header.tsx b/resources/js/react/components/Header.tsx index a67785bd..e3a5ed6a 100644 --- a/resources/js/react/components/Header.tsx +++ b/resources/js/react/components/Header.tsx @@ -5,11 +5,20 @@ import type { MenuItem } from '@/types/navigation'; import { Link, usePage } from '@inertiajs/react'; import { ModalLink } from '@inertiaui/modal-react'; import { ArrowRight, ExternalLink, Menu, X } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type MouseEvent } from 'react'; import AppLogo from './AppLogo'; import LanguageSelector from './LanguageSelector'; import ThemeSelector from './ThemeSelector'; +/** + * The modal marks `#app` `aria-hidden` as it opens, and its focus trap only + * claims focus once the panel has loaded. Leaving focus on the trigger in that + * gap puts a focused element inside an aria-hidden subtree. + */ +function blurTrigger(event: MouseEvent) { + (event.currentTarget as HTMLElement).blur(); +} + export default function Header() { const t = useT(); const page = usePage(); @@ -42,7 +51,9 @@ export default function Header() { * shared link lands on the full page. */ const AuthLink = auth?.modal_enabled ? ModalLink : Link; - const authLinkProps = auth?.modal_enabled ? { navigate: true } : {}; + const authLinkProps = auth?.modal_enabled + ? { navigate: true, onClick: blurTrigger } + : {}; useEffect(() => { const handleScroll = () => { @@ -146,7 +157,9 @@ export default function Header() { {route().has('logout') && isLoggedIn && ( {t('Logout')} diff --git a/resources/js/react/components/ThemeSelector.tsx b/resources/js/react/components/ThemeSelector.tsx index bd589ac7..674c601e 100644 --- a/resources/js/react/components/ThemeSelector.tsx +++ b/resources/js/react/components/ThemeSelector.tsx @@ -9,9 +9,9 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { useTheme, type Theme } from '@/hooks/useTheme'; +import { transitionOrigin, useTheme, type Theme } from '@/hooks/useTheme'; import { useT } from '@/i18n'; -import { useRef, type ReactNode } from 'react'; +import { type MouseEvent, type ReactNode } from 'react'; import IconAuto from '~icons/fluent/dark-theme-20-filled'; import IconMoon from '~icons/heroicons/moon'; import IconSun from '~icons/heroicons/sun'; @@ -42,7 +42,6 @@ export default function ThemeSelector({ }: ThemeSelectorProps) { const t = useT(); const { theme, setTheme } = useTheme(); - const triggerRef = useRef(null); const visibleThemes = hideDevice ? themes.filter((th) => th.code !== 'auto') @@ -50,13 +49,8 @@ export default function ThemeSelector({ const currentTheme = themes.find((th) => th.code === theme) ?? themes[0]; const CurrentIcon = currentTheme.Icon; - function switchTheme(code: Theme, el?: HTMLElement) { - setTheme( - code, - disableAnimation - ? undefined - : (el ?? triggerRef.current ?? undefined), - ); + function switchTheme(code: Theme, event: MouseEvent) { + setTheme(code, transitionOrigin(event), !disableAnimation); } if (inline) { @@ -70,7 +64,7 @@ export default function ThemeSelector({ className={`${fullWidth ? 'flex-1' : ''} ${theme === code ? 'font-semibold' : ''}`} data-testid={`color-mode-${code}`} aria-label={t(name)} - onClick={(e) => switchTheme(code, e.currentTarget)} + onClick={(event) => switchTheme(code, event)} > {t(name)} @@ -85,7 +79,6 @@ export default function ThemeSelector({