Compare commits

..
3 Commits
Author SHA1 Message Date
Twyco 1b2349e977 feat(KAN-18): redesign foundational UI components
CI/CD / test_and_check_app (push) Successful in 4m25s
CI/CD / Publish immutable image (push) Skipped
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-09 15:31:04 +02:00
Twyco 95db152b91 feat(KAN-17): add Emerald design system and resilient themes
CI/CD / test_and_check_app (push) Successful in 4m13s
CI/CD / Publish immutable image (push) Skipped
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-09 14:29:16 +02:00
Twyco 5194ed0a17 feat(KAN-16): add English localization foundation
CI/CD / test_and_check_app (push) Successful in 4m9s
CI/CD / Publish immutable image (push) Skipped
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 3m45s
2026-09-09 14:02:20 +02:00
42 changed files with 2081 additions and 562 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>
+29 -12
View File
@@ -1,18 +1,35 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { AvatarRoot } from "reka-ui"
import { cn } from "@/lib/utils"
import type { AvatarRootProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import type { AvatarVariants } from ".";
import { reactiveOmit } from "@vueuse/core";
import { AvatarRoot, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
import { avatarVariants } from ".";
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
const props = withDefaults(
defineProps<
AvatarRootProps & {
class?: HTMLAttributes["class"];
size?: AvatarVariants["size"];
shape?: AvatarVariants["shape"];
}
>(),
{ size: "default", shape: "circle" },
);
const delegatedProps = reactiveOmit(props, "class", "size", "shape");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<AvatarRoot
data-slot="avatar"
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)"
>
<slot />
</AvatarRoot>
<AvatarRoot
data-slot="avatar"
:data-size="size"
:data-shape="shape"
v-bind="forwardedProps"
:class="cn(avatarVariants({ size, shape }), props.class)"
>
<slot />
</AvatarRoot>
</template>
@@ -1,21 +1,28 @@
<script setup lang="ts">
import type { AvatarFallbackProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AvatarFallback } from "reka-ui"
import { cn } from "@/lib/utils"
import type { AvatarFallbackProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { AvatarFallback } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<AvatarFallbackProps & { class?: HTMLAttributes["class"] }>()
const props = defineProps<
AvatarFallbackProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = reactiveOmit(props, "class")
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<AvatarFallback
data-slot="avatar-fallback"
v-bind="delegatedProps"
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)"
>
<slot />
</AvatarFallback>
<AvatarFallback
data-slot="avatar-fallback"
v-bind="delegatedProps"
:class="
cn(
'bg-accent text-accent-foreground flex size-full items-center justify-center font-semibold',
props.class,
)
"
>
<slot />
</AvatarFallback>
</template>
@@ -1,41 +1,39 @@
<script setup lang="ts">
import { getInitials } from "@/composables/useInitials";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar/index";
import type { AvatarVariants } from "@/components/ui/avatar";
import type { UserModel } from "@/types/models/user-model";
import { usePage } from "@inertiajs/vue3";
import { computed, PropType } from "vue";
import { UserModel } from "@/types/models/user-model";
import { computed } from "vue";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getInitials } from "@/composables/useInitials";
const props = defineProps({
user: {
type: Object as PropType<UserModel>,
const props = withDefaults(
defineProps<{
user?: UserModel;
rounded?: boolean;
size?: AvatarVariants["size"];
label?: string;
}>(),
{
rounded: true,
size: "default",
},
rounded: {
type: Boolean,
default: false,
},
})
);
const page = usePage();
const auth = computed(() => page.props.auth);
const user = computed(() => props.user ?? auth.value.user)
const user = computed(() => props.user ?? page.props.auth.user);
const accessibleLabel = computed(() => props.label ?? user.value.name);
const initials = computed(() => getInitials(user.value.name));
</script>
<template>
<Avatar class="size-8 overflow-hidden" :class="rounded ? 'rounded-full' : 'rounded-lg'">
<Avatar :size="size" :shape="rounded ? 'circle' : 'rounded'">
<AvatarImage
v-if="user.avatar"
:src="user.avatar"
:alt="user.name"
:alt="accessibleLabel"
/>
<AvatarFallback
class="rounded-lg bg-neutral-200 font-semibold text-black dark:bg-neutral-700 dark:text-white"
>
{{ getInitials(auth.user?.name) }}
<AvatarFallback :aria-label="accessibleLabel">
{{ initials }}
</AvatarFallback>
</Avatar>
</template>
+31 -3
View File
@@ -1,3 +1,31 @@
export { default as Avatar } from "./Avatar.vue"
export { default as AvatarFallback } from "./AvatarFallback.vue"
export { default as AvatarImage } from "./AvatarImage.vue"
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
export { default as Avatar } from "./Avatar.vue";
export { default as AvatarFallback } from "./AvatarFallback.vue";
export { default as AvatarImage } from "./AvatarImage.vue";
export { default as UserAvatar } from "./UserAvatar.vue";
export const avatarVariants = cva(
"border-border bg-muted relative flex shrink-0 overflow-hidden border",
{
variants: {
size: {
sm: "size-6 text-label",
default: "size-8 text-caption",
lg: "size-10 text-body",
xl: "size-16 text-title",
},
shape: {
circle: "rounded-full",
rounded: "rounded-lg",
square: "rounded-sm",
},
},
defaultVariants: {
size: "default",
shape: "circle",
},
},
);
export type AvatarVariants = VariantProps<typeof avatarVariants>;
+21 -19
View File
@@ -1,26 +1,28 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { BadgeVariants } from "."
import { reactiveOmit } from "@vueuse/core"
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { badgeVariants } from "."
import type { PrimitiveProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import type { BadgeVariants } from ".";
import { reactiveOmit } from "@vueuse/core";
import { Primitive } from "reka-ui";
import { cn } from "@/lib/utils";
import { badgeVariants } from ".";
const props = defineProps<PrimitiveProps & {
variant?: BadgeVariants["variant"]
class?: HTMLAttributes["class"]
}>()
const props = defineProps<
PrimitiveProps & {
variant?: BadgeVariants["variant"];
class?: HTMLAttributes["class"];
}
>();
const delegatedProps = reactiveOmit(props, "class")
const delegatedProps = reactiveOmit(props, "class", "variant");
</script>
<template>
<Primitive
data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
<Primitive
data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template>
+27 -22
View File
@@ -1,26 +1,31 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
export { default as Badge } from "./Badge.vue"
export { default as Badge } from "./Badge.vue";
export const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2.5 py-1 text-label whitespace-nowrap transition-[background-color,border-color,color,box-shadow] outline-none [&>svg]:pointer-events-none [&>svg]:size-3 [&>svg]:shrink-0 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/35",
outline:
"border-border bg-card text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
success:
"border-transparent bg-success text-success-foreground [a&]:hover:brightness-95",
warning:
"border-transparent bg-warning text-warning-foreground [a&]:hover:brightness-95",
info: "border-transparent bg-info text-info-foreground [a&]:hover:brightness-95",
},
},
defaultVariants: {
variant: "default",
},
},
defaultVariants: {
variant: "default",
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
);
export type BadgeVariants = VariantProps<typeof badgeVariants>;
+37 -19
View File
@@ -1,29 +1,47 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from "."
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "."
import type { PrimitiveProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import type { ButtonVariants } from ".";
import { Primitive } from "reka-ui";
import { computed } from "vue";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import { buttonVariants } from ".";
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
variant?: ButtonVariants["variant"];
size?: ButtonVariants["size"];
class?: HTMLAttributes["class"];
disabled?: boolean;
loading?: boolean;
loadingLabel?: string;
}
const props = withDefaults(defineProps<Props>(), {
as: "button",
})
as: "button",
disabled: false,
loading: false,
});
const isDisabled = computed(() => props.disabled || props.loading);
</script>
<template>
<Primitive
data-slot="button"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
<Primitive
data-slot="button"
:data-loading="loading ? '' : undefined"
:as="as"
:as-child="asChild"
:disabled="isDisabled"
:aria-disabled="isDisabled ? 'true' : undefined"
:aria-busy="loading ? 'true' : undefined"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<Spinner
v-if="loading"
data-slot="button-spinner"
:label="loadingLabel"
/>
<slot />
</Primitive>
</template>
+33 -34
View File
@@ -1,38 +1,37 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
export { default as Button } from "./Button.vue"
export { default as Button } from "./Button.vue";
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
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",
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:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
"icon": "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-body font-semibold transition-[background-color,border-color,color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[loading]:cursor-wait data-[loading]:opacity-80 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 focus-visible:ring-offset-2 focus-visible:ring-offset-background [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default:
"border border-transparent bg-brand text-brand-foreground shadow-xs hover:brightness-95",
destructive:
"border border-transparent bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/35",
outline:
"border border-input bg-card text-foreground shadow-xs hover:border-primary/45 hover:bg-accent hover:text-accent-foreground",
secondary:
"border border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "border border-transparent hover:bg-accent hover:text-accent-foreground",
link: "min-h-0 border-0 px-0 text-primary underline-offset-4 shadow-none hover:underline",
},
size: {
default: "min-h-control px-4 py-2 has-[>svg]:px-3",
sm: "min-h-9 gap-1.5 px-3 py-1.5 has-[>svg]:px-2.5",
lg: "min-h-12 px-6 py-3 has-[>svg]:px-4",
icon: "size-control",
"icon-sm": "size-9",
"icon-lg": "size-12",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
);
export type ButtonVariants = VariantProps<typeof buttonVariants>;
@@ -1,35 +1,48 @@
<script setup lang="ts">
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "lucide-vue-next"
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Check } from "lucide-vue-next";
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<CheckboxRootEmits>()
const props = withDefaults(
defineProps<
CheckboxRootProps & {
class?: HTMLAttributes["class"];
invalid?: boolean;
}
>(),
{ invalid: false },
);
const emits = defineEmits<CheckboxRootEmits>();
const delegatedProps = reactiveOmit(props, "class")
const delegatedProps = reactiveOmit(props, "class", "invalid");
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<CheckboxRoot
v-slot="slotProps"
data-slot="checkbox"
v-bind="forwarded"
:class="
cn('peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
props.class)"
>
<CheckboxIndicator
data-slot="checkbox-indicator"
class="grid place-content-center text-current transition-none"
<CheckboxRoot
v-slot="slotProps"
data-slot="checkbox"
:data-invalid="invalid ? '' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
v-bind="forwarded"
:class="
cn(
'peer border-input bg-card size-5 shrink-0 rounded-md border shadow-xs transition-[background-color,border-color,box-shadow] outline-none hover:border-primary/55 data-[state=checked]:border-brand data-[state=checked]:bg-brand data-[state=checked]:text-brand-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25',
props.class,
)
"
>
<slot v-bind="slotProps">
<Check class="size-3.5" />
</slot>
</CheckboxIndicator>
</CheckboxRoot>
<CheckboxIndicator
data-slot="checkbox-indicator"
class="grid place-content-center text-current transition-none"
>
<slot v-bind="slotProps">
<Check class="size-4" stroke-width="2.5" />
</slot>
</CheckboxIndicator>
</CheckboxRoot>
</template>
+27 -22
View File
@@ -1,33 +1,38 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils"
import type { HTMLAttributes } from "vue";
import { useVModel } from "@vueuse/core";
import { cn } from "@/lib/utils";
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number | null
class?: HTMLAttributes["class"]
}>()
defaultValue?: string | number;
modelValue?: string | number | null;
class?: HTMLAttributes["class"];
invalid?: boolean;
}>();
const emits = defineEmits<{
(e: "update:modelValue", payload: string | number): void
}>()
(e: "update:modelValue", payload: string | number): void;
}>();
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
})
passive: true,
defaultValue: props.defaultValue,
});
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,
)"
>
<input
v-model="modelValue"
data-slot="input"
:data-invalid="invalid ? '' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:class="
cn(
'border-input bg-card placeholder:text-muted-foreground selection:bg-brand selection:text-brand-foreground min-h-control w-full min-w-0 rounded-md border px-3 py-2 text-base shadow-xs transition-[border-color,box-shadow] outline-none file:mr-3 file:inline-flex file:min-h-8 file:border-0 file:bg-transparent file:text-body file:font-semibold disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 md:text-body',
'hover:border-primary/45 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35',
'aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25',
props.class,
)
"
/>
</template>
@@ -1,51 +1,60 @@
<script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import type { SelectContentEmits, SelectContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import {
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import { SelectScrollDownButton, SelectScrollUpButton } from "."
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
import { SelectScrollDownButton, SelectScrollUpButton } from ".";
defineOptions({
inheritAttrs: false,
})
inheritAttrs: false,
});
const props = withDefaults(
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
{
position: "popper",
},
)
const emits = defineEmits<SelectContentEmits>()
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
{
position: "popper",
},
);
const emits = defineEmits<SelectContentEmits>();
const delegatedProps = reactiveOmit(props, "class")
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<SelectPortal>
<SelectContent
data-slot="select-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper'
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class,
)
"
>
<SelectScrollUpButton />
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1')">
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
<SelectPortal>
<SelectContent
data-slot="select-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground border-border data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-lg',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class,
)
"
>
<SelectScrollUpButton />
<SelectViewport
:class="
cn(
'p-1',
position === 'popper' &&
'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1',
)
"
>
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>
@@ -1,44 +1,48 @@
<script setup lang="ts">
import type { SelectItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "lucide-vue-next"
import type { SelectItemProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Check } from "lucide-vue-next";
import {
SelectItem,
SelectItemIndicator,
SelectItemText,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
SelectItem,
SelectItemIndicator,
SelectItemText,
useForwardProps,
} from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>()
const props = defineProps<
SelectItemProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = reactiveOmit(props, "class")
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps)
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectItem
data-slot="select-item"
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
props.class,
)
"
>
<span class="absolute right-2 flex size-3.5 items-center justify-center">
<SelectItemIndicator>
<slot name="indicator-icon">
<Check class="size-4" />
</slot>
</SelectItemIndicator>
</span>
<SelectItem
data-slot="select-item"
v-bind="forwardedProps"
:class="
cn(
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex min-h-9 w-full cursor-default items-center gap-2 rounded-sm py-2 pr-8 pl-2 text-body outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
props.class,
)
"
>
<span
class="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectItemIndicator>
<slot name="indicator-icon">
<Check class="size-4" />
</slot>
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItem>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItem>
</template>
@@ -1,33 +1,45 @@
<script setup lang="ts">
import type { SelectTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronDown } from "lucide-vue-next"
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import type { SelectTriggerProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { ChevronDown } from "lucide-vue-next";
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = withDefaults(
defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"], size?: "sm" | "default" }>(),
{ size: "default" },
)
defineProps<
SelectTriggerProps & {
class?: HTMLAttributes["class"];
size?: "sm" | "default";
invalid?: boolean;
}
>(),
{ size: "default", invalid: false },
);
const delegatedProps = reactiveOmit(props, "class", "size")
const forwardedProps = useForwardProps(delegatedProps)
const delegatedProps = reactiveOmit(props, "class", "size", "invalid");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectTrigger
data-slot="select-trigger"
:data-size="size"
v-bind="forwardedProps"
:class="cn(
'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)"
>
<slot />
<SelectIcon as-child>
<ChevronDown class="size-4 opacity-50" />
</SelectIcon>
</SelectTrigger>
<SelectTrigger
data-slot="select-trigger"
:data-size="size"
:data-invalid="invalid ? '' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
v-bind="forwardedProps"
:class="
cn(
'group/select-trigger border-input bg-card data-[placeholder]:text-muted-foreground flex w-fit items-center justify-between gap-2 rounded-md border px-3 py-2 text-body whitespace-nowrap shadow-xs transition-[border-color,box-shadow] outline-none hover:border-primary/45 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 data-[state=open]:border-primary data-[size=default]:min-h-control data-[size=sm]:min-h-9 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 [&_svg:not([class*=\'text-\'])]:text-muted-foreground',
props.class,
)
"
>
<slot />
<SelectIcon as-child>
<ChevronDown
class="size-4 transition-transform duration-150 group-data-[state=open]/select-trigger:rotate-180"
/>
</SelectIcon>
</SelectTrigger>
</template>
+16 -10
View File
@@ -1,17 +1,23 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { Loader2Icon } from "lucide-vue-next"
import { cn } from "@/lib/utils"
import type { HTMLAttributes } from "vue";
import { Loader2Icon } from "lucide-vue-next";
import { computed } from "vue";
import { useLocalization } from "@/composables/useLocalization";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
class?: HTMLAttributes["class"];
label?: string;
}>();
const { t } = useLocalization();
const accessibleLabel = computed(() => props.label ?? t("common.loading"));
</script>
<template>
<Loader2Icon
role="status"
aria-label="Loading"
:class="cn('size-4 animate-spin', props.class)"
/>
<Loader2Icon
role="status"
:aria-label="accessibleLabel"
:class="cn('size-4 animate-spin', props.class)"
/>
</template>
+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'));
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Tests\Feature;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class UiComponentsTest extends TestCase
{
public function test_foundational_ui_component_behaviour(): void
{
$process = new Process(['node', 'tests/Fixtures/ui-components.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('UI component behavior checks passed.', $process->getOutput());
}
}
+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();
}
+247
View File
@@ -0,0 +1,247 @@
import { createApp, h, ref } from 'vue';
import '../../resources/css/app.css';
import AppearanceTabs from '../../resources/js/components/AppearanceTabs.vue';
import {
Avatar,
AvatarFallback,
} from '../../resources/js/components/ui/avatar';
import { Badge } from '../../resources/js/components/ui/badge';
import { Button } from '../../resources/js/components/ui/button';
import { Checkbox } from '../../resources/js/components/ui/checkbox';
import { Input } from '../../resources/js/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../../resources/js/components/ui/select';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
const Demo = {
setup() {
const selectedStatus = ref('review');
const subscribed = ref(true);
return () =>
h('main', { class: 'mx-auto grid max-w-4xl gap-column p-page' }, [
h(
'div',
{
class: 'flex flex-wrap items-center justify-between gap-stack',
},
[
h('div', {}, [
h(
'h1',
{ class: 'text-headline' },
'Foundational UI components',
),
h(
'p',
{ class: 'text-muted-foreground' },
'Fluid Emerald states and controls',
),
]),
h(AppearanceTabs),
],
),
h('section', { class: 'grid gap-column sm:grid-cols-2' }, [
h(
'article',
{
class: 'grid content-start gap-column rounded-md border bg-card p-card',
},
[
h(
'h2',
{ class: 'text-title' },
'Buttons and badges',
),
h('div', { class: 'flex flex-wrap gap-stack' }, [
h(Button, {}, () => 'Primary'),
h(
Button,
{ variant: 'secondary' },
() => 'Secondary',
),
h(
Button,
{ variant: 'outline' },
() => 'Outline',
),
h(
Button,
{ variant: 'destructive' },
() => 'Delete',
),
h(Button, { disabled: true }, () => 'Disabled'),
h(Button, { loading: true }, () => 'Saving'),
]),
h('div', { class: 'flex flex-wrap gap-stack' }, [
h(Badge, {}, () => 'Active'),
h(Badge, { variant: 'success' }, () => 'Done'),
h(
Badge,
{ variant: 'warning' },
() => 'Medium',
),
h(Badge, { variant: 'info' }, () => 'Low'),
h(
Badge,
{ variant: 'destructive' },
() => 'High',
),
h(
Badge,
{ variant: 'outline' },
() => 'KAN-18',
),
]),
],
),
h(
'article',
{
class: 'grid content-start gap-column rounded-md border bg-card p-card',
},
[
h(
'h2',
{ class: 'text-title' },
'Inputs and selection',
),
h(
'label',
{ class: 'grid gap-stack', for: 'title' },
[
h(
'span',
{ class: 'text-body font-medium' },
'Title',
),
h(Input, {
id: 'title',
placeholder: 'Ticket title',
}),
],
),
h(
'label',
{
class: 'grid gap-stack',
for: 'invalid-title',
},
[
h(
'span',
{ class: 'text-body font-medium' },
'Invalid title',
),
h(Input, {
id: 'invalid-title',
invalid: true,
modelValue: 'Needs attention',
}),
],
),
h(
'label',
{ class: 'grid gap-stack', for: 'status' },
[
h(
'span',
{ class: 'text-body font-medium' },
'Status',
),
h(
Select,
{
modelValue: selectedStatus.value,
'onUpdate:modelValue': (value) => {
selectedStatus.value = value;
},
},
{
default: () => [
h(
SelectTrigger,
{
id: 'status',
class: 'w-full',
},
{
default: () =>
h(SelectValue, {
placeholder:
'Choose a status',
}),
},
),
h(SelectContent, {}, () => [
h(
SelectItem,
{ value: 'todo' },
() => 'To do',
),
h(
SelectItem,
{ value: 'review' },
() => 'Review',
),
h(
SelectItem,
{ value: 'done' },
() => 'Done',
),
]),
],
},
),
],
),
h(
'label',
{ class: 'flex items-center gap-stack' },
[
h(Checkbox, {
modelValue: subscribed.value,
'onUpdate:modelValue': (value) => {
subscribed.value = Boolean(value);
},
}),
h('span', {}, 'Notify me about changes'),
],
),
],
),
]),
h(
'section',
{
class: 'flex items-center gap-stack rounded-md border bg-card p-card',
},
[
h(Avatar, { size: 'sm' }, () =>
h(AvatarFallback, {}, () => 'NS'),
),
h(Avatar, {}, () => h(AvatarFallback, {}, () => 'AK')),
h(Avatar, { size: 'lg' }, () =>
h(AvatarFallback, {}, () => 'LT'),
),
h(
'span',
{ class: 'text-muted-foreground' },
'24px, 32px and 40px avatars',
),
],
),
]);
},
};
createApp(Demo)
.use(createLocalization())
.use(createAppearance('light'))
.mount('#app');
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en" data-appearance="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UI component preview</title>
</head>
<body>
<div id="app"></div>
<script
type="module"
src="/tests/Fixtures/ui-components-preview.mjs"
></script>
</body>
</html>
+184
View File
@@ -0,0 +1,184 @@
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
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',
});
try {
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { en } = await server.ssrLoadModule('/resources/js/locales/en.ts');
const { default: Button } = await server.ssrLoadModule(
'/resources/js/components/ui/button/Button.vue',
);
const { buttonVariants } = await server.ssrLoadModule(
'/resources/js/components/ui/button/index.ts',
);
const { default: Input } = await server.ssrLoadModule(
'/resources/js/components/ui/input/Input.vue',
);
const { default: Select } = await server.ssrLoadModule(
'/resources/js/components/ui/select/Select.vue',
);
const { default: SelectTrigger } = await server.ssrLoadModule(
'/resources/js/components/ui/select/SelectTrigger.vue',
);
const { default: SelectValue } = await server.ssrLoadModule(
'/resources/js/components/ui/select/SelectValue.vue',
);
const { default: Checkbox } = await server.ssrLoadModule(
'/resources/js/components/ui/checkbox/Checkbox.vue',
);
const { default: Badge } = await server.ssrLoadModule(
'/resources/js/components/ui/badge/Badge.vue',
);
const { badgeVariants } = await server.ssrLoadModule(
'/resources/js/components/ui/badge/index.ts',
);
const { default: Avatar } = await server.ssrLoadModule(
'/resources/js/components/ui/avatar/Avatar.vue',
);
const { default: AvatarFallback } = await server.ssrLoadModule(
'/resources/js/components/ui/avatar/AvatarFallback.vue',
);
const { default: UserAvatar } = await server.ssrLoadModule(
'/resources/js/components/ui/avatar/UserAvatar.vue',
);
const { avatarVariants } = await server.ssrLoadModule(
'/resources/js/components/ui/avatar/index.ts',
);
const german = createLocalization(
{ locale: 'de' },
{ de: { ...en, 'common.loading': 'Wird geladen…' } },
);
const render = (component, props = {}, slots = {}) =>
renderToString(
createSSRApp({ render: () => h(component, props, slots) }).use(
german,
),
);
const loadingButton = await render(
Button,
{ loading: true },
{ default: () => 'Save' },
);
assert.match(loadingButton, /<button/);
assert.match(loadingButton, /disabled/);
assert.match(loadingButton, /aria-disabled="true"/);
assert.match(loadingButton, /aria-busy="true"/);
assert.match(loadingButton, /data-loading/);
assert.match(loadingButton, /role="status"/);
assert.match(loadingButton, /aria-label="Wird geladen…"/);
assert.match(loadingButton, />Save</);
assert.match(buttonVariants(), /min-h-control/);
assert.match(buttonVariants(), /bg-brand/);
assert.match(buttonVariants({ variant: 'outline' }), /bg-card/);
const invalidInput = await render(Input, {
disabled: true,
invalid: true,
name: 'title',
});
assert.match(invalidInput, /<input/);
assert.match(invalidInput, /name="title"/);
assert.match(invalidInput, /disabled/);
assert.match(invalidInput, /aria-invalid="true"/);
assert.match(invalidInput, /data-invalid/);
assert.match(invalidInput, /min-h-control/);
const select = await render(
Select,
{ disabled: true },
{
default: () =>
h(
SelectTrigger,
{ invalid: true },
{
default: () =>
h(SelectValue, { placeholder: 'Choose' }),
},
),
},
);
assert.match(select, /role="combobox"/);
assert.match(select, /disabled/);
assert.match(select, /aria-invalid="true"/);
assert.match(select, /data-invalid/);
assert.match(select, /min-h-control/);
const checkbox = await render(Checkbox, {
disabled: true,
invalid: true,
});
assert.match(checkbox, /role="checkbox"/);
assert.match(checkbox, /aria-checked="false"/);
assert.match(checkbox, /disabled/);
assert.match(checkbox, /aria-invalid="true"/);
assert.match(checkbox, /data-invalid/);
const successBadge = await render(
Badge,
{ variant: 'success' },
{ default: () => 'Done' },
);
assert.match(successBadge, /bg-success/);
assert.match(successBadge, />Done</);
assert.doesNotMatch(successBadge, /variant="success"/);
assert.match(badgeVariants({ variant: 'success' }), /bg-success/);
assert.match(badgeVariants({ variant: 'warning' }), /bg-warning/);
assert.match(badgeVariants({ variant: 'info' }), /bg-info/);
const avatar = await render(
Avatar,
{ size: 'xl', shape: 'square' },
{ default: () => h(AvatarFallback, {}, () => 'NS') },
);
assert.match(avatar, /data-size="xl"/);
assert.match(avatar, /data-shape="square"/);
assert.match(avatar, /size-16/);
assert.match(avatar, /rounded-sm/);
assert.match(avatar, />NS</);
assert.match(avatarVariants({ size: 'sm' }), /size-6/);
const userAvatar = await render(UserAvatar, {
rounded: true,
size: 'lg',
user: {
id: 1,
name: 'Nico Schröder',
email: 'nico@example.test',
avatar: null,
avatar_color: null,
avatar_is_generated: false,
email_verified_at: null,
must_change_password_on_next_login: false,
created_at: '2026-09-09T00:00:00Z',
updated_at: '2026-09-09T00:00:00Z',
},
});
assert.match(userAvatar, /data-size="lg"/);
assert.match(userAvatar, /data-shape="circle"/);
assert.match(userAvatar, /aria-label="Nico Schröder"/);
assert.match(userAvatar, />NS</);
console.log('UI component behavior checks passed.');
} finally {
await server.close();
}