KAN-17 Emerald design system #111

Merged
Twyco merged 2 commits from feature/KAN-17-emerald-design-system into main 2026-09-11 12:11:51 +00:00
25 changed files with 1190 additions and 259 deletions
+5 -1
View File
@@ -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);
}
@@ -46,6 +46,11 @@ 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'),
],
'auth' => [
'permissions' => [
'canManageTeams' => $user !== null && ($this->permissionService->canManageAnyTeam($user) || $this->permissionService->allows($user, Permission::TeamCreate)),
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 '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;
}
}
+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);
}
+5 -5
View File
@@ -3,7 +3,8 @@ 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';
@@ -17,12 +18,11 @@ createInertiaApp({
setup({ el, App, props, plugin }) {
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();
+4 -4
View File
@@ -2,15 +2,15 @@
import { AlertCircle } from 'lucide-vue-next';
import { computed } from 'vue';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { useLocalization } from '@/composables/useLocalization';
type Props = {
errors: string[];
title?: string;
};
const props = withDefaults(defineProps<Props>(), {
title: 'Something went wrong.',
});
const props = defineProps<Props>();
const { t } = useLocalization();
const uniqueErrors = computed(() => Array.from(new Set(props.errors)));
</script>
@@ -18,7 +18,7 @@ const uniqueErrors = computed(() => Array.from(new Set(props.errors)));
<template>
<Alert variant="destructive">
<AlertCircle class="size-4" />
<AlertTitle>{{ title }}</AlertTitle>
<AlertTitle>{{ title ?? t('common.error') }}</AlertTitle>
<AlertDescription>
<ul class="list-inside list-disc text-sm">
<li v-for="(error, index) in uniqueErrors" :key="index">
+15 -9
View File
@@ -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>
+1 -1
View File
@@ -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:
+80 -97
View File
@@ -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;
}
@@ -0,0 +1,12 @@
import { inject } from 'vue';
import { localizationKey } from '@/lib/localization';
export function useLocalization() {
const localization = inject(localizationKey);
if (!localization) {
throw new Error(
'The localization plugin must be installed before useLocalization().',
);
}
return localization;
}
+159
View File
@@ -0,0 +1,159 @@
import { readonly, ref } from 'vue';
import type { App, InjectionKey } from 'vue';
import { en } from '@/locales/en';
import type {
LocalizationSettings,
TranslationCatalog,
TranslationKey,
TranslationParameters,
} from '@/types/localization';
const defaultLocale = 'en';
const catalogs: Record<string, TranslationCatalog> = { en };
function placeholders(message: string): string[] {
return [
...new Set(
[...message.matchAll(/\{(\w+)\}/g)].map((match) => match[1]),
),
].sort();
}
/** Validate every catalog, including messages that no component has rendered yet. */
export function validateCatalog(catalog: TranslationCatalog): void {
for (const key of Object.keys(en) as TranslationKey[]) {
const message = Object.hasOwn(catalog, key) ? catalog[key] : undefined;
const source = en[key];
if (message === undefined || typeof message !== typeof source) {
throw new Error(`Missing or invalid translation: ${key}`);
}
const expected = placeholders(
typeof source === 'string' ? source : source.other,
);
const forms =
typeof message === 'string'
? [message]
: Object.values(message ?? {});
if (
typeof message !== 'string' &&
(!message || typeof message.other !== 'string')
) {
throw new Error(`Missing plural fallback: ${key}`);
}
for (const form of forms) {
if (
typeof form !== 'string' ||
form.trim() === '' ||
JSON.stringify(placeholders(form)) !== JSON.stringify(expected)
) {
throw new Error(`Invalid translation placeholders: ${key}`);
}
}
}
}
export function createLocalization(
settings: Partial<LocalizationSettings> = {},
availableCatalogs: Record<string, TranslationCatalog> = catalogs,
) {
const messages: Record<string, TranslationCatalog> = {
...availableCatalogs,
en,
};
for (const catalog of Object.values(messages)) {
validateCatalog(catalog);
}
function resolveLocale(requested: string): string {
try {
const [canonical] = Intl.getCanonicalLocales(
requested.replaceAll('_', '-'),
);
if (Object.hasOwn(messages, canonical)) {
return canonical;
}
const language = canonical.split('-')[0];
return Object.hasOwn(messages, language) ? language : defaultLocale;
} catch {
return defaultLocale;
}
}
const locale = ref(resolveLocale(settings.locale ?? defaultLocale));
const timeZone = settings.timeZone ?? 'UTC';
function number(
value: number,
options: Intl.NumberFormatOptions = {},
): string {
return new Intl.NumberFormat(locale.value, options).format(value);
}
function date(
value: Date | number,
options: Intl.DateTimeFormatOptions = {},
): string {
return new Intl.DateTimeFormat(locale.value, {
timeZone,
...options,
}).format(value);
}
/** Interpolate plain text only; render with Vue text bindings, never v-html. */
function t(
key: TranslationKey,
parameters: TranslationParameters = {},
): string {
const catalog = messages[locale.value];
if (!Object.hasOwn(catalog, key)) {
throw new Error(`Missing translation: ${key}`);
}
const message = catalog[key];
let template: string;
if (typeof message === 'string') {
template = message;
} else {
const count = parameters.count;
if (typeof count !== 'number' || !Number.isFinite(count)) {
throw new Error(`Translation requires a finite count: ${key}`);
}
template =
message[new Intl.PluralRules(locale.value).select(count)] ??
message.other;
}
return template.replace(/\{(\w+)\}/g, (_, name: string) => {
if (!Object.hasOwn(parameters, name)) {
throw new Error(
`Missing translation parameter: ${key}.${name}`,
);
}
const value = parameters[name];
return typeof value === 'number' ? number(value) : value;
});
}
const localization = {
locale: readonly(locale),
t,
number,
date,
setLocale(requested: string): void {
locale.value = resolveLocale(requested);
},
install(app: App): void {
app.provide(localizationKey, localization);
},
};
return localization;
}
export type Localization = ReturnType<typeof createLocalization>;
export const localizationKey: InjectionKey<Localization> =
Symbol('localization');
+15
View File
@@ -0,0 +1,15 @@
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…',
'common.error': 'Something went wrong.',
'pagination.page': 'Page {page} of {total}',
'tickets.count': {
one: '{count} ticket',
other: '{count} tickets',
},
} as const;
+21 -1
View File
@@ -4,6 +4,12 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import type { DefineComponent } from 'vue';
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';
@@ -19,7 +25,21 @@ createServer(
import.meta.glob<DefineComponent>('./pages/**/*.vue'),
),
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) }).use(plugin),
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(
createAppearance(
normalizeAppearance(
props.initialPage.props.appearance,
),
),
)
.use(
createLocalization(
props.initialPage.props
.localization as LocalizationSettings,
),
),
}),
{ cluster: true },
);
+4
View File
@@ -1,4 +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';
@@ -19,6 +21,8 @@ declare module '@inertiajs/core' {
export interface InertiaConfig {
sharedPageProps: {
name: string;
localization: LocalizationSettings;
appearance: Appearance;
auth: Auth;
sidebarOpen: boolean;
};
+13
View File
@@ -0,0 +1,13 @@
import type { en } from '@/locales/en';
export type TranslationKey = keyof typeof en;
export type PluralMessage = Partial<Record<Intl.LDMLPluralRule, string>> & {
other: string;
};
export type TranslationCatalog = Record<TranslationKey, string | PluralMessage>;
export type TranslationParameters = Record<string, string | number>;
export type LocalizationSettings = {
locale: string;
timeZone: string;
};
+18 -19
View File
@@ -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
+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 @@
<?php
namespace Tests\Feature;
use Illuminate\Support\Facades\Validator;
use Inertia\Testing\AssertableInertia as Assert;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class LocalizationTest extends TestCase
{
public function test_frontend_localization_behaviour(): void
{
$process = new Process(['node', 'tests/Fixtures/localization.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Localization behavior checks passed.', $process->getOutput());
}
public function test_initial_page_shares_the_application_locale_and_time_zone(): void
{
config(['app.timezone' => 'Europe/Berlin']);
$this->withHeader('Accept-Language', 'de-DE')->get(route('home'))
->assertOk()
->assertSee('lang="en"', false)
->assertInertia(fn (Assert $page) => $page
->where('localization.locale', 'en')
->where('localization.timeZone', 'Europe/Berlin'));
}
public function test_server_validation_remains_english(): void
{
$this->assertSame('en', config('app.locale'));
$this->assertSame('en', config('app.fallback_locale'));
$validator = Validator::make(['email' => 'invalid'], [
'title' => ['required'],
'email' => ['email'],
]);
$this->assertSame('The title field is required.', $validator->errors()->first('title'));
$this->assertSame('The email field must be a valid email address.', $validator->errors()->first('email'));
}
}
+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();
}
+169
View File
@@ -0,0 +1,169 @@
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import { createServer } from 'vite';
import { computed, 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',
});
try {
const { createLocalization, validateCatalog } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { useLocalization } = await server.ssrLoadModule(
'/resources/js/composables/useLocalization.ts',
);
const { en } = await server.ssrLoadModule('/resources/js/locales/en.ts');
const { default: AlertError } = await server.ssrLoadModule(
'/resources/js/components/AlertError.vue',
);
const english = createLocalization();
assert.equal(english.t('common.save'), 'Save');
assert.equal(
english.t('pagination.page', { page: 0, total: 2000 }),
'Page 0 of 2,000',
);
for (const [count, expected] of [
[0, '0 tickets'],
[1, '1 ticket'],
[2, '2 tickets'],
[1.5, '1.5 tickets'],
]) {
assert.equal(english.t('tickets.count', { count }), expected);
}
assert.equal(
english.number(1234.5, { minimumFractionDigits: 2 }),
'1,234.50',
);
assert.equal(
english.number(12, { style: 'currency', currency: 'USD' }),
'$12.00',
);
assert.equal(english.date(new Date('2026-09-09T23:30:00Z')), '9/9/2026');
assert.equal(
createLocalization({ timeZone: 'Europe/Berlin' }).date(
new Date('2026-09-09T23:30:00Z'),
),
'9/10/2026',
);
assert.throws(() => english.t('missing.key'), /Missing translation/);
assert.throws(() => english.t('toString'), /Missing translation/);
assert.throws(
() => english.t('pagination.page', { page: 1 }),
/Missing translation parameter/,
);
for (const count of [undefined, '1', Infinity, NaN]) {
assert.throws(
() => english.t('tickets.count', { count }),
/finite count/,
);
}
const incomplete = { ...en };
delete incomplete['common.save'];
assert.throws(
() => validateCatalog(incomplete),
/Missing or invalid translation/,
);
assert.throws(
() =>
validateCatalog({
...en,
'tickets.count': { one: '{count} ticket' },
}),
/plural fallback/,
);
assert.throws(
() => validateCatalog({ ...en, 'pagination.page': 'Page {unknown}' }),
/placeholders/,
);
assert.throws(
() => validateCatalog({ ...en, 'common.save': '' }),
/placeholders/,
);
assert.throws(
() => createLocalization({}, { de: incomplete }),
/Missing or invalid translation/,
);
for (const locale of ['de-DE', 'en_US', 'not a locale', '']) {
assert.equal(createLocalization({ locale }).locale.value, 'en');
}
const de = {
...en,
'common.error': 'Etwas ist schiefgelaufen.',
'common.save': 'Speichern',
};
const translated = createLocalization({ locale: 'de_DE' }, { de });
assert.equal(translated.locale.value, 'de');
assert.equal(translated.number(1234.5), '1.234,5');
const label = computed(() => translated.t('common.save'));
assert.equal(label.value, 'Speichern');
translated.setLocale('en');
assert.equal(label.value, 'Save');
translated.setLocale('de');
assert.equal(label.value, 'Speichern');
assert.equal(english.locale.value, 'en');
const renderAlert = (localization, props = {}) =>
renderToString(
createSSRApp({
render: () =>
h(AlertError, {
errors: ['Invalid input', 'Invalid input'],
...props,
}),
}).use(localization),
);
const [englishHtml, germanHtml] = await Promise.all([
renderAlert(english),
renderAlert(translated),
]);
assert.match(englishHtml, /Something went wrong\./);
assert.match(germanHtml, /Etwas ist schiefgelaufen\./);
assert.equal((englishHtml.match(/Invalid input/g) ?? []).length, 1);
assert.match(
await renderAlert(translated, { title: 'Custom title' }),
/Custom title/,
);
assert.doesNotMatch(
await renderAlert(translated, { title: '' }),
/Etwas ist schiefgelaufen/,
);
const InterpolatedText = {
setup() {
const { t } = useLocalization();
return () =>
h(
'p',
t('pagination.page', {
page: '<script>alert(1)</script>',
total: 1,
}),
);
},
};
const escaped = await renderToString(
createSSRApp(InterpolatedText).use(createLocalization()),
);
assert.match(escaped, /&lt;script&gt;/);
assert.doesNotMatch(escaped, /<script>/);
console.log('Localization behavior checks passed.');
} finally {
await server.close();
}