Started working on adding mobile buzzers, a lot of rewriting
This commit is contained in:
104
src/lib/utils/color.ts
Normal file
104
src/lib/utils/color.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// ============================================
|
||||
// WCAG & Color Utilities
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Convert Hex color to RGB array
|
||||
*/
|
||||
export function hexToRgb(hex: string): [number, number, number] {
|
||||
const bigint = parseInt(hex.replace('#', ''), 16);
|
||||
return [(bigint >> 16) & 255, (bigint >> 8) & 255, bigint & 255];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Relative Luminance (WCAG 2.0 formula)
|
||||
*/
|
||||
export function getLuminance(r: number, g: number, b: number): number {
|
||||
const a = [r, g, b].map((v) => {
|
||||
v /= 255;
|
||||
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Contrast Ratio between two colors
|
||||
*/
|
||||
export function getContrast(hex1: string, hex2: string): number {
|
||||
const rgb1 = hexToRgb(hex1);
|
||||
const rgb2 = hexToRgb(hex2);
|
||||
const l1 = getLuminance(rgb1[0], rgb1[1], rgb1[2]);
|
||||
const l2 = getLuminance(rgb2[0], rgb2[1], rgb2[2]);
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Hex to HSL
|
||||
* Returns [H in degrees, S as 0-1, L as 0-1]
|
||||
*/
|
||||
export function hexToHsl(hex: string): [number, number, number] {
|
||||
const rgb = hexToRgb(hex);
|
||||
const r = rgb[0] / 255;
|
||||
const g = rgb[1] / 255;
|
||||
const b = rgb[2] / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
|
||||
case g: h = (b - r) / d + 2; break;
|
||||
case b: h = (r - g) / d + 4; break;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
return [h * 360, s, l];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HSL to Hex
|
||||
* @param h Hue in degrees (0-360)
|
||||
* @param s Saturation as percentage (0-100)
|
||||
* @param l Lightness as percentage (0-100)
|
||||
*/
|
||||
export function hslToHex(h: number, s: number, l: number): string {
|
||||
l /= 100;
|
||||
const a = s * Math.min(l, 1 - l) / 100;
|
||||
const f = (n: number) => {
|
||||
const k = (n + h / 30) % 12;
|
||||
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||
return Math.round(255 * color).toString(16).padStart(2, '0');
|
||||
};
|
||||
return `#${f(0)}${f(8)}${f(4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random color with controlled HSL values
|
||||
*/
|
||||
export function generateHslColor(
|
||||
hueRange: [number, number] | null,
|
||||
satRange: [number, number],
|
||||
lumRange: [number, number]
|
||||
): string {
|
||||
const h = hueRange
|
||||
? Math.floor(Math.random() * (hueRange[1] - hueRange[0])) + hueRange[0]
|
||||
: Math.floor(Math.random() * 360);
|
||||
|
||||
const s = Math.floor(Math.random() * (satRange[1] - satRange[0])) + satRange[0];
|
||||
const l = Math.floor(Math.random() * (lumRange[1] - lumRange[0])) + lumRange[0];
|
||||
|
||||
return hslToHex(h, s, l);
|
||||
}
|
||||
|
||||
// Default theme colors
|
||||
export const DEFAULT_THEME = {
|
||||
primary: "#003B9B",
|
||||
secondary: "#FFAB00",
|
||||
text: "#FFFFFF",
|
||||
background: "#000000",
|
||||
};
|
||||
47
src/lib/utils/focusTrap.ts
Normal file
47
src/lib/utils/focusTrap.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// Focus trap utility for modals
|
||||
export function trapFocus(node: HTMLElement) {
|
||||
const focusableSelectors = [
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'a[href]',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(', ');
|
||||
|
||||
function getFocusableElements() {
|
||||
return Array.from(node.querySelectorAll<HTMLElement>(focusableSelectors));
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
const focusable = getFocusableElements();
|
||||
if (focusable.length === 0) return;
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Focus first element on mount
|
||||
const focusable = getFocusableElements();
|
||||
if (focusable.length > 0) {
|
||||
focusable[0].focus();
|
||||
}
|
||||
|
||||
node.addEventListener('keydown', handleKeydown);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('keydown', handleKeydown);
|
||||
}
|
||||
};
|
||||
}
|
||||
155
src/lib/utils/validation.ts
Normal file
155
src/lib/utils/validation.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
// ============================================
|
||||
// JSON Validation Utilities
|
||||
// Type-safe parsing with structural validation
|
||||
// ============================================
|
||||
|
||||
import type { GameSettings, Team, Round, FinalRound } from '$lib/types/kuldvillak';
|
||||
|
||||
/**
|
||||
* Result type for validation operations
|
||||
*/
|
||||
export type ValidationResult<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: string };
|
||||
|
||||
/**
|
||||
* Validate that a value is a non-null object
|
||||
*/
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a value is an array
|
||||
*/
|
||||
function isArray(value: unknown): value is unknown[] {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a value is a string
|
||||
*/
|
||||
function isString(value: unknown): value is string {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a value is a number
|
||||
*/
|
||||
function isNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && !isNaN(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Game data structure for import/export
|
||||
*/
|
||||
export interface GameData {
|
||||
name?: string;
|
||||
settings: GameSettings;
|
||||
teams: Team[];
|
||||
rounds: Round[];
|
||||
finalRound?: FinalRound | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and parse game data from unknown input
|
||||
* @param data Unknown data to validate
|
||||
* @returns Validation result with typed data or error message
|
||||
*/
|
||||
export function validateGameData(data: unknown): ValidationResult<GameData> {
|
||||
if (!isObject(data)) {
|
||||
return { success: false, error: 'Invalid data format: expected object' };
|
||||
}
|
||||
|
||||
// Validate required fields exist
|
||||
if (!isObject(data.settings)) {
|
||||
return { success: false, error: 'Missing or invalid settings' };
|
||||
}
|
||||
|
||||
if (!isArray(data.teams)) {
|
||||
return { success: false, error: 'Missing or invalid teams array' };
|
||||
}
|
||||
|
||||
if (!isArray(data.rounds)) {
|
||||
return { success: false, error: 'Missing or invalid rounds array' };
|
||||
}
|
||||
|
||||
// Validate teams structure
|
||||
for (let i = 0; i < data.teams.length; i++) {
|
||||
const team = data.teams[i];
|
||||
if (!isObject(team)) {
|
||||
return { success: false, error: `Invalid team at index ${i}` };
|
||||
}
|
||||
if (!isString(team.id)) {
|
||||
return { success: false, error: `Team ${i} missing id` };
|
||||
}
|
||||
if (!isString(team.name)) {
|
||||
return { success: false, error: `Team ${i} missing name` };
|
||||
}
|
||||
}
|
||||
|
||||
// Validate rounds structure
|
||||
for (let i = 0; i < data.rounds.length; i++) {
|
||||
const round = data.rounds[i];
|
||||
if (!isObject(round)) {
|
||||
return { success: false, error: `Invalid round at index ${i}` };
|
||||
}
|
||||
if (!isArray(round.categories)) {
|
||||
return { success: false, error: `Round ${i} missing categories` };
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up legacy properties from settings
|
||||
const settingsObj = data.settings as Record<string, unknown>;
|
||||
const { teamColors, ...cleanSettings } = settingsObj;
|
||||
|
||||
// Build validated game data
|
||||
const gameData: GameData = {
|
||||
name: isString(data.name) ? data.name : undefined,
|
||||
settings: cleanSettings as unknown as GameSettings,
|
||||
teams: data.teams.map((t) => {
|
||||
const team = t as Record<string, unknown>;
|
||||
return {
|
||||
id: team.id as string,
|
||||
name: team.name as string,
|
||||
score: isNumber(team.score) ? team.score : 0,
|
||||
};
|
||||
}),
|
||||
rounds: data.rounds as Round[],
|
||||
finalRound: isObject(data.finalRound)
|
||||
? (data.finalRound as unknown as FinalRound)
|
||||
: null,
|
||||
};
|
||||
|
||||
return { success: true, data: gameData };
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse JSON string with validation
|
||||
* @param jsonString JSON string to parse
|
||||
* @returns Validation result with parsed data or error
|
||||
*/
|
||||
export function parseJSON<T>(jsonString: string): ValidationResult<T> {
|
||||
try {
|
||||
const data = JSON.parse(jsonString) as T;
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to parse JSON'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate game data from JSON string
|
||||
* @param jsonString JSON string containing game data
|
||||
* @returns Validation result with typed game data or error
|
||||
*/
|
||||
export function parseGameData(jsonString: string): ValidationResult<GameData> {
|
||||
const parseResult = parseJSON<unknown>(jsonString);
|
||||
if (!parseResult.success) {
|
||||
return parseResult;
|
||||
}
|
||||
return validateGameData(parseResult.data);
|
||||
}
|
||||
Reference in New Issue
Block a user