diff --git a/app/Http/Middleware/HandleAppearance.php b/app/Http/Middleware/HandleAppearance.php index 1a01684..de0b96b 100644 --- a/app/Http/Middleware/HandleAppearance.php +++ b/app/Http/Middleware/HandleAppearance.php @@ -16,7 +16,11 @@ class HandleAppearance */ public function handle(Request $request, Closure $next): Response { - View::share('appearance', $request->cookie('appearance') ?? 'system'); + $appearance = $request->cookie('appearance'); + $appearance = in_array($appearance, ['light', 'dark', 'system'], true) ? $appearance : 'system'; + + $request->attributes->set('appearance', $appearance); + View::share('appearance', $appearance); return $next($request); } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index a35eeb6..d980988 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -46,6 +46,7 @@ class HandleInertiaRequests extends Middleware return [ ...parent::share($request), 'name' => config('app.name'), + 'appearance' => $request->attributes->get('appearance', 'system'), 'localization' => [ 'locale' => app()->getLocale(), 'timeZone' => config('app.timezone'), diff --git a/public/fonts/inter/InterVariable-Italic.woff2 b/public/fonts/inter/InterVariable-Italic.woff2 new file mode 100644 index 0000000..b3530f3 Binary files /dev/null and b/public/fonts/inter/InterVariable-Italic.woff2 differ diff --git a/public/fonts/inter/InterVariable.woff2 b/public/fonts/inter/InterVariable.woff2 new file mode 100644 index 0000000..5a8d3e7 Binary files /dev/null and b/public/fonts/inter/InterVariable.woff2 differ diff --git a/public/fonts/inter/LICENSE.txt b/public/fonts/inter/LICENSE.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/public/fonts/inter/LICENSE.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/resources/css/app.css b/resources/css/app.css index 6f9d1f5..2949d41 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,56 +1,98 @@ @import 'tailwindcss'; - @import 'tw-animate-css'; +@import './theme.css'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../storage/framework/views/*.php'; +@source not '../../bootstrap/ssr'; -@custom-variant dark (&:is(.dark *)); +@custom-variant dark (&:where(.dark, .dark *)); + +@font-face { + font-family: Inter; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url('/fonts/inter/InterVariable.woff2') format('woff2'); +} + +@font-face { + font-family: Inter; + font-style: italic; + font-weight: 100 900; + font-display: swap; + src: url('/fonts/inter/InterVariable-Italic.woff2') format('woff2'); +} @theme inline { - --font-sans: - Instrument Sans, ui-sans-serif, system-ui, sans-serif, - 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', - 'Noto Color Emoji'; + --font-sans: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'; - --radius-lg: var(--radius); - --radius-md: calc(var(--radius) - 2px); - --radius-sm: calc(var(--radius) - 4px); + --radius-sm: 0.125rem; + --radius-md: var(--radius); + --radius-lg: 0.5rem; + --radius-xl: 0.75rem; + + --spacing-page: 1.5rem; + --spacing-column: 1rem; + --spacing-card: 0.75rem; + --spacing-stack: 0.5rem; + --spacing-control: 2.75rem; + + --text-headline: 1.5rem; + --text-headline--line-height: 2rem; + --text-headline--font-weight: 600; + --text-headline--letter-spacing: -0.02em; + --text-headline-mobile: 1.25rem; + --text-headline-mobile--line-height: 1.75rem; + --text-headline-mobile--font-weight: 600; + --text-headline-mobile--letter-spacing: -0.02em; + --text-title: 1.125rem; + --text-title--line-height: 1.5rem; + --text-title--font-weight: 600; + --text-title--letter-spacing: -0.01em; + --text-body: 0.875rem; + --text-body--line-height: 1.25rem; + --text-caption: 0.8125rem; + --text-caption--line-height: 1.125rem; + --text-label: 0.6875rem; + --text-label--line-height: 1rem; + --text-label--font-weight: 700; + --text-label--letter-spacing: 0.05em; + + --shadow-card-hover: 0 4px 12px rgb(0 0 0 / 0.08); --color-background: var(--background); --color-foreground: var(--foreground); - --color-card: var(--card); --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); - --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); - + --color-brand: var(--brand); + --color-brand-foreground: var(--brand-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-info: var(--info); + --color-info-foreground: var(--info-foreground); --color-chart-1: var(--chart-1); --color-chart-2: var(--chart-2); --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); - --color-sidebar: var(--sidebar-background); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); @@ -61,112 +103,35 @@ --color-sidebar-ring: var(--sidebar-ring); } -/* - The default border color has changed to `currentColor` in Tailwind CSS v4, - so we've added these compatibility styles to make sure everything still - looks the same as it did with Tailwind CSS v3. - - If we ever want to remove these styles, we need to add an explicit border - color utility to any element that depends on these defaults. -*/ -@layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentColor); - } -} - -@layer utilities { - body, - html { - --font-sans: - 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, - 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', - 'Noto Color Emoji'; - } -} - -:root { - --background: hsl(0 0% 100%); - --foreground: hsl(0 0% 3.9%); - --card: hsl(0 0% 100%); - --card-foreground: hsl(0 0% 3.9%); - --popover: hsl(0 0% 100%); - --popover-foreground: hsl(0 0% 3.9%); - --primary: hsl(0 0% 9%); - --primary-foreground: hsl(0 0% 98%); - --secondary: hsl(0 0% 92.1%); - --secondary-foreground: hsl(0 0% 9%); - --muted: hsl(0 0% 96.1%); - --muted-foreground: hsl(0 0% 45.1%); - --accent: hsl(0 0% 96.1%); - --accent-foreground: hsl(0 0% 9%); - --destructive: hsl(0 84.2% 60.2%); - --destructive-foreground: hsl(0 0% 98%); - --border: hsl(0 0% 92.8%); - --input: hsl(0 0% 89.8%); - --ring: hsl(0 0% 3.9%); - --chart-1: hsl(12 76% 61%); - --chart-2: hsl(173 58% 39%); - --chart-3: hsl(197 37% 24%); - --chart-4: hsl(43 74% 66%); - --chart-5: hsl(27 87% 67%); - --radius: 0.5rem; - --sidebar-background: hsl(0 0% 98%); - --sidebar-foreground: hsl(240 5.3% 26.1%); - --sidebar-primary: hsl(0 0% 10%); - --sidebar-primary-foreground: hsl(0 0% 98%); - --sidebar-accent: hsl(0 0% 94%); - --sidebar-accent-foreground: hsl(0 0% 30%); - --sidebar-border: hsl(0 0% 91%); - --sidebar-ring: hsl(217.2 91.2% 59.8%); - --sidebar: hsl(0 0% 98%); -} - -.dark { - --background: hsl(0 0% 3.9%); - --foreground: hsl(0 0% 98%); - --card: hsl(0 0% 3.9%); - --card-foreground: hsl(0 0% 98%); - --popover: hsl(0 0% 3.9%); - --popover-foreground: hsl(0 0% 98%); - --primary: hsl(0 0% 98%); - --primary-foreground: hsl(0 0% 9%); - --secondary: hsl(0 0% 14.9%); - --secondary-foreground: hsl(0 0% 98%); - --muted: hsl(0 0% 16.08%); - --muted-foreground: hsl(0 0% 63.9%); - --accent: hsl(0 0% 14.9%); - --accent-foreground: hsl(0 0% 98%); - --destructive: hsl(0 84% 60%); - --destructive-foreground: hsl(0 0% 98%); - --border: hsl(0 0% 14.9%); - --input: hsl(0 0% 14.9%); - --ring: hsl(0 0% 83.1%); - --chart-1: hsl(220 70% 50%); - --chart-2: hsl(160 60% 45%); - --chart-3: hsl(30 80% 55%); - --chart-4: hsl(280 65% 60%); - --chart-5: hsl(340 75% 55%); - --sidebar-background: hsl(0 0% 7%); - --sidebar-foreground: hsl(0 0% 95.9%); - --sidebar-primary: hsl(360, 100%, 100%); - --sidebar-primary-foreground: hsl(0 0% 100%); - --sidebar-accent: hsl(0 0% 15.9%); - --sidebar-accent-foreground: hsl(240 4.8% 95.9%); - --sidebar-border: hsl(0 0% 15.9%); - --sidebar-ring: hsl(217.2 91.2% 59.8%); - --sidebar: hsl(240 5.9% 10%); -} - @layer base { * { - @apply border-border outline-ring/50; + @apply border-border; } + body { - @apply bg-background text-foreground; + @apply bg-background font-sans text-body text-foreground antialiased; + font-optical-sizing: auto; + } + + :focus-visible { + outline: 2px solid var(--ring); + outline-offset: 3px; + } + + ::selection { + background: var(--brand); + color: var(--brand-foreground); + } +} + +/* Preserve completion events while removing visible nonessential motion. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; } } diff --git a/resources/css/theme.css b/resources/css/theme.css new file mode 100644 index 0000000..b7e8449 --- /dev/null +++ b/resources/css/theme.css @@ -0,0 +1,96 @@ +/* Fluid Emerald / Kinetic Kanban: shared by the first paint and the app bundle. */ +:root { + color-scheme: light; + --radius: 0.25rem; + --background: #f7f9fb; + --foreground: #191c1e; + --card: #ffffff; + --card-foreground: #191c1e; + --popover: #ffffff; + --popover-foreground: #191c1e; + --primary: #006e2f; + --primary-foreground: #ffffff; + --secondary: #d0e1fb; + --secondary-foreground: #38485d; + --muted: #f2f4f6; + --muted-foreground: #505f76; + --accent: #dcfce7; + --accent-foreground: #005321; + --destructive: #ba1a1a; + --destructive-foreground: #ffffff; + --border: #e2e8f0; + --input: #6d7b6c; + --ring: #006e2f; + --brand: #22c55e; + --brand-foreground: #002109; + --success: #dcfce7; + --success-foreground: #005321; + --warning: #fef3c7; + --warning-foreground: #78350f; + --info: #dbeafe; + --info-foreground: #1e40af; + --chart-1: #006e2f; + --chart-2: #505f76; + --chart-3: #9e4036; + --chart-4: #92400e; + --chart-5: #1d4ed8; + --sidebar-background: #f2f4f6; + --sidebar-foreground: #191c1e; + --sidebar-primary: #006e2f; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #dcfce7; + --sidebar-accent-foreground: #005321; + --sidebar-border: #e2e8f0; + --sidebar-ring: #006e2f; + --sidebar: #f2f4f6; +} + +.dark { + color-scheme: dark; + --background: #0f172a; + --foreground: #f1f5f9; + --card: #1e293b; + --card-foreground: #f1f5f9; + --popover: #1e293b; + --popover-foreground: #f1f5f9; + --primary: #4ae176; + --primary-foreground: #002109; + --secondary: #334155; + --secondary-foreground: #e2e8f0; + --muted: #1e293b; + --muted-foreground: #b7c8e1; + --accent: #173b2b; + --accent-foreground: #6bff8f; + --destructive: #fca5a5; + --destructive-foreground: #450a0a; + --border: #334155; + --input: #8293aa; + --ring: #4ae176; + --brand: #22c55e; + --brand-foreground: #002109; + --success: #173b2b; + --success-foreground: #6bff8f; + --warning: #422006; + --warning-foreground: #fde68a; + --info: #172554; + --info-foreground: #bfdbfe; + --chart-1: #4ae176; + --chart-2: #b7c8e1; + --chart-3: #ffb4a9; + --chart-4: #fbbf24; + --chart-5: #60a5fa; + --sidebar-background: #111c30; + --sidebar-foreground: #f1f5f9; + --sidebar-primary: #4ae176; + --sidebar-primary-foreground: #002109; + --sidebar-accent: #173b2b; + --sidebar-accent-foreground: #6bff8f; + --sidebar-border: #334155; + --sidebar-ring: #4ae176; + --sidebar: #111c30; +} + +html { + background-color: var(--background); + color: var(--foreground); +} diff --git a/resources/js/app.ts b/resources/js/app.ts index 0d6194e..1048d74 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -3,7 +3,7 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; import type { DefineComponent } from 'vue'; import { createApp, h } from 'vue'; import '../css/app.css'; -import { initializeTheme } from '@/composables/useAppearance'; +import { createAppearance } from '@/composables/useAppearance'; import { createLocalization } from '@/lib/localization'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; @@ -19,12 +19,10 @@ createInertiaApp({ createApp({ render: () => h(App, props) }) .use(plugin) .use(createLocalization(props.initialPage.props.localization)) + .use(createAppearance(props.initialPage.props.appearance)) .mount(el); }, progress: { - color: '#4B5563', + color: 'var(--primary)', }, }); - -// This will set light / dark mode on page load... -initializeTheme(); diff --git a/resources/js/components/AppearanceTabs.vue b/resources/js/components/AppearanceTabs.vue index 22712f9..d305c42 100644 --- a/resources/js/components/AppearanceTabs.vue +++ b/resources/js/components/AppearanceTabs.vue @@ -1,33 +1,39 @@ diff --git a/resources/js/components/ui/button/index.ts b/resources/js/components/ui/button/index.ts index 26e2c55..7cba0b4 100644 --- a/resources/js/components/ui/button/index.ts +++ b/resources/js/components/ui/button/index.ts @@ -11,7 +11,7 @@ export const buttonVariants = cva( default: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: - "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: diff --git a/resources/js/composables/useAppearance.ts b/resources/js/composables/useAppearance.ts index 0568774..15100c4 100644 --- a/resources/js/composables/useAppearance.ts +++ b/resources/js/composables/useAppearance.ts @@ -1,124 +1,107 @@ -import type { ComputedRef, Ref } from 'vue'; -import { computed, onMounted, ref } from 'vue'; +import type { App, ComputedRef, InjectionKey, Ref } from 'vue'; +import { computed, inject, readonly, ref } from 'vue'; import type { Appearance, ResolvedAppearance } from '@/types'; export type { Appearance, ResolvedAppearance }; export type UseAppearanceReturn = { - appearance: Ref; + appearance: Readonly>; resolvedAppearance: ComputedRef; updateAppearance: (value: Appearance) => void; }; -export function updateTheme(value: Appearance): void { - if (typeof window === 'undefined') { - return; - } +const appearanceKey: InjectionKey = Symbol('appearance'); - if (value === 'system') { - const mediaQueryList = window.matchMedia( - '(prefers-color-scheme: dark)', - ); - const systemTheme = mediaQueryList.matches ? 'dark' : 'light'; +export function normalizeAppearance(value: unknown): Appearance { + return value === 'light' || value === 'dark' ? value : 'system'; +} +/** Each Vue app owns its state, including each server-rendered request. */ +export function createAppearance(initialAppearance: Appearance = 'system') { + const appearance = ref(normalizeAppearance(initialAppearance)); + const systemDark = ref(false); + const resolvedAppearance = computed(() => + appearance.value === 'system' + ? systemDark.value + ? 'dark' + : 'light' + : appearance.value, + ); + + function applyTheme(): void { document.documentElement.classList.toggle( 'dark', - systemTheme === 'dark', + resolvedAppearance.value === 'dark', ); - } else { - document.documentElement.classList.toggle('dark', value === 'dark'); - } -} - -const setCookie = (name: string, value: string, days = 365) => { - if (typeof document === 'undefined') { - return; + document.documentElement.style.colorScheme = resolvedAppearance.value; + document.documentElement.dataset.appearance = appearance.value; } - const maxAge = days * 24 * 60 * 60; - - document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`; -}; - -const mediaQuery = () => { - if (typeof window === 'undefined') { - return null; - } - - return window.matchMedia('(prefers-color-scheme: dark)'); -}; - -const getStoredAppearance = () => { - if (typeof window === 'undefined') { - return null; - } - - return localStorage.getItem('appearance') as Appearance | null; -}; - -const prefersDark = (): boolean => { - if (typeof window === 'undefined') { - return false; - } - - return window.matchMedia('(prefers-color-scheme: dark)').matches; -}; - -const handleSystemThemeChange = () => { - const currentAppearance = getStoredAppearance(); - - updateTheme(currentAppearance || 'system'); -}; - -export function initializeTheme(): void { - if (typeof window === 'undefined') { - return; - } - - // Initialize theme from saved preference or default to system... - const savedAppearance = getStoredAppearance(); - updateTheme(savedAppearance || 'system'); - - // Set up system theme change listener... - mediaQuery()?.addEventListener('change', handleSystemThemeChange); -} - -const appearance = ref('system'); - -export function useAppearance(): UseAppearanceReturn { - onMounted(() => { - const savedAppearance = localStorage.getItem( - 'appearance', - ) as Appearance | null; - - if (savedAppearance) { - appearance.value = savedAppearance; - } - }); - - const resolvedAppearance = computed(() => { - if (appearance.value === 'system') { - return prefersDark() ? 'dark' : 'light'; + function updateAppearance(value: Appearance): void { + appearance.value = normalizeAppearance(value); + if (typeof window === 'undefined') { + return; } - return appearance.value; - }); + applyTheme(); + try { + window.localStorage.setItem('appearance', appearance.value); + } catch { + // Private or embedded browsers may deny storage; the current theme still works. + } + document.cookie = `appearance=${appearance.value};path=/;max-age=31536000;SameSite=Lax`; + } - function updateAppearance(value: Appearance) { - appearance.value = value; + function handleSystemChange(event: MediaQueryListEvent): void { + systemDark.value = event.matches; + applyTheme(); + } - // Store in localStorage for client-side persistence... - localStorage.setItem('appearance', value); - - // Store in cookie for SSR... - setCookie('appearance', value); - - updateTheme(value); + function handleStorageChange(event: StorageEvent): void { + if (event.key === 'appearance' || event.key === null) { + updateAppearance(normalizeAppearance(event.newValue)); + } } return { - appearance, + appearance: readonly(appearance), resolvedAppearance, updateAppearance, + install(app: App): void { + app.provide(appearanceKey, { + appearance: readonly(appearance), + resolvedAppearance, + updateAppearance, + }); + if (typeof window === 'undefined') { + return; + } + + // The blocking head script already reconciled local storage and the SSR cookie. + appearance.value = normalizeAppearance( + document.documentElement.dataset.appearance ?? + initialAppearance, + ); + const media = window.matchMedia('(prefers-color-scheme: dark)'); + systemDark.value = media.matches; + applyTheme(); + media.addEventListener('change', handleSystemChange); + window.addEventListener('storage', handleStorageChange); + + app.onUnmount(() => { + media.removeEventListener('change', handleSystemChange); + window.removeEventListener('storage', handleStorageChange); + }); + }, }; } + +export function useAppearance(): UseAppearanceReturn { + const appearance = inject(appearanceKey); + if (!appearance) { + throw new Error( + 'The appearance plugin must be installed before useAppearance().', + ); + } + return appearance; +} diff --git a/resources/js/locales/en.ts b/resources/js/locales/en.ts index cfbc50b..8b619f4 100644 --- a/resources/js/locales/en.ts +++ b/resources/js/locales/en.ts @@ -1,4 +1,8 @@ export const en = { + 'appearance.label': 'Color theme', + 'appearance.light': 'Light', + 'appearance.dark': 'Dark', + 'appearance.system': 'System', 'common.cancel': 'Cancel', 'common.save': 'Save', 'common.loading': 'Loading…', diff --git a/resources/js/ssr.ts b/resources/js/ssr.ts index b04a69e..f50c59a 100644 --- a/resources/js/ssr.ts +++ b/resources/js/ssr.ts @@ -6,6 +6,10 @@ import { createSSRApp, h } from 'vue'; import { renderToString } from 'vue/server-renderer'; import { createLocalization } from '@/lib/localization'; import type { LocalizationSettings } from '@/types/localization'; +import { + createAppearance, + normalizeAppearance, +} from '@/composables/useAppearance'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; @@ -23,6 +27,13 @@ createServer( setup: ({ App, props, plugin }) => createSSRApp({ render: () => h(App, props) }) .use(plugin) + .use( + createAppearance( + normalizeAppearance( + props.initialPage.props.appearance, + ), + ), + ) .use( createLocalization( props.initialPage.props diff --git a/resources/js/types/global.d.ts b/resources/js/types/global.d.ts index 3490be7..85fedb6 100644 --- a/resources/js/types/global.d.ts +++ b/resources/js/types/global.d.ts @@ -1,5 +1,6 @@ import type { Auth } from '@/types/auth'; import type { LocalizationSettings } from '@/types/localization'; +import type { Appearance } from '@/types/ui'; import type { Page, Router } from '@inertiajs/core'; import { createHeadManager } from '@inertiajs/vue3'; @@ -21,6 +22,7 @@ declare module '@inertiajs/core' { sharedPageProps: { name: string; localization: LocalizationSettings; + appearance: Appearance; auth: Auth; sidebarOpen: boolean; }; diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 3ef8e7a..56dd700 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -1,33 +1,33 @@ - ($appearance ?? 'system') == 'dark'])> + ($appearance ?? 'system') === 'dark'])> - {{-- Inline script to detect system dark mode preference and apply it immediately --}} + {{-- Resolve before first paint; the Vue plugin reads the same data attribute. --}} - {{-- Inline style to set the HTML background color based on our theme in app.css --}} + {{-- The same tokens are bundled by Vite, so first paint cannot drift. --}} {{ config('app.name', 'Laravel') }} @@ -36,8 +36,7 @@ - - + @vite(['resources/js/app.ts', "resources/js/pages/{$page['component']}.vue"]) @inertiaHead diff --git a/tests/Feature/AppearanceTest.php b/tests/Feature/AppearanceTest.php new file mode 100644 index 0000000..ed4af50 --- /dev/null +++ b/tests/Feature/AppearanceTest.php @@ -0,0 +1,64 @@ +withUnencryptedCookie('appearance', $cookie); + } + + $response = $this->get(route('home')); + $response->assertOk() + ->assertSee('data-appearance="'.$expected.'"', false) + ->assertInertia(fn (Assert $page) => $page->where('appearance', $expected)) + ->assertDontSee('fonts.bunny.net', false) + ->assertDontSee('Instrument Sans', false) + ->assertSee('fonts/inter/InterVariable.woff2', false); + + preg_match('/]+>/', $response->getContent(), $matches); + $this->assertSame($expected === 'dark', str_contains($matches[0], 'class="dark"')); + $this->assertStringContainsString(file_get_contents(resource_path('css/theme.css')), $response->getContent()); + } + + /** @return array */ + public static function appearanceCookies(): array + { + return [ + 'default' => [null, 'system'], + 'light' => ['light', 'light'], + 'dark' => ['dark', 'dark'], + 'system' => ['system', 'system'], + 'invalid' => ['invalid', 'system'], + 'empty' => ['', 'system'], + 'malformed' => ['', 'system'], + ]; + } + + public function test_theme_behaviour_and_contrast(): void + { + $process = new Process(['node', 'tests/Fixtures/appearance.mjs'], base_path()); + $process->setTimeout(60); + $process->run(); + + $this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput()); + $this->assertStringContainsString('Appearance behavior checks passed.', $process->getOutput()); + } + + public function test_inter_is_bundled_with_its_license(): void + { + foreach (['InterVariable.woff2', 'InterVariable-Italic.woff2'] as $font) { + $this->assertSame('wOF2', file_get_contents(public_path('fonts/inter/'.$font), false, null, 0, 4)); + } + + $this->assertStringContainsString('SIL OPEN FONT LICENSE', file_get_contents(public_path('fonts/inter/LICENSE.txt'))); + } +} diff --git a/tests/Fixtures/appearance-preview.mjs b/tests/Fixtures/appearance-preview.mjs new file mode 100644 index 0000000..1ba16f4 --- /dev/null +++ b/tests/Fixtures/appearance-preview.mjs @@ -0,0 +1,47 @@ +import { createApp, h } from 'vue'; +import '../../resources/css/app.css'; +import AppearanceTabs from '../../resources/js/components/AppearanceTabs.vue'; +import AlertError from '../../resources/js/components/AlertError.vue'; +import { Button } from '../../resources/js/components/ui/button'; +import { createAppearance } from '../../resources/js/composables/useAppearance'; +import { createLocalization } from '../../resources/js/lib/localization'; + +const localization = createLocalization(); +createApp({ + render: () => + h('main', { class: 'mx-auto grid max-w-xl gap-column p-page' }, [ + h('h1', { class: 'text-headline' }, 'Emerald design system'), + h(AppearanceTabs), + h( + 'section', + { + class: 'grid gap-column rounded-md border bg-card p-card text-card-foreground', + }, + [ + h('h2', { class: 'text-title' }, 'Readable surfaces'), + h( + 'p', + { class: 'text-muted-foreground' }, + 'Inter, emerald actions and a visible keyboard focus.', + ), + h('label', { for: 'sample-input' }, 'Sample input'), + h('input', { + id: 'sample-input', + class: 'min-h-control rounded-md border border-input bg-background px-3', + placeholder: 'Type here', + }), + h('div', { class: 'flex flex-wrap gap-stack' }, [ + h(Button, {}, () => localization.t('common.save')), + h(Button, { variant: 'secondary' }, () => + localization.t('common.cancel'), + ), + h(Button, { variant: 'destructive' }, () => 'Delete'), + ]), + h(AlertError, { errors: ['A readable validation error.'] }), + ], + ), + ]), +}) + .use(localization) + .use(createAppearance()) + .mount('#app'); diff --git a/tests/Fixtures/appearance.html b/tests/Fixtures/appearance.html new file mode 100644 index 0000000..bfa2bf6 --- /dev/null +++ b/tests/Fixtures/appearance.html @@ -0,0 +1,12 @@ + + + + + + Emerald appearance fixture + + +
+ + + diff --git a/tests/Fixtures/appearance.mjs b/tests/Fixtures/appearance.mjs new file mode 100644 index 0000000..83d7af1 --- /dev/null +++ b/tests/Fixtures/appearance.mjs @@ -0,0 +1,219 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import vm from 'node:vm'; +import vue from '@vitejs/plugin-vue'; +import { createServer } from 'vite'; +import { createSSRApp, h } from 'vue'; +import { renderToString } from 'vue/server-renderer'; + +const server = await createServer({ + configFile: false, + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('../../resources/js', import.meta.url)), + }, + }, + server: { middlewareMode: true, hmr: false, watch: null }, + appType: 'custom', +}); + +function browserState(cookie, stored, dark, storageDenied = false) { + const root = { dataset: { appearance: cookie }, style: {}, dark: false }; + root.classList = { + toggle: (_, value) => { + root.dark = value; + }, + }; + const listeners = new Map(); + const storage = new Map(stored === null ? [] : [['appearance', stored]]); + const media = { + matches: dark, + addEventListener: (_, listener) => listeners.set('media', listener), + removeEventListener: () => listeners.delete('media'), + }; + return { + document: { documentElement: root, cookie: '' }, + window: { + matchMedia: () => media, + localStorage: { + getItem: (key) => { + if (storageDenied) { + throw new Error('Storage denied'); + } + return storage.get(key) ?? null; + }, + setItem: (key, value) => { + if (storageDenied) { + throw new Error('Storage denied'); + } + storage.set(key, value); + }, + }, + addEventListener: (_, listener) => + listeners.set('storage', listener), + removeEventListener: () => listeners.delete('storage'), + }, + root, + media, + listeners, + storage, + }; +} + +try { + const { createAppearance, normalizeAppearance } = + await server.ssrLoadModule( + '/resources/js/composables/useAppearance.ts', + ); + const { createLocalization } = await server.ssrLoadModule( + '/resources/js/lib/localization.ts', + ); + const { default: AppearanceTabs } = await server.ssrLoadModule( + '/resources/js/components/AppearanceTabs.vue', + ); + + const renderTheme = (value) => + renderToString( + createSSRApp({ render: () => h(AppearanceTabs) }) + .use(createLocalization()) + .use(createAppearance(value)), + ); + const [light, dark] = await Promise.all([ + renderTheme('light'), + renderTheme('dark'), + ]); + const selectedButton = (html) => + html.match( + /]*aria-pressed="true"[^>]*>([\s\S]*?)<\/button>/, + )[1]; + assert.match(selectedButton(light), />LightDark([\s\S]*?)<\/script>/)[1]; + for (const [cookie, stored, osDark, denied, expected, preference] of [ + ['system', null, false, false, false, 'system'], + ['system', null, true, false, true, 'system'], + ['dark', null, false, false, true, 'dark'], + ['light', null, true, false, false, 'light'], + ['light', 'dark', false, false, true, 'dark'], + ['dark', null, false, true, true, 'dark'], + ['light', 'invalid', true, false, true, 'system'], + ]) { + const state = browserState(cookie, stored, osDark, denied); + vm.runInNewContext(bootstrap, state); + assert.equal(state.root.dark, expected); + assert.equal(state.root.dataset.appearance, preference); + assert.equal(state.root.style.colorScheme, expected ? 'dark' : 'light'); + } + + for (const denied of [false, true]) { + const state = browserState('system', null, false, denied); + globalThis.window = state.window; + globalThis.document = state.document; + const theme = createAppearance(); + let cleanup; + theme.install({ + provide() {}, + onUnmount(callback) { + cleanup = callback; + }, + }); + assert.equal(theme.resolvedAppearance.value, 'light'); + state.listeners.get('media')({ matches: true }); + assert.equal(theme.resolvedAppearance.value, 'dark'); + assert.equal(state.root.dark, true); + theme.updateAppearance('light'); + assert.equal(state.root.dark, false); + assert.match(state.document.cookie, /appearance=light;.*SameSite=Lax/); + state.listeners.get('media')({ matches: false }); + state.listeners.get('media')({ matches: true }); + assert.equal(theme.resolvedAppearance.value, 'light'); + theme.updateAppearance('system'); + assert.equal(theme.resolvedAppearance.value, 'dark'); + state.listeners.get('storage')({ + key: 'appearance', + newValue: 'light', + }); + assert.equal(theme.appearance.value, 'light'); + state.listeners.get('storage')({ key: null, newValue: null }); + assert.equal(theme.appearance.value, 'system'); + if (!denied) { + assert.equal(state.storage.get('appearance'), 'system'); + } + cleanup(); + assert.equal(state.listeners.size, 0); + delete globalThis.window; + delete globalThis.document; + } + + const css = await readFile('resources/css/theme.css', 'utf8'); + function luminance(hex) { + const values = hex + .match(/[a-f\d]{2}/gi) + .map((part) => parseInt(part, 16) / 255) + .map((value) => + value <= 0.04045 + ? value / 12.92 + : ((value + 0.055) / 1.055) ** 2.4, + ); + return values[0] * 0.2126 + values[1] * 0.7152 + values[2] * 0.0722; + } + function contrast(first, second) { + const values = [luminance(first), luminance(second)].sort( + (a, b) => b - a, + ); + return (values[0] + 0.05) / (values[1] + 0.05); + } + for (const selector of [':root', '.dark']) { + const declarations = css + .slice(css.indexOf(selector)) + .match(/\{([^}]+)\}/)[1]; + const tokens = Object.fromEntries( + [...declarations.matchAll(/--([\w-]+):\s*(#[a-f\d]{6})/gi)].map( + (match) => [match[1], match[2]], + ), + ); + for (const [background, foreground] of [ + ['background', 'foreground'], + ['card', 'card-foreground'], + ['popover', 'popover-foreground'], + ['primary', 'primary-foreground'], + ['secondary', 'secondary-foreground'], + ['muted', 'muted-foreground'], + ['accent', 'accent-foreground'], + ['destructive', 'destructive-foreground'], + ['card', 'destructive'], + ['brand', 'brand-foreground'], + ['success', 'success-foreground'], + ['warning', 'warning-foreground'], + ['info', 'info-foreground'], + ['sidebar-background', 'sidebar-foreground'], + ['sidebar-accent', 'sidebar-accent-foreground'], + ['sidebar-primary', 'sidebar-primary-foreground'], + ]) { + assert.ok( + contrast(tokens[background], tokens[foreground]) >= 4.5, + selector + ': ' + background + '/' + foreground, + ); + } + assert.ok(contrast(tokens.background, tokens.ring) >= 3); + assert.ok(contrast(tokens.card, tokens.input) >= 3); + } + const appCss = await readFile('resources/css/app.css', 'utf8'); + assert.match(appCss, /@media \(prefers-reduced-motion: reduce\)/); + assert.match(appCss, /animation-duration: 0.01ms !important/); + assert.match(appCss, /transition-duration: 0.01ms !important/); + assert.match(appCss, /scroll-behavior: auto !important/); + console.log('Appearance behavior checks passed.'); +} finally { + delete globalThis.window; + delete globalThis.document; + await server.close(); +}