- | {`Group ${groupNumber}`} |
-
+ className="table-row-link">
+ | {`Group ${groupNumber}`} |
+
{assignments
.filter((a) => a.activityId === childActivity.id)
?.sort((a, b) => a.personName.localeCompare(b.personName))
diff --git a/src/pages/Competition/Stats/StatsBox.tsx b/src/pages/Competition/Stats/StatsBox.tsx
index 7d27deb..6d823f4 100644
--- a/src/pages/Competition/Stats/StatsBox.tsx
+++ b/src/pages/Competition/Stats/StatsBox.tsx
@@ -1,8 +1,8 @@
export function StatsBox({ title, value }: { title: string; value: number }) {
return (
- {value}
- {title}
+ {value}
+ {title}
);
}
diff --git a/src/pages/Competition/Stats/index.tsx b/src/pages/Competition/Stats/index.tsx
index 814648f..008defa 100644
--- a/src/pages/Competition/Stats/index.tsx
+++ b/src/pages/Competition/Stats/index.tsx
@@ -24,7 +24,7 @@ export default function Round() {
return (
-
+
@@ -35,10 +35,10 @@ export default function Round() {
gridTemplateColumns: `repeat(${eventCount}, 1fr)`,
}}>
{wcif?.events?.map(({ id }) => (
-
+
))}
{wcif?.events?.map(({ id }) => (
-
+
{
acceptedRegistrations?.filter(({ registration }) =>
registration?.eventIds.includes(id),
diff --git a/src/pages/Competition/StreamSchedule/index.tsx b/src/pages/Competition/StreamSchedule/index.tsx
index 2cb355d..c98039e 100644
--- a/src/pages/Competition/StreamSchedule/index.tsx
+++ b/src/pages/Competition/StreamSchedule/index.tsx
@@ -69,17 +69,17 @@ export default function CompetitionStreamSchedule() {
const renderActivities = () => (
<>
- {t('competition.streamSchedule.subtitle')}
-
-
+ {t('competition.streamSchedule.subtitle')}
+
+
-
- | {t('competition.streamSchedule.time')} |
- {t('competition.streamSchedule.event')} |
- {t('competition.streamSchedule.round')} |
- {t('competition.streamSchedule.group')} |
- {t('competition.streamSchedule.stage')} |
-
+ |
+ | {t('competition.streamSchedule.time')} |
+ {t('competition.streamSchedule.event')} |
+ {t('competition.streamSchedule.round')} |
+ {t('competition.streamSchedule.group')} |
+ {t('competition.streamSchedule.stage')} |
+
{t('competition.streamSchedule.featuredCompetitors')}
|
@@ -88,7 +88,7 @@ export default function CompetitionStreamSchedule() {
{scheduleDays.map(({ date, dateParts }) => (
- |
+ |
{dateParts.find((i) => i.type === 'weekday')?.value || date}
|
@@ -119,15 +119,15 @@ export default function CompetitionStreamSchedule() {
return (
- {startTime} |
-
-
+ | {startTime} |
+
+
|
- {roundNumber} |
- {groupNumber || '*'} |
-
+ | {roundNumber} |
+ {groupNumber || '*'} |
+
|
-
+ |
{streamPersonIds(activity)
.map(getPersonById)
.filter((person) => !!person)
@@ -156,9 +156,9 @@ export default function CompetitionStreamSchedule() {
);
return (
-
+
-
+
{activities.length > 0 ? renderActivities() : No Live Stream information }
diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx
index a90d42e..1d27d1d 100644
--- a/src/pages/Home/index.tsx
+++ b/src/pages/Home/index.tsx
@@ -16,16 +16,16 @@ export default function Home() {
return (
-
- {t('home.subtitle')}
- {t('home.explanation')}
-
+
+ {t('home.subtitle')}
+ {t('home.explanation')}
+
{t('home.learnMore')}
{t('home.support')}
diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx
new file mode 100644
index 0000000..54e917c
--- /dev/null
+++ b/src/pages/Settings/index.tsx
@@ -0,0 +1,43 @@
+import { Container } from '@/components';
+import { Theme, useUserSettings } from '@/providers/UserSettingsProvider';
+
+export default function Settings() {
+ const { theme, setTheme } = useUserSettings();
+
+ const themeOptions: { value: Theme; label: string; description: string }[] = [
+ { value: 'light', label: 'Light', description: 'Always use light theme' },
+ { value: 'dark', label: 'Dark', description: 'Always use dark theme' },
+ { value: 'system', label: 'System', description: 'Use system preference' },
+ ];
+
+ return (
+
+ Settings
+
+
+ Appearance
+
+
+
+ {themeOptions.map((option) => (
+
+ setTheme(option.value)}
+ className="radio mt-1"
+ />
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/pages/Support/index.tsx b/src/pages/Support/index.tsx
index 041fefa..51eb153 100644
--- a/src/pages/Support/index.tsx
+++ b/src/pages/Support/index.tsx
@@ -9,17 +9,17 @@ export default function Support() {
return (
-
- Thanks for being a user of Competition Groups!
-
- This website is a passion project by Cailyn Hoover.
+
+ Thanks for being a user of Competition Groups!
+
+ This website is a passion project by Cailyn Sinclair.
I do not receive any compensation for developing this platform, and if you find
this website useful, please consider supporting me by buying me a coffee
diff --git a/src/providers/UserSettingsProvider/UserSettingsContext.tsx b/src/providers/UserSettingsProvider/UserSettingsContext.tsx
new file mode 100644
index 0000000..a09ef0c
--- /dev/null
+++ b/src/providers/UserSettingsProvider/UserSettingsContext.tsx
@@ -0,0 +1,17 @@
+import { createContext, useContext } from 'react';
+
+export type Theme = 'light' | 'dark' | 'system';
+
+export interface UserSettingsContextProps {
+ theme: Theme;
+ setTheme: (theme: Theme) => void;
+ effectiveTheme: 'light' | 'dark';
+}
+
+export const UserSettingsContext = createContext ({
+ theme: 'system',
+ setTheme: () => {},
+ effectiveTheme: 'light',
+});
+
+export const useUserSettings = () => useContext(UserSettingsContext);
diff --git a/src/providers/UserSettingsProvider/UserSettingsProvider.tsx b/src/providers/UserSettingsProvider/UserSettingsProvider.tsx
new file mode 100644
index 0000000..6c671df
--- /dev/null
+++ b/src/providers/UserSettingsProvider/UserSettingsProvider.tsx
@@ -0,0 +1,60 @@
+import { PropsWithChildren, useEffect, useState } from 'react';
+import { getLocalStorage, setLocalStorage } from '@/lib/localStorage';
+import { Theme, UserSettingsContext } from './UserSettingsContext';
+
+const THEME_STORAGE_KEY = 'theme';
+
+const getSystemTheme = (): 'light' | 'dark' => {
+ if (typeof window === 'undefined') return 'light';
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+};
+
+const getStoredTheme = (): Theme => {
+ const stored = getLocalStorage(THEME_STORAGE_KEY);
+ if (stored === 'light' || stored === 'dark' || stored === 'system') {
+ return stored;
+ }
+ return 'system';
+};
+
+export function UserSettingsProvider({ children }: PropsWithChildren) {
+ const [theme, setThemeState] = useState(getStoredTheme);
+ const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme);
+
+ const effectiveTheme = theme === 'system' ? systemTheme : theme;
+
+ useEffect(() => {
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
+ const handleChange = (e: MediaQueryListEvent) => {
+ setSystemTheme(e.matches ? 'dark' : 'light');
+ };
+
+ mediaQuery.addEventListener('change', handleChange);
+ return () => mediaQuery.removeEventListener('change', handleChange);
+ }, []);
+
+ useEffect(() => {
+ const root = document.documentElement;
+ if (effectiveTheme === 'dark') {
+ root.classList.add('dark');
+ } else {
+ root.classList.remove('dark');
+ }
+ }, [effectiveTheme]);
+
+ const setTheme = (newTheme: Theme) => {
+ setThemeState(newTheme);
+ setLocalStorage(THEME_STORAGE_KEY, newTheme);
+ };
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/providers/UserSettingsProvider/index.ts b/src/providers/UserSettingsProvider/index.ts
new file mode 100644
index 0000000..adfd274
--- /dev/null
+++ b/src/providers/UserSettingsProvider/index.ts
@@ -0,0 +1,3 @@
+export { UserSettingsProvider } from './UserSettingsProvider';
+export { useUserSettings } from './UserSettingsContext';
+export type { Theme } from './UserSettingsContext';
diff --git a/src/styles/buttons.css b/src/styles/buttons.css
new file mode 100644
index 0000000..1ec73d5
--- /dev/null
+++ b/src/styles/buttons.css
@@ -0,0 +1,31 @@
+@layer components {
+ /* Base button styles */
+ .btn {
+ @apply inline-flex gap-2 px-4 py-2 text-sm font-medium transition-colors duration-150 border rounded-md shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 disabled:opacity-50 disabled:cursor-not-allowed;
+ }
+
+ /* Full-width block button */
+ .btn-block {
+ @apply w-full min-h-[40px];
+ }
+
+ /* Blue/primary button variant */
+ .btn-blue {
+ @apply text-gray-900 bg-blue-200 border-blue-300 hover:bg-blue-300 dark:bg-blue-700 dark:hover:bg-blue-600 dark:text-white dark:border-blue-600;
+ }
+
+ /* Green/success button variant */
+ .btn-green {
+ @apply text-gray-900 bg-green-200 border-green-300 hover:bg-green-300 dark:bg-green-700 dark:hover:bg-green-600 dark:text-white dark:border-green-600;
+ }
+
+ /* Gray/neutral button variant */
+ .btn-gray {
+ @apply text-gray-900 bg-gray-200 border-gray-300 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-white dark:border-gray-600;
+ }
+
+ /* Light/subtle button variant */
+ .btn-light {
+ @apply text-gray-900 bg-gray-100 border-gray-300 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-white dark:border-gray-600;
+ }
+}
diff --git a/src/styles/forms.css b/src/styles/forms.css
new file mode 100644
index 0000000..0a91ce6
--- /dev/null
+++ b/src/styles/forms.css
@@ -0,0 +1,59 @@
+/**
+ * Form Components
+ * Reusable form element styles with consistent theming
+ */
+
+@layer components {
+ /* Base input styles */
+ .input {
+ @apply block w-full px-3 py-2 type-body-sm
+ bg-tertiary border border-default rounded-lg
+ text-default placeholder-gray-600 dark:placeholder-gray-400
+ focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
+ focus:border-blue-500 dark:focus:border-blue-400 focus:outline-none;
+ }
+
+ /* Small input variant */
+ .input-sm {
+ @apply input h-8 text-sm px-2 py-1;
+ }
+
+ /* Large input variant */
+ .input-lg {
+ @apply input h-12 text-base px-4 py-3;
+ }
+
+ /* Select dropdown */
+ .select {
+ @apply input appearance-none cursor-pointer;
+ }
+
+ /* Checkbox input */
+ .checkbox {
+ @apply w-4 h-4 rounded border-default bg-tertiary
+ text-blue-600 dark:text-blue-500
+ focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:outline-none;
+ }
+
+ /* Radio input */
+ .radio {
+ @apply w-4 h-4 border-default bg-tertiary
+ text-blue-600 dark:text-blue-500
+ focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:outline-none;
+ }
+
+ /* Form label */
+ .form-label {
+ @apply block mb-2 type-label;
+ }
+
+ /* Form group container */
+ .form-group {
+ @apply space-y-2;
+ }
+
+ /* Form hint/description text */
+ .form-hint {
+ @apply type-meta text-muted;
+ }
+}
diff --git a/src/styles/index.css b/src/styles/index.css
new file mode 100644
index 0000000..7331d76
--- /dev/null
+++ b/src/styles/index.css
@@ -0,0 +1,44 @@
+/**
+ * Main Style Entry Point
+ * Imports Tailwind and all modular style files
+ */
+
+/* Tailwind CSS */
+@import 'tailwindcss/base';
+@import 'tailwindcss/components';
+@import 'tailwindcss/utilities';
+
+/* Modular style imports (order matters: tokens before components that use them) */
+@import 'typography.css';
+@import 'tokens.css';
+@import 'buttons.css';
+@import 'links.css';
+@import 'forms.css';
+@import 'tables.css';
+
+/* Viewport height utility with dynamic viewport support */
+.full-viewport-height {
+ height: 100vh;
+}
+
+@supports (height: 100dvh) {
+ .full-viewport-height {
+ height: 100dvh;
+ }
+}
+
+/* Base layer overrides */
+@layer base {
+ body {
+ @apply type-body;
+ }
+
+ p {
+ @apply type-body;
+ }
+}
+
+/* Sticky grid header utility */
+.stickyGridHeader > * {
+ @apply sticky top-0;
+}
diff --git a/src/styles/links.css b/src/styles/links.css
new file mode 100644
index 0000000..bae6438
--- /dev/null
+++ b/src/styles/links.css
@@ -0,0 +1,26 @@
+/**
+ * Link Components
+ * Reusable link styles for different contexts
+ */
+
+@layer components {
+ /* Inline text link */
+ .link-inline {
+ @apply text-blue-600 dark:text-blue-400 hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500;
+ }
+
+ /* Navigation link */
+ .link-nav {
+ @apply w-full p-2 text-center text-blue-500 dark:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-blue-700 dark:hover:text-blue-300;
+ }
+
+ /* Card-style link */
+ .link-card {
+ @apply block w-full px-2 py-2 transition-colors border rounded-md bg-panel border-tertiary-weak hover:bg-gray-100 dark:hover:bg-gray-700;
+ }
+
+ /* External link with icon space */
+ .link-external {
+ @apply inline-flex justify-between w-full gap-2 px-4 py-2 bg-blue-200 rounded-md dark:bg-blue-700 dark:hover:bg-blue-600 hover:opacity-80;
+ }
+}
diff --git a/src/styles/tables.css b/src/styles/tables.css
new file mode 100644
index 0000000..59cb184
--- /dev/null
+++ b/src/styles/tables.css
@@ -0,0 +1,319 @@
+/**
+ * Table Components & Utilities
+ * Comprehensive table styling system with consistent color tokens
+ */
+
+@layer utilities {
+ /*
+ * ===========================================
+ * TABLE COLOR TOKENS
+ * Core colors used across all tables:
+ * - Header: Strong background for table headers (default: gray/tertiary)
+ * - Header variants: blue, primary, secondary themed headers
+ * - Row: Normal background for even rows
+ * - Row Alt: Slightly different background for odd rows (striping)
+ * ===========================================
+ */
+
+ /* Header background - default gray/tertiary */
+ .table-bg-header {
+ @apply bg-gray-200 dark:bg-gray-800;
+ }
+
+ /* Header background - blue themed */
+ .table-bg-header-blue {
+ @apply bg-blue-200 dark:bg-blue-800;
+ }
+
+ /* Header background - primary themed (uses primary color tokens) */
+ .table-bg-header-primary {
+ @apply bg-blue-200 dark:bg-blue-700;
+ }
+
+ /* Header background - secondary themed (uses secondary color tokens) */
+ .table-bg-header-secondary {
+ @apply bg-green-200 dark:bg-green-900;
+ }
+
+ /* Row background - normal (even rows) */
+ .table-bg-row {
+ @apply bg-white dark:bg-gray-900;
+ }
+
+ /* Row background - alternate/odd rows (striped) */
+ .table-bg-row-alt {
+ @apply bg-gray-50 dark:bg-gray-800;
+ }
+
+ /* Row background - primary themed alternate (odd rows) */
+ .table-bg-row-alt-primary {
+ @apply bg-blue-50 dark:bg-blue-900;
+ }
+
+ /* Row background - secondary themed alternate (odd rows) */
+ .table-bg-row-alt-secondary {
+ @apply bg-green-50 dark:bg-green-900/60;
+ }
+
+ /* Row hover background - distinct from both row colors */
+ .table-bg-row-hover {
+ @apply bg-gray-200 dark:bg-gray-600;
+ }
+
+ /* Row hover background - primary themed */
+ .table-bg-row-hover-primary {
+ @apply bg-blue-100 dark:bg-blue-800;
+ }
+
+ /* Row hover background - secondary themed */
+ .table-bg-row-hover-secondary {
+ @apply bg-green-100 dark:bg-green-900;
+ }
+
+ /*
+ * ===========================================
+ * CONTAINER UTILITIES
+ * ===========================================
+ */
+
+ .table-container {
+ @apply border border-gray-300 rounded-md shadow-md dark:border-gray-700 dark:shadow-gray-800 print:shadow-none;
+ }
+
+ .table-container-light {
+ @apply shadow-sm dark:shadow-gray-800 print:shadow-none;
+ }
+
+ .table-container-full {
+ @apply overflow-x-auto;
+ }
+
+ .table-base {
+ @apply w-full bg-panel;
+ }
+
+ /*
+ * ===========================================
+ * HEADER UTILITIES
+ * ===========================================
+ */
+
+ /* Header row - default (gray/tertiary) */
+ .table-header {
+ @apply table-bg-header;
+ }
+
+ /* Header row - blue themed */
+ .table-header-blue {
+ @apply table-bg-header-blue;
+ }
+
+ /* Header row - primary themed */
+ .table-header-primary {
+ @apply table-bg-header-primary;
+ }
+
+ /* Header row - secondary themed */
+ .table-header-secondary {
+ @apply table-bg-header-secondary;
+ }
+
+ /* Header cell - SMALL padding (dense tables) */
+ .table-header-cell-sm {
+ @apply px-3 py-2 text-gray-900 dark:text-white type-label;
+ }
+
+ /* Header cell - MEDIUM padding (standard) */
+ .table-header-cell {
+ @apply px-6 py-3 text-gray-900 dark:text-white type-label;
+ }
+
+ /* Header cell - LARGE padding (spacious) */
+ .table-header-cell-lg {
+ @apply px-8 py-4 text-gray-900 dark:text-white type-label;
+ }
+
+ /* Centered header cell variants */
+ .table-header-cell-sm-center {
+ @apply text-center table-header-cell-sm;
+ }
+
+ .table-header-cell-center {
+ @apply text-center table-header-cell;
+ }
+
+ .table-header-cell-lg-center {
+ @apply text-center table-header-cell-lg;
+ }
+
+ /* Group header (date/section separators) - uses header token */
+ .table-row-group-header {
+ @apply px-3 py-2 font-bold text-center type-heading table-bg-header;
+ }
+
+ /*
+ * ===========================================
+ * CELL UTILITIES
+ * ===========================================
+ */
+
+ .table-cell-sm {
+ @apply px-3 py-2 text-gray-900 dark:text-white;
+ }
+
+ .table-cell {
+ @apply px-6 py-2.5 text-gray-900 dark:text-white;
+ }
+
+ .table-cell-lg {
+ @apply px-8 py-3.5 text-gray-900 dark:text-white;
+ }
+
+ /* Centered cell variants */
+ .table-cell-sm-center {
+ @apply text-center table-cell-sm;
+ }
+
+ .table-cell-center {
+ @apply table-cell text-center;
+ }
+
+ .table-cell-lg-center {
+ @apply text-center table-cell-lg;
+ }
+
+ /*
+ * ===========================================
+ * ROW UTILITIES
+ * ===========================================
+ */
+
+ /* Interactive row with hover */
+ .table-row-hover {
+ @apply transition-all cursor-pointer;
+ }
+
+ .table-row-hover:hover {
+ @apply table-bg-row-hover;
+ }
+
+ /* Link-style row (for or elements acting as rows) */
+ .table-row-link {
+ @apply table-row transition-all border-b cursor-pointer border-tertiary-weak;
+ }
+
+ .table-row-link:hover {
+ @apply table-bg-row-hover;
+ }
+
+ /* Simple hover background utility */
+ .table-row-hover-bg {
+ @apply transition-colors cursor-pointer;
+ }
+
+ .table-row-hover-bg:hover {
+ @apply table-bg-row-hover;
+ }
+
+ /*
+ * ===========================================
+ * BORDER UTILITIES
+ * ===========================================
+ */
+
+ .table-row-border-y {
+ @apply border-y border-tertiary-weak;
+ }
+
+ .table-row-border-b {
+ @apply border-b border-tertiary-weak;
+ }
+
+ .table-row-border-t {
+ @apply border-t border-tertiary-weak;
+ }
+
+ .table-bordered {
+ @apply border border-collapse border-tertiary-weak;
+ }
+
+ .table-bordered th,
+ .table-bordered td {
+ @apply border border-tertiary-weak;
+ }
+
+ /*
+ * ===========================================
+ * STRIPED TABLE UTILITY
+ * Uses the 3 color tokens for consistent striping
+ * ===========================================
+ */
+
+ .table-striped tbody tr:nth-child(even),
+ .table-striped tbody .table-row-link:nth-child(even) {
+ @apply table-bg-row-alt;
+ }
+
+ .table-striped tbody tr:nth-child(odd),
+ .table-striped tbody .table-row-link:nth-child(odd) {
+ @apply table-bg-row;
+ }
+
+ /* Hover states for striped rows */
+ .table-striped tbody tr:hover,
+ .table-striped tbody .table-row-link:hover {
+ @apply table-bg-row-hover;
+ }
+
+ /*
+ * ===========================================
+ * PRIMARY THEMED STRIPED TABLE
+ * ===========================================
+ */
+
+ .table-striped-primary tbody tr:nth-child(even),
+ .table-striped-primary tbody .table-row-link:nth-child(even) {
+ @apply table-bg-row-alt-primary;
+ }
+
+ .table-striped-primary tbody tr:nth-child(odd),
+ .table-striped-primary tbody .table-row-link:nth-child(odd) {
+ @apply table-bg-row;
+ }
+
+ .table-striped-primary tbody tr:hover,
+ .table-striped-primary tbody .table-row-link:hover {
+ @apply table-bg-row-hover-primary;
+ }
+
+ /*
+ * ===========================================
+ * SECONDARY THEMED STRIPED TABLE
+ * ===========================================
+ */
+
+ .table-striped-secondary tbody tr:nth-child(even),
+ .table-striped-secondary tbody .table-row-link:nth-child(even) {
+ @apply table-bg-row-alt-secondary;
+ }
+
+ .table-striped-secondary tbody tr:nth-child(odd),
+ .table-striped-secondary tbody .table-row-link:nth-child(odd) {
+ @apply table-bg-row;
+ }
+
+ .table-striped-secondary tbody tr:hover,
+ .table-striped-secondary tbody .table-row-link:hover {
+ @apply table-bg-row-hover-secondary;
+ }
+
+ /*
+ * Assignment-themed table row utilities are generated by the Tailwind plugin:
+ * See tailwind/assignment-colors.plugin.js
+ *
+ * Generated classes:
+ * - .table-bg-row-{name} - Table row background
+ * - .table-bg-row-alt-{name} - Table row alternate (striped) background
+ * - .table-bg-row-hover-{name} - Table row hover background
+ */
+}
diff --git a/src/styles/tokens.css b/src/styles/tokens.css
new file mode 100644
index 0000000..b79ef2c
--- /dev/null
+++ b/src/styles/tokens.css
@@ -0,0 +1,170 @@
+@layer components {
+ /* App & panel background tokens */
+ .bg-app {
+ @apply bg-white dark:bg-gray-900;
+ }
+
+ .bg-panel {
+ @apply bg-white dark:bg-gray-800;
+ }
+
+ .bg-panel-elevated {
+ @apply bg-gray-50 dark:bg-gray-700;
+ }
+
+ /* Text-only tokens (no background assumption) */
+ .text-default {
+ @apply text-gray-900 dark:text-white;
+ }
+
+ .text-subtle {
+ @apply text-gray-600 dark:text-gray-400;
+ }
+
+ /* Primary color tokens (blue) */
+ .bg-primary {
+ @apply bg-blue-200 dark:bg-blue-700;
+ }
+
+ .bg-primary-strong {
+ @apply bg-blue-300 dark:bg-blue-600;
+ }
+
+ .text-primary {
+ @apply text-blue-700 dark:text-blue-300;
+ }
+
+ .border-primary {
+ @apply border-blue-300 dark:border-blue-600;
+ }
+
+ /* Secondary color tokens (green) */
+ .bg-secondary {
+ @apply bg-green-200 dark:bg-green-700;
+ }
+
+ .bg-secondary-strong {
+ @apply bg-green-300 dark:bg-green-600;
+ }
+
+ .text-secondary {
+ @apply text-green-700 dark:text-green-300;
+ }
+
+ .border-secondary {
+ @apply border-green-300 dark:border-green-600;
+ }
+
+ /* Tertiary/neutral color tokens (gray) */
+ .bg-tertiary {
+ @apply bg-gray-100 dark:bg-gray-800;
+ }
+
+ .bg-tertiary-strong {
+ @apply bg-gray-200 dark:bg-gray-700;
+ }
+
+ .text-tertiary {
+ @apply text-gray-700 dark:text-gray-300;
+ }
+
+ .border-tertiary {
+ @apply border-gray-300 dark:border-gray-600;
+ }
+
+ /* Default border tokens */
+ .border-default {
+ @apply border-gray-300 dark:border-gray-700;
+ }
+
+ .border-strong {
+ @apply border-gray-400 dark:border-gray-600;
+ }
+
+ /* Weaker neutral border token */
+ .border-tertiary-weak {
+ @apply border-gray-200 dark:border-gray-700;
+ }
+
+ /* Muted neutral text token */
+ .text-muted {
+ @apply text-gray-500 dark:text-gray-400;
+ }
+
+ /* Active/selected state tokens */
+ .bg-active {
+ @apply bg-blue-100 dark:bg-blue-900;
+ }
+
+ /* Disabled state token */
+ .disabled-state {
+ @apply opacity-50 cursor-not-allowed;
+ }
+
+ /*
+ * Assignment color tokens are generated by the Tailwind plugin:
+ * See tailwind/assignment-colors.plugin.js
+ *
+ * Generated classes:
+ * - .bg-assignment-{name} - Full background for pills/badges
+ * - .bg-assignment-{name}-muted - Subtle background with opacity for table cells
+ * - .text-assignment-{name} - Contrasting text color
+ * - .border-assignment-{name} - Border color for accent lines
+ */
+}
+
+@layer utilities {
+ /* Hover tokens for neutral backgrounds/text */
+ .hover-bg-tertiary {
+ @apply hover:bg-gray-200 dark:hover:bg-gray-700;
+ }
+
+ .hover-text-tertiary {
+ @apply hover:text-gray-700 dark:hover:text-gray-300;
+ }
+
+ .hover-text-muted {
+ @apply hover:text-gray-500 dark:hover:text-gray-300;
+ }
+
+ /* Hover tokens for primary */
+ .hover-bg-primary {
+ @apply hover:bg-blue-300 dark:hover:bg-blue-600;
+ }
+
+ .hover-text-primary {
+ @apply hover:text-blue-700 dark:hover:text-blue-300;
+ }
+
+ /* Hover tokens for secondary */
+ .hover-bg-secondary {
+ @apply hover:bg-green-300 dark:hover:bg-green-600;
+ }
+
+ .hover-text-secondary {
+ @apply hover:text-green-700 dark:hover:text-green-300;
+ }
+
+ /* Focus ring token */
+ .ring-focus {
+ @apply focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:outline-none;
+ }
+
+ /* Divider, ring, and placeholder neutral tokens */
+ .divide-tertiary-weak {
+ @apply divide-gray-100;
+ }
+
+ .ring-tertiary-weak {
+ @apply ring-gray-100 dark:ring-gray-600;
+ }
+
+ .placeholder-tertiary {
+ @apply dark:placeholder-gray-400;
+ }
+
+ /* Shadow token for dark mode neutrals */
+ .shadow-tertiary-dark {
+ @apply dark:shadow-gray-800;
+ }
+}
diff --git a/src/styles/typography.css b/src/styles/typography.css
new file mode 100644
index 0000000..210af53
--- /dev/null
+++ b/src/styles/typography.css
@@ -0,0 +1,38 @@
+@layer components {
+ /* Typography utilities for consistent text styling */
+ .type-display {
+ @apply text-3xl font-bold leading-tight text-gray-900 dark:text-white;
+ }
+
+ .type-title {
+ @apply text-2xl font-bold leading-tight text-gray-900 dark:text-white;
+ }
+
+ .type-heading {
+ @apply text-xl font-semibold leading-snug text-gray-900 dark:text-white;
+ }
+
+ .type-subheading {
+ @apply text-lg font-semibold leading-snug text-gray-900 dark:text-white;
+ }
+
+ /* Standard body text */
+ .type-body {
+ @apply text-base leading-relaxed text-gray-900 dark:text-white;
+ }
+
+ /* Slightly smaller body text for secondary content */
+ .type-body-sm {
+ @apply text-sm leading-relaxed text-gray-800 dark:text-gray-200;
+ }
+
+ /* Medium-weight text for labels and important inline text */
+ .type-label {
+ @apply text-sm font-medium text-gray-700 dark:text-gray-300;
+ }
+
+ /* Smaller, lighter text for metadata and secondary information */
+ .type-meta {
+ @apply text-xs text-gray-500 dark:text-gray-400;
+ }
+}
diff --git a/tailwind.config.js b/tailwind.config.js
index 2d9051f..0ce1f57 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -1,7 +1,10 @@
+const assignmentColorsPlugin = require('./tailwind/assignment-colors.plugin');
+
module.exports = {
- content: ['./src/**/*.{html,js,tsx}'],
+ content: ['./src/**/*.{html,js,ts,tsx}'],
+ darkMode: 'class',
theme: {
extend: {},
},
- plugins: [],
+ plugins: [assignmentColorsPlugin],
};
diff --git a/tailwind/assignment-colors.plugin.js b/tailwind/assignment-colors.plugin.js
new file mode 100644
index 0000000..79a614e
--- /dev/null
+++ b/tailwind/assignment-colors.plugin.js
@@ -0,0 +1,271 @@
+/**
+ * Tailwind CSS Plugin: Assignment Colors
+ *
+ * Generates utility classes for WCA competition assignment types.
+ * This replaces ~200 lines of repetitive CSS with programmatic generation.
+ *
+ * Generated classes:
+ * - .bg-assignment-{name} - Full background for pills/badges
+ * - .bg-assignment-{name}-muted - Subtle background with opacity for table cells
+ * - .text-assignment-{name} - Contrasting text color
+ * - .border-assignment-{name} - Border color for accent lines
+ * - .table-bg-row-{name} - Table row background
+ * - .table-bg-row-alt-{name} - Table row alternate (striped) background
+ * - .table-bg-row-hover-{name} - Table row hover background
+ */
+
+const plugin = require('tailwindcss/plugin');
+
+/**
+ * Complete assignment color definitions
+ * Each assignment has all color variants explicitly defined for precise control.
+ *
+ * Structure:
+ * - bg: { light, dark } - Full background colors
+ * - bgMuted: { light, dark, lightOpacity, darkOpacity } - Subtle backgrounds
+ * - text: { light, dark } - Text colors
+ * - border: { light, dark } - Border colors
+ * - tableRow: { light, dark, darkOpacity } - Table row backgrounds
+ * - tableRowAlt: { light, dark, darkOpacity } - Alternating row backgrounds
+ * - tableRowHover: { light, dark } - Row hover backgrounds
+ */
+const assignmentColorConfig = {
+ // Competitor - Green
+ competitor: {
+ bg: { light: 'green.200', dark: 'green.800' },
+ bgMuted: { light: 'green.100', dark: 'green.900', lightOpacity: 80, darkOpacity: 50 },
+ text: { light: 'green.800', dark: 'green.200' },
+ border: { light: 'green.300', dark: 'green.700' },
+ tableRow: { light: 'white', dark: 'green.900', darkOpacity: 30 },
+ tableRowAlt: { light: 'green.50', dark: 'green.900', darkOpacity: 50 },
+ tableRowHover: { light: 'green.200', dark: 'green.800' },
+ },
+
+ // Scrambler - Yellow (needs stronger shades for visibility)
+ scrambler: {
+ bg: { light: 'yellow.200', dark: 'yellow.700' },
+ bgMuted: { light: 'yellow.200', dark: 'yellow.900', lightOpacity: 80, darkOpacity: 50 },
+ text: { light: 'yellow.900', dark: 'yellow.200' },
+ border: { light: 'yellow.400', dark: 'yellow.600' },
+ tableRow: { light: 'white', dark: 'yellow.900', darkOpacity: 30 },
+ tableRowAlt: { light: 'yellow.50', dark: 'yellow.900', darkOpacity: 50 },
+ tableRowHover: { light: 'yellow.200', dark: 'yellow.700' },
+ },
+
+ // Runner - Orange (needs stronger shades for visibility)
+ runner: {
+ bg: { light: 'orange.300', dark: 'orange.700' },
+ bgMuted: { light: 'orange.200', dark: 'orange.900', lightOpacity: 80, darkOpacity: 50 },
+ text: { light: 'orange.900', dark: 'orange.200' },
+ border: { light: 'orange.400', dark: 'orange.600' },
+ tableRow: { light: 'white', dark: 'orange.900', darkOpacity: 30 },
+ tableRowAlt: { light: 'orange.50', dark: 'orange.900', darkOpacity: 50 },
+ tableRowHover: { light: 'orange.200', dark: 'orange.700' },
+ },
+
+ // Judge - Blue
+ judge: {
+ bg: { light: 'blue.200', dark: 'blue.800' },
+ bgMuted: { light: 'blue.100', dark: 'blue.900', lightOpacity: 80, darkOpacity: 50 },
+ text: { light: 'blue.800', dark: 'blue.200' },
+ border: { light: 'blue.300', dark: 'blue.700' },
+ tableRow: { light: 'white', dark: 'blue.900', darkOpacity: 30 },
+ tableRowAlt: { light: 'blue.50', dark: 'blue.900', darkOpacity: 50 },
+ tableRowHover: { light: 'blue.200', dark: 'blue.800' },
+ },
+
+ // Delegate - Purple
+ delegate: {
+ bg: { light: 'purple.200', dark: 'purple.800' },
+ bgMuted: { light: 'purple.100', dark: 'purple.900', lightOpacity: 80, darkOpacity: 50 },
+ text: { light: 'purple.800', dark: 'purple.200' },
+ border: { light: 'purple.300', dark: 'purple.700' },
+ tableRow: { light: 'white', dark: 'purple.900', darkOpacity: 30 },
+ tableRowAlt: { light: 'purple.50', dark: 'purple.900', darkOpacity: 50 },
+ tableRowHover: { light: 'purple.200', dark: 'purple.800' },
+ },
+
+ // Stage Lead - Fuchsia
+ stagelead: {
+ bg: { light: 'fuchsia.200', dark: 'fuchsia.800' },
+ bgMuted: { light: 'fuchsia.100', dark: 'fuchsia.900', lightOpacity: 80, darkOpacity: 50 },
+ text: { light: 'fuchsia.800', dark: 'fuchsia.200' },
+ border: { light: 'fuchsia.300', dark: 'fuchsia.700' },
+ tableRow: { light: 'white', dark: 'fuchsia.900', darkOpacity: 30 },
+ tableRowAlt: { light: 'fuchsia.50', dark: 'fuchsia.900', darkOpacity: 50 },
+ tableRowHover: { light: 'fuchsia.200', dark: 'fuchsia.800' },
+ },
+
+ // Announcer - Violet
+ announcer: {
+ bg: { light: 'violet.200', dark: 'violet.800' },
+ bgMuted: { light: 'violet.100', dark: 'violet.900', lightOpacity: 80, darkOpacity: 50 },
+ text: { light: 'violet.800', dark: 'violet.200' },
+ border: { light: 'violet.300', dark: 'violet.700' },
+ tableRow: { light: 'white', dark: 'violet.900', darkOpacity: 30 },
+ tableRowAlt: { light: 'violet.50', dark: 'violet.900', darkOpacity: 50 },
+ tableRowHover: { light: 'violet.200', dark: 'violet.800' },
+ },
+
+ // Showrunner - Pink
+ showrunner: {
+ bg: { light: 'pink.200', dark: 'pink.800' },
+ bgMuted: { light: 'pink.100', dark: 'pink.900', lightOpacity: 80, darkOpacity: 50 },
+ text: { light: 'pink.800', dark: 'pink.200' },
+ border: { light: 'pink.300', dark: 'pink.700' },
+ tableRow: { light: 'white', dark: 'pink.900', darkOpacity: 30 },
+ tableRowAlt: { light: 'pink.50', dark: 'pink.900', darkOpacity: 50 },
+ tableRowHover: { light: 'pink.200', dark: 'pink.800' },
+ },
+
+ // Data Entry - Cyan
+ dataentry: {
+ bg: { light: 'cyan.200', dark: 'cyan.800' },
+ bgMuted: { light: 'cyan.100', dark: 'cyan.900', lightOpacity: 80, darkOpacity: 50 },
+ text: { light: 'cyan.800', dark: 'cyan.200' },
+ border: { light: 'cyan.300', dark: 'cyan.700' },
+ tableRow: { light: 'white', dark: 'cyan.900', darkOpacity: 30 },
+ tableRowAlt: { light: 'cyan.50', dark: 'cyan.900', darkOpacity: 50 },
+ tableRowHover: { light: 'cyan.200', dark: 'cyan.800' },
+ },
+
+ // Other - Slate
+ other: {
+ bg: { light: 'slate.200', dark: 'slate.700' },
+ bgMuted: { light: 'slate.100', dark: 'slate.800', lightOpacity: 80, darkOpacity: 50 },
+ text: { light: 'slate.800', dark: 'slate.200' },
+ border: { light: 'slate.300', dark: 'slate.600' },
+ tableRow: { light: 'white', dark: 'slate.800', darkOpacity: 30 },
+ tableRowAlt: { light: 'slate.50', dark: 'slate.800', darkOpacity: 50 },
+ tableRowHover: { light: 'slate.200', dark: 'slate.700' },
+ },
+
+ // Neutral (break, lunch, setupteardown) - Gray
+ neutral: {
+ bg: { light: 'gray.200', dark: 'gray.700' },
+ bgMuted: { light: 'gray.100', dark: 'gray.800', lightOpacity: 80, darkOpacity: 50 },
+ text: { light: 'gray.800', dark: 'gray.200' },
+ border: { light: 'gray.300', dark: 'gray.600' },
+ tableRow: { light: 'white', dark: 'gray.800', darkOpacity: 30 },
+ tableRowAlt: { light: 'gray.50', dark: 'gray.800', darkOpacity: 50 },
+ tableRowHover: { light: 'gray.200', dark: 'gray.700' },
+ },
+
+ // Core Staff - Rose
+ core: {
+ bg: { light: 'rose.200', dark: 'rose.800' },
+ bgMuted: { light: 'rose.100', dark: 'rose.900', lightOpacity: 80, darkOpacity: 50 },
+ text: { light: 'rose.800', dark: 'rose.200' },
+ border: { light: 'rose.300', dark: 'rose.700' },
+ tableRow: { light: 'white', dark: 'rose.900', darkOpacity: 30 },
+ tableRowAlt: { light: 'rose.50', dark: 'rose.900', darkOpacity: 50 },
+ tableRowHover: { light: 'rose.200', dark: 'rose.800' },
+ },
+
+ // Stream - Indigo
+ stream: {
+ bg: { light: 'indigo.200', dark: 'indigo.800' },
+ bgMuted: { light: 'indigo.100', dark: 'indigo.900', lightOpacity: 80, darkOpacity: 50 },
+ text: { light: 'indigo.800', dark: 'indigo.200' },
+ border: { light: 'indigo.300', dark: 'indigo.700' },
+ tableRow: { light: 'white', dark: 'indigo.900', darkOpacity: 30 },
+ tableRowAlt: { light: 'indigo.50', dark: 'indigo.900', darkOpacity: 50 },
+ tableRowHover: { light: 'indigo.200', dark: 'indigo.800' },
+ },
+
+ // Photo - Amber (needs stronger shades for visibility)
+ photo: {
+ bg: { light: 'amber.300', dark: 'amber.700' },
+ bgMuted: { light: 'amber.200', dark: 'amber.900', lightOpacity: 80, darkOpacity: 50 },
+ text: { light: 'amber.900', dark: 'amber.200' },
+ border: { light: 'amber.400', dark: 'amber.600' },
+ tableRow: { light: 'white', dark: 'amber.900', darkOpacity: 30 },
+ tableRowAlt: { light: 'amber.50', dark: 'amber.900', darkOpacity: 50 },
+ tableRowHover: { light: 'amber.200', dark: 'amber.700' },
+ },
+};
+
+module.exports = plugin(function ({ addComponents, addUtilities, theme }) {
+ const assignmentComponents = {};
+ const tableUtilities = {};
+
+ for (const [name, config] of Object.entries(assignmentColorConfig)) {
+ // .bg-assignment-{name}
+ assignmentComponents[`.bg-assignment-${name}`] = {
+ backgroundColor: theme(`colors.${config.bg.light}`),
+ '.dark &': {
+ backgroundColor: theme(`colors.${config.bg.dark}`),
+ },
+ };
+
+ // .bg-assignment-{name}-muted
+ assignmentComponents[`.bg-assignment-${name}-muted`] = {
+ backgroundColor: `rgb(${hexToRgb(theme(`colors.${config.bgMuted.light}`))} / ${config.bgMuted.lightOpacity}%)`,
+ '.dark &': {
+ backgroundColor: `rgb(${hexToRgb(theme(`colors.${config.bgMuted.dark}`))} / ${config.bgMuted.darkOpacity}%)`,
+ },
+ };
+
+ // .text-assignment-{name}
+ assignmentComponents[`.text-assignment-${name}`] = {
+ color: theme(`colors.${config.text.light}`),
+ '.dark &': {
+ color: theme(`colors.${config.text.dark}`),
+ },
+ };
+
+ // .border-assignment-{name}
+ assignmentComponents[`.border-assignment-${name}`] = {
+ borderColor: theme(`colors.${config.border.light}`),
+ '.dark &': {
+ borderColor: theme(`colors.${config.border.dark}`),
+ },
+ };
+
+ // .table-bg-row-{name}
+ tableUtilities[`.table-bg-row-${name}`] = {
+ backgroundColor: theme(`colors.${config.tableRow.light}`),
+ '.dark &': {
+ backgroundColor: `rgb(${hexToRgb(theme(`colors.${config.tableRow.dark}`))} / ${config.tableRow.darkOpacity}%)`,
+ },
+ };
+
+ // .table-bg-row-alt-{name}
+ tableUtilities[`.table-bg-row-alt-${name}`] = {
+ backgroundColor: theme(`colors.${config.tableRowAlt.light}`),
+ '.dark &': {
+ backgroundColor: `rgb(${hexToRgb(theme(`colors.${config.tableRowAlt.dark}`))} / ${config.tableRowAlt.darkOpacity}%)`,
+ },
+ };
+
+ // .table-bg-row-hover-{name}
+ tableUtilities[`.table-bg-row-hover-${name}`] = {
+ backgroundColor: theme(`colors.${config.tableRowHover.light}`),
+ '.dark &': {
+ backgroundColor: theme(`colors.${config.tableRowHover.dark}`),
+ },
+ };
+ }
+
+ addComponents(assignmentComponents);
+ addUtilities(tableUtilities);
+});
+
+/**
+ * Convert hex color to RGB string for use with opacity
+ * @param {string} hex - Hex color value (e.g., '#4ade80' or '4ade80')
+ * @returns {string} RGB values as space-separated string (e.g., '74 222 128')
+ */
+function hexToRgb(hex) {
+ if (!hex) return '0 0 0';
+
+ // Remove # if present
+ const cleanHex = hex.replace('#', '');
+
+ // Parse RGB values
+ const r = parseInt(cleanHex.substring(0, 2), 16);
+ const g = parseInt(cleanHex.substring(2, 4), 16);
+ const b = parseInt(cleanHex.substring(4, 6), 16);
+
+ return `${r} ${g} ${b}`;
+}
| |