Replace fullcalendar calendar header with custom toolbar

This commit is contained in:
Gregor Vostrak
2026-03-04 15:47:23 +01:00
parent c581ad8854
commit 89a4a1962a
9 changed files with 231 additions and 105 deletions

View File

@@ -1,7 +1,7 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures'; import { test } from '../playwright/fixtures';
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
async function goToCalendar(page: Page) { async function goToCalendar(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar'); await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
@@ -17,6 +17,10 @@ async function clearCalendarSettings(page: Page) {
await page.evaluate(() => localStorage.removeItem('solidtime:calendar-settings')); await page.evaluate(() => localStorage.removeItem('solidtime:calendar-settings'));
} }
function getCalendarTitle(page: Page) {
return page.getByTestId('calendar-title');
}
test.describe('Calendar Settings', () => { test.describe('Calendar Settings', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await clearCalendarSettings(page); await clearCalendarSettings(page);
@@ -39,7 +43,9 @@ test.describe('Calendar Settings', () => {
// Change snap interval to 30 min // Change snap interval to 30 min
await page.getByLabel('Snap Interval').click(); await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '30 min' }).click(); await page.getByRole('option', { name: '30 min' }).click();
await page.locator('.fc-toolbar-title').click();
// Close the popover by pressing Escape
await page.keyboard.press('Escape');
// Verify localStorage was updated // Verify localStorage was updated
const stored = await page.evaluate(() => const stored = await page.evaluate(() =>
@@ -54,7 +60,7 @@ test.describe('Calendar Settings', () => {
await expect(page.getByLabel('Snap Interval')).toContainText('30 min'); await expect(page.getByLabel('Snap Interval')).toContainText('30 min');
}); });
test('start time change is applied to calendar and rejects values >= end time', async ({ test('start time change is applied to calendar and rejects invalid values', async ({
page, page,
}) => { }) => {
await goToCalendar(page); await goToCalendar(page);
@@ -71,24 +77,23 @@ test.describe('Calendar Settings', () => {
// Change start time to 8 AM (valid) // Change start time to 8 AM (valid)
await page.getByLabel('Start Time').click(); await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '8:00 AM' }).click(); await page.getByRole('option', { name: '8:00 AM' }).click();
await page.locator('.fc-toolbar-title').click();
// Calendar should no longer show hours before 8 AM // Try to set start time to 6 PM (invalid: equals end time) — should be rejected
await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).toHaveCount(0);
await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0);
// Try to set start time to 6 PM (invalid: equals end time)
await openSettingsPopover(page);
await page.getByLabel('Start Time').click(); await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '6:00 PM' }).click(); await page.getByRole('option', { name: '6:00 PM' }).click();
// Should be rejected — start time stays at 8 AM // Should be rejected — start time stays at 8 AM
await expect(page.getByLabel('Start Time')).toContainText('8:00 AM'); await expect(page.getByLabel('Start Time')).toContainText('8:00 AM');
// Close the popover
await page.keyboard.press('Escape');
// Calendar should no longer show hours before 8 AM
await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).toHaveCount(0);
await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0);
}); });
test('end time change is applied to calendar and rejects values <= start time', async ({ test('end time change is applied to calendar and rejects invalid values', async ({ page }) => {
page,
}) => {
await goToCalendar(page); await goToCalendar(page);
// Verify 19:00 slot exists with default end (24:00) // Verify 19:00 slot exists with default end (24:00)
@@ -103,19 +108,20 @@ test.describe('Calendar Settings', () => {
// Change end time to 6 PM (valid) // Change end time to 6 PM (valid)
await page.getByLabel('End Time').click(); await page.getByLabel('End Time').click();
await page.getByRole('option', { name: '6:00 PM' }).click(); await page.getByRole('option', { name: '6:00 PM' }).click();
await page.locator('.fc-toolbar-title').click();
// Calendar should no longer show hours at or after 6 PM // Try to set end time to 8 AM (invalid: equals start time) — should be rejected
await expect(page.locator('.fc-timegrid-slot[data-time="18:00:00"]')).toHaveCount(0);
await expect(page.locator('.fc-timegrid-slot[data-time="17:00:00"]')).not.toHaveCount(0);
// Try to set end time to 8 AM (invalid: equals start time)
await openSettingsPopover(page);
await page.getByLabel('End Time').click(); await page.getByLabel('End Time').click();
await page.getByRole('option', { name: '8:00 AM' }).click(); await page.getByRole('option', { name: '8:00 AM' }).click();
// Should be rejected — end time stays at 6 PM // Should be rejected — end time stays at 6 PM
await expect(page.getByLabel('End Time')).toContainText('6:00 PM'); await expect(page.getByLabel('End Time')).toContainText('6:00 PM');
// Close the popover
await page.keyboard.press('Escape');
// Calendar should no longer show hours at or after 6 PM
await expect(page.locator('.fc-timegrid-slot[data-time="18:00:00"]')).toHaveCount(0);
await expect(page.locator('.fc-timegrid-slot[data-time="17:00:00"]')).not.toHaveCount(0);
}); });
test('grid scale affects number of calendar slots', async ({ page }) => { test('grid scale affects number of calendar slots', async ({ page }) => {
@@ -128,19 +134,31 @@ test.describe('Calendar Settings', () => {
await openSettingsPopover(page); await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click(); await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click(); await page.getByRole('option', { name: '30 min' }).click();
await page.locator('.fc-toolbar-title').click(); await page.keyboard.press('Escape');
// Wait for FullCalendar to re-render with new slot count
await expect(async () => {
const count = await page.locator('.fc-timegrid-slot').count();
expect(count).toBeLessThan(defaultSlotCount);
}).toPass({ timeout: 5000 });
const largerSlotCount = await page.locator('.fc-timegrid-slot').count(); const largerSlotCount = await page.locator('.fc-timegrid-slot').count();
expect(largerSlotCount).toBeLessThan(defaultSlotCount);
// Change to 5 min scale (should have many more slots) // Navigate away and back to get a clean calendar mount
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await goToCalendar(page);
// Change to 5 min scale (many more slots)
await openSettingsPopover(page); await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click(); await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '5 min', exact: true }).click(); await page.getByRole('option', { name: '5 min', exact: true }).click();
await page.locator('.fc-toolbar-title').click(); await page.keyboard.press('Escape');
const smallerSlotCount = await page.locator('.fc-timegrid-slot').count(); // Wait for FullCalendar to re-render with new slot count
expect(smallerSlotCount).toBeGreaterThan(defaultSlotCount); await expect(async () => {
const count = await page.locator('.fc-timegrid-slot').count();
expect(count).toBeGreaterThan(largerSlotCount);
}).toPass({ timeout: 5000 });
}); });
test('all settings persist across navigation', async ({ page }) => { test('all settings persist across navigation', async ({ page }) => {
@@ -156,7 +174,9 @@ test.describe('Calendar Settings', () => {
await page.getByRole('option', { name: '10:00 PM' }).click(); await page.getByRole('option', { name: '10:00 PM' }).click();
await page.getByLabel('Grid Scale').click(); await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click(); await page.getByRole('option', { name: '30 min' }).click();
await page.locator('.fc-toolbar-title').click();
// Close the popover
await page.keyboard.press('Escape');
// Navigate away and back // Navigate away and back
await page.goto(PLAYWRIGHT_BASE_URL + '/time'); await page.goto(PLAYWRIGHT_BASE_URL + '/time');
@@ -170,3 +190,66 @@ test.describe('Calendar Settings', () => {
await expect(page.getByLabel('Grid Scale')).toContainText('30 min'); await expect(page.getByLabel('Grid Scale')).toContainText('30 min');
}); });
}); });
test.describe('Calendar Toolbar', () => {
test('prev and next buttons navigate the calendar', async ({ page }) => {
await goToCalendar(page);
const initialTitle = await getCalendarTitle(page).textContent();
// Click next
await page.getByRole('button', { name: 'Next', exact: true }).click();
await expect(page.locator('.fc')).toBeVisible();
const nextTitle = await getCalendarTitle(page).textContent();
expect(nextTitle).not.toBe(initialTitle);
// Click prev — should go back to original
await page.getByRole('button', { name: 'Previous', exact: true }).click();
await expect(page.locator('.fc')).toBeVisible();
const backTitle = await getCalendarTitle(page).textContent();
expect(backTitle).toBe(initialTitle);
});
test('today button returns to current week', async ({ page }) => {
await goToCalendar(page);
const initialTitle = await getCalendarTitle(page).textContent();
// Navigate away
await page.getByRole('button', { name: 'Next', exact: true }).click();
await page.getByRole('button', { name: 'Next', exact: true }).click();
const awayTitle = await getCalendarTitle(page).textContent();
expect(awayTitle).not.toBe(initialTitle);
// Click today
await page.getByRole('button', { name: 'today', exact: true }).click();
await expect(page.locator('.fc')).toBeVisible();
const todayTitle = await getCalendarTitle(page).textContent();
expect(todayTitle).toBe(initialTitle);
});
test('view switcher toggles between week and day views', async ({ page }) => {
await goToCalendar(page);
// Default should be week view — verify multiple day columns exist
await expect(page.locator('.fc-col-header-cell')).not.toHaveCount(1);
// Switch to day view
await page.getByRole('tab', { name: 'day', exact: true }).click();
await expect(page.locator('.fc')).toBeVisible();
// Day view should show exactly 1 day column
await expect(page.locator('.fc-col-header-cell')).toHaveCount(1);
// Switch back to week view
await page.getByRole('tab', { name: 'week', exact: true }).click();
await expect(page.locator('.fc')).toBeVisible();
// Week view should show multiple day columns again
await expect(page.locator('.fc-col-header-cell')).not.toHaveCount(1);
});
});

View File

@@ -222,16 +222,16 @@ test('test that calendar navigation buttons work', async ({ page }) => {
await expect(page.locator('.fc')).toBeVisible(); await expect(page.locator('.fc')).toBeVisible();
// Click the "next" button to navigate forward // Click the "next" button to navigate forward
await page.locator('button.fc-next-button').click(); await page.getByRole('button', { name: 'Next' }).click();
await expect(page.locator('.fc')).toBeVisible(); await expect(page.locator('.fc')).toBeVisible();
// Click the "prev" button to navigate back // Click the "prev" button to navigate back
await page.locator('button.fc-prev-button').click(); await page.getByRole('button', { name: 'Previous' }).click();
await expect(page.locator('.fc')).toBeVisible(); await expect(page.locator('.fc')).toBeVisible();
// Navigate forward first so "today" button becomes enabled, then click it // Navigate forward first, then click today
await page.locator('button.fc-next-button').click(); await page.getByRole('button', { name: 'Next' }).click();
await page.locator('button.fc-today-button').click(); await page.getByRole('button', { name: 'today' }).click();
await expect(page.locator('.fc')).toBeVisible(); await expect(page.locator('.fc')).toBeVisible();
}); });

View File

@@ -44,7 +44,7 @@ const isRunningInDifferentOrganization = computed(() => {
</div> </div>
<div> <div>
<div class="text-text-secondary font-medium text-xs">Current Timer</div> <div class="text-text-secondary font-medium text-xs">Current Timer</div>
<div class="text-text-primary font-medium text-lg"> <div class="text-text-primary font-medium text-base">
{{ currentTime }} {{ currentTime }}
</div> </div>
</div> </div>

View File

@@ -157,7 +157,7 @@ const { tags } = useTagsQuery();
:tags :tags
:clients></TimeEntryCreateModal> :clients></TimeEntryCreateModal>
<CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle> <CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle>
<div class="relative pt-1"> <div class="relative pt-1.5">
<TimeTrackerRunningInDifferentOrganizationOverlay <TimeTrackerRunningInDifferentOrganizationOverlay
v-if="isRunningInDifferentOrganization" v-if="isRunningInDifferentOrganization"
@switch-organization=" @switch-organization="

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import { Button } from '..';
import { ChevronLeft, ChevronRight } from 'lucide-vue-next';
import { Tabs, TabsList } from '@/Components/ui/tabs';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
import type { CalendarSettings } from './calendarSettings';
defineProps<{
viewTitle: string;
activeView: string;
settings: CalendarSettings;
}>();
const emit = defineEmits<{
prev: [];
next: [];
today: [];
'change-view': [view: string];
'update:settings': [value: CalendarSettings];
}>();
</script>
<template>
<div class="flex items-center justify-between bg-background px-2 py-1.5">
<!-- Left: Navigation -->
<div class="flex items-center gap-1">
<Button
variant="outline"
size="sm"
class="h-8 w-8 p-0"
aria-label="Previous"
@click="emit('prev')">
<ChevronLeft class="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
class="h-8 w-8 p-0"
aria-label="Next"
@click="emit('next')">
<ChevronRight class="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" @click="emit('today')"> today </Button>
</div>
<!-- Center: Title -->
<span data-testid="calendar-title" class="text-base font-semibold text-foreground">{{
viewTitle
}}</span>
<!-- Right: View switcher + Settings -->
<div class="flex items-center gap-1">
<Tabs
:model-value="activeView"
@update:model-value="(v) => emit('change-view', String(v))">
<TabsList class="flex items-center space-x-0.5 sm:space-x-1">
<TabBarItem value="timeGridWeek">week</TabBarItem>
<TabBarItem value="timeGridDay">day</TabBarItem>
</TabsList>
</Tabs>
<CalendarSettingsPopover
:settings="settings"
@update:settings="(v) => emit('update:settings', v)" />
</div>
</div>
</template>

View File

@@ -29,7 +29,7 @@ import { getUserTimezone, getWeekStart } from '../utils/settings';
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..'; import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
import FullCalendarEventContent from './FullCalendarEventContent.vue'; import FullCalendarEventContent from './FullCalendarEventContent.vue';
import FullCalendarDayHeader from './FullCalendarDayHeader.vue'; import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
import CalendarSettingsPopover from './CalendarSettingsPopover.vue'; import CalendarToolbar from './CalendarToolbar.vue';
import type { CalendarSettings } from './calendarSettings'; import type { CalendarSettings } from './calendarSettings';
import { useVisualSnap } from './useVisualSnap'; import { useVisualSnap } from './useVisualSnap';
import { import {
@@ -123,6 +123,27 @@ function onSettingsUpdate(newSettings: CalendarSettings) {
calendarSettings.value = newSettings; calendarSettings.value = newSettings;
} }
// Toolbar state — updated via datesSet callback
const viewTitle = ref('');
const activeView = ref('timeGridWeek');
function handlePrev() {
calendarRef.value?.getApi().prev();
scrollToCurrentTime();
}
function handleNext() {
calendarRef.value?.getApi().next();
scrollToCurrentTime();
}
function handleToday() {
calendarRef.value?.getApi().today();
scrollToCurrentTime();
}
function handleChangeView(view: string) {
calendarRef.value?.getApi().changeView(view);
scrollToCurrentTime();
}
// Reactive "now" for running time entry - updates every minute // Reactive "now" for running time entry - updates every minute
const currentTime = ref(getDayJsInstance()()); const currentTime = ref(getDayJsInstance()());
let currentTimeInterval: ReturnType<typeof setInterval> | null = null; let currentTimeInterval: ReturnType<typeof setInterval> | null = null;
@@ -240,6 +261,8 @@ const dailyTotals = computed(() => {
}); });
function emitDatesChange(arg: DatesSetArg) { function emitDatesChange(arg: DatesSetArg) {
viewTitle.value = arg.view.title;
activeView.value = arg.view.type;
emit('dates-change', { start: arg.start, end: arg.end }); emit('dates-change', { start: arg.start, end: arg.end });
// Render activity boxes after calendar view has been rendered // Render activity boxes after calendar view has been rendered
renderActivityBoxes(); renderActivityBoxes();
@@ -506,11 +529,7 @@ const calendarOptions = computed(() => {
return { return {
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin], plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],
initialView: 'timeGridWeek', initialView: 'timeGridWeek',
headerToolbar: { headerToolbar: false as const,
left: 'prev,next today',
center: 'title',
right: 'timeGridWeek,timeGridDay',
},
height: 'parent', height: 'parent',
slotMinTime: formatDuration(s.startHour * 3600), slotMinTime: formatDuration(s.startHour * 3600),
slotMaxTime: formatDuration(s.endHour * 3600), slotMaxTime: formatDuration(s.endHour * 3600),
@@ -617,7 +636,7 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div class="w-full relative h-full flex-1"> <div class="w-full relative h-full flex-1 flex flex-col">
<div v-if="loading" class="flex items-center justify-center h-full"> <div v-if="loading" class="flex items-center justify-center h-full">
<div class="flex flex-col items-center space-y-4"> <div class="flex flex-col items-center space-y-4">
<LoadingSpinner class="h-8 w-8" /> <LoadingSpinner class="h-8 w-8" />
@@ -656,13 +675,20 @@ onUnmounted(() => {
:clients="clients" :clients="clients"
:currency="currency" :currency="currency"
:can-create-project="canCreateProject" /> :can-create-project="canCreateProject" />
<div class="calendar-settings-trigger"> <CalendarToolbar
<CalendarSettingsPopover :view-title="viewTitle"
:settings="calendarSettings" :active-view="activeView"
@update:settings="onSettingsUpdate" /> :settings="calendarSettings"
</div> @prev="handlePrev"
@next="handleNext"
@today="handleToday"
@change-view="handleChangeView"
@update:settings="onSettingsUpdate" />
<ContextMenu> <ContextMenu>
<ContextMenuTrigger as="div" class="h-full" @contextmenu="handleCalendarContextMenu"> <ContextMenuTrigger
as="div"
class="flex-1 min-h-0"
@contextmenu="handleCalendarContextMenu">
<FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions"> <FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions">
<template #eventContent="arg"> <template #eventContent="arg">
<FullCalendarEventContent <FullCalendarEventContent
@@ -743,13 +769,6 @@ onUnmounted(() => {
</template> </template>
<style scoped> <style scoped>
.calendar-settings-trigger {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 20;
}
.fullcalendar { .fullcalendar {
height: 100%; height: 100%;
--fc-border-color: var(--border); --fc-border-color: var(--border);
@@ -764,49 +783,12 @@ onUnmounted(() => {
.fullcalendar :deep(.fc-timegrid-slot) { .fullcalendar :deep(.fc-timegrid-slot) {
height: 25px; height: 25px;
transition: height 0.2s ease;
} }
.fullcalendar :deep(.fc-timegrid-slot-label) { .fullcalendar :deep(.fc-timegrid-slot-label) {
background-color: var(--background); background-color: var(--background);
} }
.fullcalendar :deep(.fc-toolbar) {
background-color: var(--background);
padding: 0.5rem;
padding-right: 2.75rem;
margin-bottom: 0;
}
.fullcalendar :deep(.fc-toolbar-title) {
color: var(--foreground);
font-size: 1rem;
font-weight: 600;
}
.fullcalendar :deep(.fc-button) {
background-color: var(--secondary);
border: 1px solid var(--border);
color: var(--foreground);
font-weight: 500;
font-size: 14px !important;
}
.fullcalendar :deep(.fc-button:hover) {
background-color: var(--muted);
border-color: var(--border);
}
.fullcalendar :deep(.fc-button:focus) {
box-shadow: 0 0 0 2px var(--ring);
}
.fullcalendar :deep(.fc-button-active) {
background-color: var(--primary);
border-color: var(--primary);
color: var(--primary-foreground);
}
.fullcalendar :deep(.fc-col-header) { .fullcalendar :deep(.fc-col-header) {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }

View File

@@ -214,7 +214,7 @@ useSelectEvents(
v-model="tempDescription" v-model="tempDescription"
placeholder="What are you working on?" placeholder="What are you working on?"
data-testid="time_entry_description" data-testid="time_entry_description"
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-lg text-text-primary bg-transparent border-none placeholder-text-secondary font-medium focus:ring-0 transition" class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base text-text-primary bg-transparent border-none placeholder-text-secondary font-medium focus:ring-0 transition"
type="text" type="text"
@keydown.enter="startTimerIfNotActive" @keydown.enter="startTimerIfNotActive"
@keydown.esc="showDropdown = false" @keydown.esc="showDropdown = false"

View File

@@ -154,7 +154,7 @@ function closeAndFocusInput() {
v-model="currentTime" v-model="currentTime"
placeholder="00:00:00" placeholder="00:00:00"
data-testid="time_entry_time" data-testid="time_entry_time"
class="w-[110px] lg:w-[130px] h-full text-text-primary py-2.5 rounded-lg border-border-secondary border text-center px-4 text-base lg:text-lg font-semibold bg-card-background border-none placeholder-text-tertiary focus:ring-0 transition" class="w-[110px] lg:w-[120px] h-full text-text-primary py-2.5 rounded-lg border-border-secondary border text-center px-4 text-base font-semibold tabular-nums bg-card-background border-none placeholder-text-tertiary focus:ring-0 transition"
type="text" type="text"
@focusin="openModalOnTab" @focusin="openModalOnTab"
@click="openModalOnClick" @click="openModalOnClick"

View File

@@ -19,17 +19,11 @@ export const solidtimeTheme = {
'2xs': ['0.625rem', { lineHeight: '0.75rem' }], '2xs': ['0.625rem', { lineHeight: '0.75rem' }],
xs: ['0.75rem', { lineHeight: '1rem' }], xs: ['0.75rem', { lineHeight: '1rem' }],
sm: ['0.8125rem', { lineHeight: '1.125rem' }], sm: ['0.8125rem', { lineHeight: '1.125rem' }],
base: ['0.875rem', { lineHeight: '1.25rem' }], base: ['0.9375rem', { lineHeight: '1.375rem' }],
lg: ['1rem', { lineHeight: '1.5rem' }], lg: ['1.125rem', { lineHeight: '1.5rem' }],
xl: ['1.125rem', { lineHeight: '1.75rem' }], xl: ['1.25rem', { lineHeight: '1.75rem' }],
'2xl': ['1.25rem', { lineHeight: '1.75rem' }], '2xl': ['1.5rem', { lineHeight: '2rem' }],
'3xl': ['1.5rem', { lineHeight: '2rem' }], '3xl': ['2.25rem', { lineHeight: '2.5rem' }],
'4xl': ['1.75rem', { lineHeight: '2.25rem' }],
'5xl': ['2rem', { lineHeight: '1' }],
'6xl': ['2.25rem', { lineHeight: '1' }],
'7xl': ['2.5rem', { lineHeight: '1' }],
'8xl': ['3rem', { lineHeight: '1' }],
'9xl': ['3.5rem', { lineHeight: '1' }],
}, },
colors: { colors: {
ring: 'var(--ring)', ring: 'var(--ring)',