phase 2 - ui refactor

This commit is contained in:
mohamad 2025-06-28 14:15:03 +02:00
parent 2510277907
commit 8b181087c3
31 changed files with 1147 additions and 2875 deletions

View File

@ -1,9 +1,11 @@
// @ts-nocheck
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from "eslint-plugin-storybook";
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginTailwindcss from 'eslint-plugin-tailwindcss'
import pluginVitest from '@vitest/eslint-plugin'
import pluginPlaywright from 'eslint-plugin-playwright'
import pluginOxlint from 'eslint-plugin-oxlint'
@ -36,4 +38,5 @@ export default defineConfigWithVueTs(
},
...pluginOxlint.configs['flat/recommended'],
skipFormatting,
pluginTailwindcss.configs.recommended,
)

1615
fe/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@
"test:unit": "vitest",
"test:e2e": "playwright test",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"type-check": "vue-tsc --noEmit",
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
"lint:eslint": "eslint . --fix",
"lint": "run-s lint:*",
@ -19,18 +19,15 @@
"build-storybook": "storybook build"
},
"dependencies": {
"@headlessui/vue": "^1.7.23",
"@iconify/vue": "^4.1.1",
"@sentry/tracing": "^7.120.3",
"@sentry/vue": "^7.120.3",
"@supabase/auth-js": "^2.69.1",
"@supabase/supabase-js": "^2.49.4",
"@vueuse/core": "^13.1.0",
"axios": "^1.9.0",
"date-fns": "^4.1.0",
"framer-motion": "^12.16.0",
"motion": "^12.15.0",
"pinia": "^3.0.2",
"qs": "^6.14.0",
"reka-ui": "^2.3.1",
"vue": "^3.5.13",
"vue-i18n": "^9.9.1",
"vue-router": "^4.5.1",
@ -38,11 +35,11 @@
"workbox-background-sync": "^7.3.0"
},
"devDependencies": {
"@headlessui/tailwindcss": "^0.2.2",
"@intlify/unplugin-vue-i18n": "^4.0.0",
"@playwright/test": "^1.51.1",
"@storybook/addon-docs": "^9.0.2",
"@storybook/addon-onboarding": "^9.0.2",
"@storybook/vue3-vite": "^9.0.2",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.10",
"@tsconfig/node22": "^22.0.1",
"@types/date-fns": "^2.5.3",
"@types/jsdom": "^21.1.7",
@ -54,18 +51,21 @@
"@vue/eslint-config-typescript": "^14.5.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.16",
"eslint": "^9.26.0",
"eslint-plugin-oxlint": "^0.16.0",
"eslint-plugin-playwright": "^2.2.0",
"eslint-plugin-storybook": "^9.0.2",
"eslint-plugin-tailwindcss": "^3.13.0",
"eslint-plugin-vue": "~10.0.0",
"jiti": "^2.4.2",
"jsdom": "^26.0.0",
"npm-run-all2": "^7.0.2",
"oxlint": "^0.16.0",
"postcss": "^8.4.34",
"prettier": "^3.5.3",
"sass": "^1.88.0",
"storybook": "^9.0.2",
"tailwindcss": "^3.4.4",
"typescript": "~5.8.0",
"vite": "^6.2.4",
"vite-plugin-pwa": "^1.0.0",

6
fe/postcss.config.cjs Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -10,21 +10,3 @@ import NotificationDisplay from '@/components/global/NotificationDisplay.vue';
// const offlineStore = useOfflineStore();
// offlineStore.init(); // If you move init logic here
</script>
<style lang="scss">
body {
margin: 0;
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
background-color: #f0f2f5;
/* Example background */
}
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
</style>

9
fe/src/assets/base.css Normal file
View File

@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-light text-dark font-hand antialiased;
}
}

View File

@ -1,96 +0,0 @@
// src/assets/main.scss
// @import './variables.scss'; // Your custom variables
@use './valerie-ui.scss';
// Example global styles
body {
font-family: sans-serif;
margin: 0;
background-color: var(--bg-color-page, #f4f4f8);
color: var(--text-color, #333);
}
a {
color: var(--primary-color);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
// Offline UI styles
.offline-item {
position: relative;
opacity: 0.8;
transition: opacity 0.3s ease;
&::after {
content: '';
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 1rem;
height: 1rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8'/%3E%3Cpath d='M3 3v5h5'/%3E%3Cpath d='M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16'/%3E%3Cpath d='M16 21h5v-5'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
animation: spin 1s linear infinite;
}
&.synced {
opacity: 1;
&::after {
display: none;
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// Disabled offline features
.feature-offline-disabled {
position: relative;
cursor: not-allowed;
opacity: 0.6;
&::before {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding: 0.5rem;
background-color: var(--bg-color-tooltip, #333);
color: white;
border-radius: 0.25rem;
font-size: 0.875rem;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
z-index: 1000;
}
&:hover::before {
opacity: 1;
visibility: visible;
}
}
// Add more global utility classes or base styles

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +0,0 @@
// src/assets/variables.scss
:root {
--primary-color: #1976D2;
--secondary-color: #26A69A;
--accent-color: #9C27B0;
--dark-color: #1D1D1D;
--dark-page-color: #121212;
--positive-color: #21BA45;
--negative-color: #C10015;
--info-color: #31CCEC;
--warning-color: #F2C037;
--text-color: #333;
--bg-color: #fff;
--bg-color-page: #f0f2f5;
--header-height: 56px;
--footer-height: 56px;
}

View File

@ -0,0 +1,13 @@
<template>
<Icon :icon="name" v-bind="$attrs" />
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue'
interface Props {
name: string
}
defineProps<Props>()
</script>

View File

@ -29,19 +29,20 @@
<div class="neo-item-actions">
<button class="btn btn-sm btn-neutral" @click="toggleTimer"
:disabled="chore.is_completed || !chore.current_assignment_id">
{{ isActiveTimer ? '⏸️' : '▶️' }}
<BaseIcon :name="isActiveTimer ? 'heroicons:pause-20-solid' : 'heroicons:play-20-solid'"
class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-neutral" @click="emit('open-details', chore)" title="View Details">
📋
<BaseIcon name="heroicons:clipboard-document-list-20-solid" class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-neutral" @click="emit('open-history', chore)" title="View History">
📅
<BaseIcon name="heroicons:calendar-days-20-solid" class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-neutral" @click="emit('edit', chore)">
<BaseIcon name="heroicons:pencil-square-20-solid" class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-danger" @click="emit('delete', chore)">
🗑
<BaseIcon name="heroicons:trash-20-solid" class="w-4 h-4" />
</button>
</div>
</div>
@ -62,6 +63,7 @@ import { formatDistanceToNow, parseISO, isToday, isPast } from 'date-fns';
import type { ChoreWithCompletion } from '../types/chore';
import type { TimeEntry } from '../stores/timeEntryStore';
import { formatDuration } from '../utils/formatters';
import BaseIcon from './BaseIcon.vue';
const props = defineProps<{
chore: ChoreWithCompletion;

View File

@ -0,0 +1,73 @@
<template>
<button :type="type" :class="buttonClasses" :disabled="disabled">
<slot />
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
export interface ButtonProps {
/** Visual variant */
variant?: 'solid' | 'outline' | 'ghost'
/** Color token */
color?: 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'neutral'
/** Size preset */
size?: 'sm' | 'md' | 'lg'
/** HTML button type */
type?: 'button' | 'submit' | 'reset'
/** Disabled state */
disabled?: boolean
}
const props = withDefaults(defineProps<ButtonProps>(), {
variant: 'solid',
color: 'primary',
size: 'md',
type: 'button',
disabled: false,
})
/**
* Tailwind class maps keep in sync with design tokens in `tailwind.config.ts`.
*/
const base =
'inline-flex items-center justify-center font-medium rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 transition disabled:opacity-50 disabled:pointer-events-none'
const sizeClasses: Record<string, string> = {
sm: 'px-2.5 py-1.5 text-xs',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
}
const colorMatrix: Record<string, Record<string, string>> = {
solid: {
primary: 'bg-primary text-white hover:bg-primary/90',
secondary: 'bg-secondary text-white hover:bg-secondary/90',
success: 'bg-success text-white hover:bg-success/90',
warning: 'bg-warning text-white hover:bg-warning/90',
danger: 'bg-danger text-white hover:bg-danger/90',
neutral: 'bg-neutral text-white hover:bg-neutral/90',
},
outline: {
primary: 'border border-primary text-primary hover:bg-primary/10',
secondary: 'border border-secondary text-secondary hover:bg-secondary/10',
success: 'border border-success text-success hover:bg-success/10',
warning: 'border border-warning text-warning hover:bg-warning/10',
danger: 'border border-danger text-danger hover:bg-danger/10',
neutral: 'border border-neutral text-neutral hover:bg-neutral/10',
},
ghost: {
primary: 'text-primary hover:bg-primary/10',
secondary: 'text-secondary hover:bg-secondary/10',
success: 'text-success hover:bg-success/10',
warning: 'text-warning hover:bg-warning/10',
danger: 'text-danger hover:bg-danger/10',
neutral: 'text-neutral hover:bg-neutral/10',
},
}
const buttonClasses = computed(() => {
return [base, sizeClasses[props.size], colorMatrix[props.variant][props.color]].join(' ')
})
</script>

View File

@ -0,0 +1,36 @@
<template>
<TransitionRoot as="template" :show="modelValue">
<Dialog as="div" class="fixed inset-0 z-50 overflow-y-auto" @close="emitClose">
<div class="flex min-h-screen items-center justify-center px-4 py-8 text-center sm:block sm:p-0">
<!-- Backdrop -->
<TransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0"
enter-to="opacity-100" leave="ease-in duration-200" leave-from="opacity-100" leave-to="opacity-0">
<DialogOverlay class="fixed inset-0 bg-black/40 ui-not-focusable" />
</TransitionChild>
<!-- Panel -->
<TransitionChild as="template" enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100" leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<DialogPanel
class="w-full transform overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-dark dark:text-white sm:max-w-lg">
<slot />
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup lang="ts">
import { Dialog, DialogOverlay, DialogPanel, TransitionRoot, TransitionChild } from '@headlessui/vue'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
function emitClose() {
emit('update:modelValue', false)
}
</script>

View File

@ -0,0 +1,27 @@
<template>
<Listbox v-slot="{ open }" :model-value="modelValue" @update:modelValue="updateValue">
<div class="relative">
<slot name="button" />
<Transition enter="transition ease-out duration-100" enter-from="transform opacity-0 scale-95"
enter-to="transform opacity-100 scale-100" leave="transition ease-in duration-75"
leave-from="transform opacity-100 scale-100" leave-to="transform opacity-0 scale-95">
<ListboxOptions v-if="open"
class="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none dark:bg-dark dark:text-white sm:text-sm">
<slot />
</ListboxOptions>
</Transition>
</div>
</Listbox>
</template>
<script setup lang="ts">
import { Listbox, ListboxOptions } from '@headlessui/vue'
import { ref } from 'vue'
const props = defineProps<{ modelValue: unknown }>()
const emit = defineEmits<{ (e: 'update:modelValue', value: unknown): void }>()
function updateValue(value: unknown) {
emit('update:modelValue', value)
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<Menu as="div" class="relative inline-block text-left">
<slot name="button" />
<Transition enter="transition ease-out duration-100" enter-from="transform opacity-0 scale-95"
enter-to="transform opacity-100 scale-100" leave="transition ease-in duration-75"
leave-from="transform opacity-100 scale-100" leave-to="transform opacity-0 scale-95">
<MenuItems
class="absolute right-0 z-30 mt-2 w-56 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black/5 focus:outline-none dark:bg-dark dark:text-white">
<slot />
</MenuItems>
</Transition>
</Menu>
</template>
<script setup lang="ts">
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/vue'
</script>

View File

@ -0,0 +1,27 @@
<template>
<Switch :model-value="modelValue" @update:modelValue="emit('update:modelValue', $event)" :class="[
modelValue ? activeClasses : inactiveClasses,
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ui-not-focusable',
]">
<span aria-hidden="true" :class="[
modelValue ? 'translate-x-6' : 'translate-x-1',
'inline-block h-4 w-4 transform rounded-full bg-white transition',
]" />
</Switch>
</template>
<script setup lang="ts">
import { Switch } from '@headlessui/vue'
const props = defineProps<{ modelValue: boolean; color?: 'primary' | 'success' | 'danger' }>()
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
const activeColorMap = {
primary: 'bg-primary',
success: 'bg-success',
danger: 'bg-danger',
}
const activeClasses = activeColorMap[props.color ?? 'primary']
const inactiveClasses = 'bg-neutral/40'
</script>

View File

@ -0,0 +1,14 @@
<template>
<TabGroup as="div">
<div class="flex space-x-2" v-if="$slots.tabs">
<slot name="tabs" />
</div>
<TabPanels class="mt-4">
<slot />
</TabPanels>
</TabGroup>
</template>
<script setup lang="ts">
import { TabGroup, TabPanels } from '@headlessui/vue'
</script>

View File

@ -0,0 +1,11 @@
<template>
<Transition enter="transition-all ease-out duration-300" enter-from="max-h-0 opacity-0"
enter-to="max-h-screen opacity-100" leave="transition-all ease-in duration-200"
leave-from="max-h-screen opacity-100" leave-to="max-h-0 opacity-0">
<slot />
</Transition>
</template>
<script setup lang="ts">
import { Transition } from '@headlessui/vue'
</script>

View File

@ -0,0 +1,35 @@
import { mount } from '@vue/test-utils'
import Button from '../Button.vue'
import { describe, it, expect } from 'vitest'
describe('Button.vue', () => {
it('renders default (solid primary md) styles', () => {
const wrapper = mount(Button, {
slots: { default: 'Click Me' },
})
expect(wrapper.text()).toBe('Click Me')
const cls = wrapper.attributes('class')
expect(cls).toContain('bg-primary')
expect(cls).toContain('px-4') // md size padding
})
it('renders outline success variant', () => {
const wrapper = mount(Button, {
props: { variant: 'outline', color: 'success' },
slots: { default: 'Save' },
})
const cls = wrapper.attributes('class')
expect(cls).toContain('border-success')
expect(cls).toContain('text-success')
})
it('renders ghost danger lg size', () => {
const wrapper = mount(Button, {
props: { variant: 'ghost', color: 'danger', size: 'lg' },
slots: { default: 'Delete' },
})
const cls = wrapper.attributes('class')
expect(cls).toContain('text-danger')
expect(cls).toContain('px-6') // lg size padding
})
})

View File

@ -0,0 +1,22 @@
import { mount } from '@vue/test-utils'
import Dialog from '../Dialog.vue'
import { describe, it, expect } from 'vitest'
describe('Dialog.vue', () => {
it('renders slot content when open', () => {
const wrapper = mount(Dialog, {
props: { modelValue: true },
slots: { default: '<p data-test="content">Hello</p>' },
})
expect(wrapper.find('[data-test="content"]').exists()).toBe(true)
})
it('does not render when closed', () => {
const wrapper = mount(Dialog, {
props: { modelValue: false },
slots: { default: '<p>Hidden</p>' },
})
// Should not render content
expect(wrapper.html()).toBe('')
})
})

View File

@ -0,0 +1,23 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import Listbox from '../Listbox.vue'
const Option = {
template: '<div role="option" :value="value">{{ value }}</div>',
props: ['value'],
}
describe('Listbox.vue', () => {
it('emits update when option selected', async () => {
const wrapper = mount(Listbox, {
props: { modelValue: 'one' },
slots: {
button: '<button data-test="toggle">Toggle</button>',
default: '<div role="option">one</div>',
},
})
await wrapper.find('[data-test="toggle"]').trigger('click')
// The selection event is internal; basic mount test
expect(wrapper.find('[data-test="toggle"]').exists()).toBe(true)
})
})

View File

@ -0,0 +1,20 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import Menu from '../Menu.vue'
// Provide a minimal menu item component for slot
const MenuItemStub = {
template: '<div role="menuitem">Item</div>',
}
describe('Menu.vue', () => {
it('renders menu button slot', () => {
const wrapper = mount(Menu, {
slots: {
button: '<button data-test="trigger">Open</button>',
default: MenuItemStub,
},
})
expect(wrapper.find('[data-test="trigger"]').exists()).toBe(true)
})
})

View File

@ -0,0 +1,14 @@
import { mount } from '@vue/test-utils'
import UISwitch from '../Switch.vue'
import { describe, it, expect } from 'vitest'
describe('Switch.vue', () => {
it('emits update:modelValue when toggled', async () => {
const wrapper = mount(UISwitch, {
props: { modelValue: false },
})
// the headlessui switch renders a button element
await wrapper.find('button').trigger('click')
expect(wrapper.emitted()['update:modelValue']).toBeTruthy()
})
})

View File

@ -0,0 +1,16 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import Tabs from '../Tabs.vue'
describe('Tabs.vue', () => {
it('renders tabs and panels', () => {
const wrapper = mount(Tabs, {
slots: {
tabs: '<button>Tab 1</button>',
default: '<div>Panel 1</div>',
},
})
expect(wrapper.text()).toContain('Tab 1')
expect(wrapper.text()).toContain('Panel 1')
})
})

View File

@ -0,0 +1,14 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import TransitionExpand from '../TransitionExpand.vue'
describe('TransitionExpand.vue', () => {
it('renders slot content', () => {
const wrapper = mount(TransitionExpand, {
slots: {
default: '<p data-test="content">Hello</p>',
},
})
expect(wrapper.find('[data-test="content"]').exists()).toBe(true)
})
})

View File

@ -0,0 +1,7 @@
export { default as Button } from './Button.vue'
export { default as Dialog } from './Dialog.vue'
export { default as Menu } from './Menu.vue'
export { default as Listbox } from './Listbox.vue'
export { default as Tabs } from './Tabs.vue'
export { default as Switch } from './Switch.vue'
export { default as TransitionExpand } from './TransitionExpand.vue'

View File

@ -10,7 +10,7 @@ import deMessages from './i18n/de.json'
import frMessages from './i18n/fr.json'
import esMessages from './i18n/es.json'
import nlMessages from './i18n/nl.json'
import './assets/main.scss'
import './assets/base.css'
import { api, globalAxios } from '@/services/api'
import { useAuthStore } from '@/stores/auth'

View File

@ -12,6 +12,7 @@ import { useTimeEntryStore, type TimeEntry } from '../stores/timeEntryStore';
import { storeToRefs } from 'pinia';
import { useAuthStore } from '@/stores/auth';
import type { UserPublic } from '@/types/user';
import BaseIcon from '@/components/BaseIcon.vue';
const { t } = useI18n()
@ -813,8 +814,13 @@ const getAssignmentIdForUser = (userId: number): number | null => {
<div class="assignment-main">
<span class="assigned-user">{{ assignment.assigned_user?.name || assignment.assigned_user?.email
}}</span>
<span class="assignment-status" :class="{ completed: assignment.is_complete }">
{{ assignment.is_complete ? '✅ Completed' : '⏳ Pending' }}
<span class="assignment-status">
<template v-if="assignment.is_complete">
<BaseIcon name="heroicons:check-circle-20-solid" class="w-4 h-4 text-success inline" /> Completed
</template>
<template v-else>
<BaseIcon name="heroicons:clock-20-solid" class="w-4 h-4 text-warning inline" /> Pending
</template>
</span>
</div>
<div class="assignment-details">

49
fe/tailwind.config.ts Normal file
View File

@ -0,0 +1,49 @@
// @ts-nocheck
import type { Config } from 'tailwindcss'
import forms from '@tailwindcss/forms'
import typography from '@tailwindcss/typography'
import headlessui from '@headlessui/tailwindcss'
const config: Config = {
darkMode: 'class',
content: [
'./index.html',
'./src/**/*.{vue,js,ts,jsx,tsx}',
],
theme: {
extend: {
colors: {
primary: '#FF7B54',
secondary: '#FFB26B',
accent: '#FFD56B',
info: '#54C7FF',
success: '#A0E7A0',
warning: '#FFD56B',
danger: '#FF4D4D',
dark: '#393E46',
light: '#FFF8F0',
black: '#000000',
neutral: '#64748B',
},
spacing: {
1: '4px',
4: '16px',
6: '24px',
8: '32px',
12: '48px',
16: '64px',
},
fontFamily: {
hand: ['"Patrick Hand"', 'cursive'],
sans: ['Inter', 'ui-sans-serif', 'system-ui'],
},
},
},
plugins: [
forms,
typography,
headlessui({ prefix: 'ui' }),
],
}
export default config

View File

@ -1,11 +1,17 @@
{
"extends": "./tsconfig.app.json",
"include": ["src/**/__tests__/*", "env.d.ts"],
"include": [
"src/**/__tests__/*",
"env.d.ts"
],
"exclude": [],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
"lib": [],
"types": ["node", "jsdom"]
"types": [
"node",
"jsdom",
"vitest"
]
}
}

6
package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "doe",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}