feat(KAN-17): add Emerald design system and resilient themes

This commit is contained in:
2026-09-11 12:11:50 +00:00
parent 71d9d1b63e
commit 8657d75682
19 changed files with 757 additions and 254 deletions
+5 -1
View File
@@ -16,7 +16,11 @@ class HandleAppearance
*/ */
public function handle(Request $request, Closure $next): Response 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); return $next($request);
} }
@@ -46,6 +46,7 @@ class HandleInertiaRequests extends Middleware
return [ return [
...parent::share($request), ...parent::share($request),
'name' => config('app.name'), 'name' => config('app.name'),
'appearance' => $request->attributes->get('appearance', 'system'),
'localization' => [ 'localization' => [
'locale' => app()->getLocale(), 'locale' => app()->getLocale(),
'timeZone' => config('app.timezone'), 'timeZone' => config('app.timezone'),
Binary file not shown.
Binary file not shown.
+92
View File
@@ -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.
+87 -122
View File
@@ -1,56 +1,98 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import 'tw-animate-css'; @import 'tw-animate-css';
@import './theme.css';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.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 { @theme inline {
--font-sans: --font-sans: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
Instrument Sans, ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--radius-lg: var(--radius); --radius-sm: 0.125rem;
--radius-md: calc(var(--radius) - 2px); --radius-md: var(--radius);
--radius-sm: calc(var(--radius) - 4px); --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-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --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-1: var(--chart-1);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar-background); --color-sidebar: var(--sidebar-background);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
@@ -61,112 +103,35 @@
--color-sidebar-ring: var(--sidebar-ring); --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 { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border;
} }
body { 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;
} }
} }
+96
View File
@@ -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
View File
@@ -3,7 +3,7 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import type { DefineComponent } from 'vue'; import type { DefineComponent } from 'vue';
import { createApp, h } from 'vue'; import { createApp, h } from 'vue';
import '../css/app.css'; import '../css/app.css';
import { initializeTheme } from '@/composables/useAppearance'; import { createAppearance } from '@/composables/useAppearance';
import { createLocalization } from '@/lib/localization'; import { createLocalization } from '@/lib/localization';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
@@ -19,12 +19,10 @@ createInertiaApp({
createApp({ render: () => h(App, props) }) createApp({ render: () => h(App, props) })
.use(plugin) .use(plugin)
.use(createLocalization(props.initialPage.props.localization)) .use(createLocalization(props.initialPage.props.localization))
.use(createAppearance(props.initialPage.props.appearance))
.mount(el); .mount(el);
}, },
progress: { progress: {
color: '#4B5563', color: 'var(--primary)',
}, },
}); });
// This will set light / dark mode on page load...
initializeTheme();
+15 -9
View File
@@ -1,33 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
import { Monitor, Moon, Sun } from 'lucide-vue-next'; import { Monitor, Moon, Sun } from 'lucide-vue-next';
import { useAppearance } from '@/composables/useAppearance'; import { useAppearance } from '@/composables/useAppearance';
import { useLocalization } from '@/composables/useLocalization';
const { appearance, updateAppearance } = useAppearance(); const { appearance, updateAppearance } = useAppearance();
const { t } = useLocalization();
const tabs = [ const tabs = [
{ value: 'light', Icon: Sun, label: 'Light' }, { value: 'light', Icon: Sun, label: 'appearance.light' },
{ value: 'dark', Icon: Moon, label: 'Dark' }, { value: 'dark', Icon: Moon, label: 'appearance.dark' },
{ value: 'system', Icon: Monitor, label: 'System' }, { value: 'system', Icon: Monitor, label: 'appearance.system' },
] as const; ] as const;
</script> </script>
<template> <template>
<div <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 <button
v-for="{ value, Icon, label } in tabs" v-for="{ value, Icon, label } in tabs"
:key="value" :key="value"
type="button"
:aria-pressed="appearance === value"
@click="updateAppearance(value)" @click="updateAppearance(value)"
:class="[ :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 appearance === value
? 'shadow-xs bg-white dark:bg-neutral-700 dark:text-neutral-100' ? 'bg-primary text-primary-foreground'
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60', : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
]" ]"
> >
<component :is="Icon" class="-ml-1 h-4 w-4" /> <component :is="Icon" class="size-4 shrink-0" aria-hidden="true" />
<span class="ml-1.5 text-sm">{{ label }}</span> <span>{{ t(label) }}</span>
</button> </button>
</div> </div>
</template> </template>
+1 -1
View File
@@ -11,7 +11,7 @@ export const buttonVariants = cva(
default: default:
"bg-primary text-primary-foreground hover:bg-primary/90", "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: 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: 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", "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: secondary:
+80 -97
View File
@@ -1,124 +1,107 @@
import type { ComputedRef, Ref } from 'vue'; import type { App, ComputedRef, InjectionKey, Ref } from 'vue';
import { computed, onMounted, ref } from 'vue'; import { computed, inject, readonly, ref } from 'vue';
import type { Appearance, ResolvedAppearance } from '@/types'; import type { Appearance, ResolvedAppearance } from '@/types';
export type { Appearance, ResolvedAppearance }; export type { Appearance, ResolvedAppearance };
export type UseAppearanceReturn = { export type UseAppearanceReturn = {
appearance: Ref<Appearance>; appearance: Readonly<Ref<Appearance>>;
resolvedAppearance: ComputedRef<ResolvedAppearance>; resolvedAppearance: ComputedRef<ResolvedAppearance>;
updateAppearance: (value: Appearance) => void; updateAppearance: (value: Appearance) => void;
}; };
export function updateTheme(value: Appearance): void { const appearanceKey: InjectionKey<UseAppearanceReturn> = Symbol('appearance');
if (typeof window === 'undefined') {
return;
}
if (value === 'system') { export function normalizeAppearance(value: unknown): Appearance {
const mediaQueryList = window.matchMedia( return value === 'light' || value === 'dark' ? value : 'system';
'(prefers-color-scheme: dark)', }
);
const systemTheme = mediaQueryList.matches ? 'dark' : 'light';
/** 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( document.documentElement.classList.toggle(
'dark', 'dark',
systemTheme === 'dark', resolvedAppearance.value === 'dark',
); );
} else { document.documentElement.style.colorScheme = resolvedAppearance.value;
document.documentElement.classList.toggle('dark', value === 'dark'); document.documentElement.dataset.appearance = appearance.value;
}
}
const setCookie = (name: string, value: string, days = 365) => {
if (typeof document === 'undefined') {
return;
} }
const maxAge = days * 24 * 60 * 60; function updateAppearance(value: Appearance): void {
appearance.value = normalizeAppearance(value);
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`; if (typeof window === 'undefined') {
}; return;
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';
} }
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) { function handleSystemChange(event: MediaQueryListEvent): void {
appearance.value = value; systemDark.value = event.matches;
applyTheme();
}
// Store in localStorage for client-side persistence... function handleStorageChange(event: StorageEvent): void {
localStorage.setItem('appearance', value); if (event.key === 'appearance' || event.key === null) {
updateAppearance(normalizeAppearance(event.newValue));
// Store in cookie for SSR... }
setCookie('appearance', value);
updateTheme(value);
} }
return { return {
appearance, appearance: readonly(appearance),
resolvedAppearance, resolvedAppearance,
updateAppearance, 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;
}
+4
View File
@@ -1,4 +1,8 @@
export const en = { export const en = {
'appearance.label': 'Color theme',
'appearance.light': 'Light',
'appearance.dark': 'Dark',
'appearance.system': 'System',
'common.cancel': 'Cancel', 'common.cancel': 'Cancel',
'common.save': 'Save', 'common.save': 'Save',
'common.loading': 'Loading…', 'common.loading': 'Loading…',
+11
View File
@@ -6,6 +6,10 @@ import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer'; import { renderToString } from 'vue/server-renderer';
import { createLocalization } from '@/lib/localization'; import { createLocalization } from '@/lib/localization';
import type { LocalizationSettings } from '@/types/localization'; import type { LocalizationSettings } from '@/types/localization';
import {
createAppearance,
normalizeAppearance,
} from '@/composables/useAppearance';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
@@ -23,6 +27,13 @@ createServer(
setup: ({ App, props, plugin }) => setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) }) createSSRApp({ render: () => h(App, props) })
.use(plugin) .use(plugin)
.use(
createAppearance(
normalizeAppearance(
props.initialPage.props.appearance,
),
),
)
.use( .use(
createLocalization( createLocalization(
props.initialPage.props props.initialPage.props
+2
View File
@@ -1,5 +1,6 @@
import type { Auth } from '@/types/auth'; import type { Auth } from '@/types/auth';
import type { LocalizationSettings } from '@/types/localization'; import type { LocalizationSettings } from '@/types/localization';
import type { Appearance } from '@/types/ui';
import type { Page, Router } from '@inertiajs/core'; import type { Page, Router } from '@inertiajs/core';
import { createHeadManager } from '@inertiajs/vue3'; import { createHeadManager } from '@inertiajs/vue3';
@@ -21,6 +22,7 @@ declare module '@inertiajs/core' {
sharedPageProps: { sharedPageProps: {
name: string; name: string;
localization: LocalizationSettings; localization: LocalizationSettings;
appearance: Appearance;
auth: Auth; auth: Auth;
sidebarOpen: boolean; sidebarOpen: boolean;
}; };
+18 -19
View File
@@ -1,33 +1,33 @@
<!DOCTYPE html> <!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> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <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> <script>
(function() { (function() {
const appearance = '{{ $appearance ?? "system" }}'; const root = document.documentElement;
let appearance = root.dataset.appearance;
if (appearance === 'system') { try {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const stored = window.localStorage.getItem('appearance');
if (stored !== null) {
if (prefersDark) { appearance = stored;
document.documentElement.classList.add('dark');
} }
} 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> </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> <style>
html { {!! file_get_contents(resource_path('css/theme.css')) !!}
background-color: oklch(1 0 0);
}
html.dark {
background-color: oklch(0.145 0 0);
}
</style> </style>
<title inertia>{{ config('app.name', 'Laravel') }}</title> <title inertia>{{ config('app.name', 'Laravel') }}</title>
@@ -36,8 +36,7 @@
<link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.bunny.net"> <link rel="preload" href="{{ asset('fonts/inter/InterVariable.woff2') }}" as="font" type="font/woff2" crossorigin>
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
@vite(['resources/js/app.ts', "resources/js/pages/{$page['component']}.vue"]) @vite(['resources/js/app.ts', "resources/js/pages/{$page['component']}.vue"])
@inertiaHead @inertiaHead
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace Tests\Feature;
use Inertia\Testing\AssertableInertia as Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class AppearanceTest extends TestCase
{
#[DataProvider('appearanceCookies')]
public function test_initial_render_uses_a_validated_theme(?string $cookie, string $expected): void
{
if ($cookie !== null) {
$this->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('/<html[^>]+>/', $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<string, array{?string, string}> */
public static function appearanceCookies(): array
{
return [
'default' => [null, 'system'],
'light' => ['light', 'light'],
'dark' => ['dark', 'dark'],
'system' => ['system', 'system'],
'invalid' => ['invalid', 'system'],
'empty' => ['', 'system'],
'malformed' => ['</script><script>alert(1)</script>', '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')));
}
}
+47
View File
@@ -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');
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" data-appearance="system">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Emerald appearance fixture</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./appearance-preview.mjs"></script>
</body>
</html>
+219
View File
@@ -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(
/<button[^>]*aria-pressed="true"[^>]*>([\s\S]*?)<\/button>/,
)[1];
assert.match(selectedButton(light), />Light</);
assert.match(selectedButton(dark), />Dark</);
assert.match(light, /role="group" aria-label="Color theme"/);
assert.equal((light.match(/type="button"/g) ?? []).length, 3);
assert.equal((dark.match(/aria-pressed="true"/g) ?? []).length, 1);
assert.equal(normalizeAppearance('unexpected'), 'system');
const blade = await readFile('resources/views/app.blade.php', 'utf8');
const bootstrap = blade.match(/<script>([\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();
}