feat(KAN-17): add Emerald design system and resilient themes
This commit is contained in:
+87
-122
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+3
-5
@@ -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();
|
||||
|
||||
@@ -1,33 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { Monitor, Moon, Sun } from 'lucide-vue-next';
|
||||
import { useAppearance } from '@/composables/useAppearance';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
|
||||
const { appearance, updateAppearance } = useAppearance();
|
||||
const { t } = useLocalization();
|
||||
|
||||
const tabs = [
|
||||
{ value: 'light', Icon: Sun, label: 'Light' },
|
||||
{ value: 'dark', Icon: Moon, label: 'Dark' },
|
||||
{ value: 'system', Icon: Monitor, label: 'System' },
|
||||
{ value: 'light', Icon: Sun, label: 'appearance.light' },
|
||||
{ value: 'dark', Icon: Moon, label: 'appearance.dark' },
|
||||
{ value: 'system', Icon: Monitor, label: 'appearance.system' },
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800"
|
||||
role="group"
|
||||
:aria-label="t('appearance.label')"
|
||||
class="border-border bg-muted inline-flex max-w-full gap-1 rounded-lg border p-1"
|
||||
>
|
||||
<button
|
||||
v-for="{ value, Icon, label } in tabs"
|
||||
:key="value"
|
||||
type="button"
|
||||
:aria-pressed="appearance === value"
|
||||
@click="updateAppearance(value)"
|
||||
:class="[
|
||||
'flex items-center rounded-md px-3.5 py-1.5 transition-colors',
|
||||
'min-h-control text-body focus-visible:outline-ring flex min-w-0 items-center justify-center gap-1.5 rounded-md px-3 font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
appearance === value
|
||||
? 'shadow-xs bg-white dark:bg-neutral-700 dark:text-neutral-100'
|
||||
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
]"
|
||||
>
|
||||
<component :is="Icon" class="-ml-1 h-4 w-4" />
|
||||
<span class="ml-1.5 text-sm">{{ label }}</span>
|
||||
<component :is="Icon" class="size-4 shrink-0" aria-hidden="true" />
|
||||
<span>{{ t(label) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>;
|
||||
appearance: Readonly<Ref<Appearance>>;
|
||||
resolvedAppearance: ComputedRef<ResolvedAppearance>;
|
||||
updateAppearance: (value: Appearance) => void;
|
||||
};
|
||||
|
||||
export function updateTheme(value: Appearance): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const appearanceKey: InjectionKey<UseAppearanceReturn> = 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<Appearance>(normalizeAppearance(initialAppearance));
|
||||
const systemDark = ref(false);
|
||||
const resolvedAppearance = computed<ResolvedAppearance>(() =>
|
||||
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<Appearance>('system');
|
||||
|
||||
export function useAppearance(): UseAppearanceReturn {
|
||||
onMounted(() => {
|
||||
const savedAppearance = localStorage.getItem(
|
||||
'appearance',
|
||||
) as Appearance | null;
|
||||
|
||||
if (savedAppearance) {
|
||||
appearance.value = savedAppearance;
|
||||
}
|
||||
});
|
||||
|
||||
const resolvedAppearance = computed<ResolvedAppearance>(() => {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+2
@@ -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;
|
||||
};
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" data-appearance="{{ $appearance ?? 'system' }}" @class(['dark' => ($appearance ?? 'system') === 'dark'])>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
|
||||
{{-- Resolve before first paint; the Vue plugin reads the same data attribute. --}}
|
||||
<script>
|
||||
(function() {
|
||||
const appearance = '{{ $appearance ?? "system" }}';
|
||||
|
||||
if (appearance === 'system') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
if (prefersDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
const root = document.documentElement;
|
||||
let appearance = root.dataset.appearance;
|
||||
try {
|
||||
const stored = window.localStorage.getItem('appearance');
|
||||
if (stored !== null) {
|
||||
appearance = stored;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the server cookie when browser storage is unavailable.
|
||||
}
|
||||
appearance = ['light', 'dark', 'system'].includes(appearance) ? appearance : 'system';
|
||||
const dark = appearance === 'dark' || (appearance === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
root.dataset.appearance = appearance;
|
||||
root.classList.toggle('dark', dark);
|
||||
root.style.colorScheme = dark ? 'dark' : 'light';
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{-- 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. --}}
|
||||
<style>
|
||||
html {
|
||||
background-color: oklch(1 0 0);
|
||||
}
|
||||
|
||||
html.dark {
|
||||
background-color: oklch(0.145 0 0);
|
||||
}
|
||||
{!! file_get_contents(resource_path('css/theme.css')) !!}
|
||||
</style>
|
||||
|
||||
<title inertia>{{ config('app.name', 'Laravel') }}</title>
|
||||
@@ -36,8 +36,7 @@
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
|
||||
<link rel="preload" href="{{ asset('fonts/inter/InterVariable.woff2') }}" as="font" type="font/woff2" crossorigin>
|
||||
|
||||
@vite(['resources/js/app.ts', "resources/js/pages/{$page['component']}.vue"])
|
||||
@inertiaHead
|
||||
|
||||
Reference in New Issue
Block a user