@object-ui/core 2.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -450,3 +450,81 @@ export function resolveMode(
450
450
 
451
451
  return 'light'; // fallback
452
452
  }
453
+
454
+ // ============================================================================
455
+ // WCAG Contrast Checking (v2.0.7)
456
+ // ============================================================================
457
+
458
+ /**
459
+ * Parse a hex color string to RGB values [0-255].
460
+ */
461
+ function hexToRGB(hex: string): [number, number, number] | null {
462
+ let clean = hex.replace(/^#/, '');
463
+ if (clean.length === 3) {
464
+ clean = clean[0] + clean[0] + clean[1] + clean[1] + clean[2] + clean[2];
465
+ }
466
+ const match = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(clean);
467
+ if (!match) return null;
468
+ return [parseInt(match[1], 16), parseInt(match[2], 16), parseInt(match[3], 16)];
469
+ }
470
+
471
+ /**
472
+ * Calculate relative luminance per WCAG 2.1 spec.
473
+ * @see https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
474
+ */
475
+ function relativeLuminance(r: number, g: number, b: number): number {
476
+ const [rs, gs, bs] = [r, g, b].map(c => {
477
+ const s = c / 255;
478
+ return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
479
+ });
480
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
481
+ }
482
+
483
+ /**
484
+ * Calculate the WCAG 2.1 contrast ratio between two hex colors.
485
+ * Returns a value between 1 and 21.
486
+ *
487
+ * @param hex1 - First color in hex format (#RGB or #RRGGBB)
488
+ * @param hex2 - Second color in hex format (#RGB or #RRGGBB)
489
+ * @returns Contrast ratio (1-21), or null if colors are invalid
490
+ */
491
+ export function contrastRatio(hex1: string, hex2: string): number | null {
492
+ const rgb1 = hexToRGB(hex1);
493
+ const rgb2 = hexToRGB(hex2);
494
+ if (!rgb1 || !rgb2) return null;
495
+
496
+ const l1 = relativeLuminance(...rgb1);
497
+ const l2 = relativeLuminance(...rgb2);
498
+ const lighter = Math.max(l1, l2);
499
+ const darker = Math.min(l1, l2);
500
+ return (lighter + 0.05) / (darker + 0.05);
501
+ }
502
+
503
+ /**
504
+ * Check if two colors meet the specified WCAG contrast level.
505
+ *
506
+ * WCAG levels:
507
+ * - AA: 4.5:1 for normal text, 3:1 for large text
508
+ * - AAA: 7:1 for normal text, 4.5:1 for large text
509
+ *
510
+ * @param hex1 - First color in hex format
511
+ * @param hex2 - Second color in hex format
512
+ * @param level - WCAG level: 'AA' or 'AAA'
513
+ * @param isLargeText - Whether the text is large (18pt+ or 14pt+ bold)
514
+ * @returns true if the color pair meets the required contrast level
515
+ */
516
+ export function meetsContrastLevel(
517
+ hex1: string,
518
+ hex2: string,
519
+ level: 'AA' | 'AAA' = 'AA',
520
+ isLargeText = false,
521
+ ): boolean {
522
+ const ratio = contrastRatio(hex1, hex2);
523
+ if (ratio === null) return false;
524
+
525
+ if (level === 'AAA') {
526
+ return isLargeText ? ratio >= 4.5 : ratio >= 7;
527
+ }
528
+ // AA
529
+ return isLargeText ? ratio >= 3 : ratio >= 4.5;
530
+ }
@@ -21,6 +21,8 @@ import {
21
21
  mergeThemes,
22
22
  resolveThemeInheritance,
23
23
  resolveMode,
24
+ contrastRatio,
25
+ meetsContrastLevel,
24
26
  } from '../ThemeEngine';
25
27
 
26
28
  // ============================================================================
@@ -604,3 +606,63 @@ describe('resolveMode', () => {
604
606
  window.matchMedia = original;
605
607
  });
606
608
  });
609
+
610
+ // ============================================================================
611
+ // WCAG Contrast Checking (v2.0.7)
612
+ // ============================================================================
613
+
614
+ describe('contrastRatio', () => {
615
+ it('should return 21 for black and white', () => {
616
+ expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 0);
617
+ });
618
+
619
+ it('should return 1 for identical colors', () => {
620
+ expect(contrastRatio('#336699', '#336699')).toBeCloseTo(1, 1);
621
+ });
622
+
623
+ it('should return null for invalid hex', () => {
624
+ expect(contrastRatio('invalid', '#000000')).toBeNull();
625
+ expect(contrastRatio('#000000', 'xyz')).toBeNull();
626
+ });
627
+
628
+ it('should handle shorthand hex (#RGB)', () => {
629
+ const ratio = contrastRatio('#000', '#fff');
630
+ expect(ratio).toBeCloseTo(21, 0);
631
+ });
632
+
633
+ it('should be order-independent', () => {
634
+ const ratio1 = contrastRatio('#000000', '#336699');
635
+ const ratio2 = contrastRatio('#336699', '#000000');
636
+ expect(ratio1).toBe(ratio2);
637
+ });
638
+ });
639
+
640
+ describe('meetsContrastLevel', () => {
641
+ it('should pass AA for black on white (normal text)', () => {
642
+ expect(meetsContrastLevel('#000000', '#ffffff', 'AA')).toBe(true);
643
+ });
644
+
645
+ it('should pass AAA for black on white (normal text)', () => {
646
+ expect(meetsContrastLevel('#000000', '#ffffff', 'AAA')).toBe(true);
647
+ });
648
+
649
+ it('should fail AA for similar grays', () => {
650
+ // #777 on #999 gives ~1.6:1 ratio
651
+ expect(meetsContrastLevel('#777777', '#999999', 'AA')).toBe(false);
652
+ });
653
+
654
+ it('should use lower threshold for large text (AA)', () => {
655
+ // #767676 on white gives ~4.54:1 (passes AA normal, passes AA large)
656
+ expect(meetsContrastLevel('#767676', '#ffffff', 'AA', false)).toBe(true);
657
+ expect(meetsContrastLevel('#767676', '#ffffff', 'AA', true)).toBe(true);
658
+ });
659
+
660
+ it('should use lower threshold for large text (AAA)', () => {
661
+ // Black on white: 21:1 — passes both
662
+ expect(meetsContrastLevel('#000000', '#ffffff', 'AAA', true)).toBe(true);
663
+ });
664
+
665
+ it('should return false for invalid colors', () => {
666
+ expect(meetsContrastLevel('invalid', '#ffffff', 'AA')).toBe(false);
667
+ });
668
+ });
@@ -19,4 +19,6 @@ export {
19
19
  mergeThemes,
20
20
  resolveThemeInheritance,
21
21
  resolveMode,
22
+ contrastRatio,
23
+ meetsContrastLevel,
22
24
  } from './ThemeEngine';
@@ -0,0 +1,83 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
10
+ import { debugLog, debugTime, debugTimeEnd } from '../debug';
11
+
12
+ describe('Debug Utilities', () => {
13
+ let consoleSpy: ReturnType<typeof vi.spyOn>;
14
+
15
+ beforeEach(() => {
16
+ consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
17
+ });
18
+
19
+ afterEach(() => {
20
+ consoleSpy.mockRestore();
21
+ (globalThis as any).OBJECTUI_DEBUG = undefined;
22
+ });
23
+
24
+ describe('debugLog', () => {
25
+ it('should not log when debug is disabled', () => {
26
+ (globalThis as any).OBJECTUI_DEBUG = false;
27
+ debugLog('schema', 'test message');
28
+ expect(consoleSpy).not.toHaveBeenCalled();
29
+ });
30
+
31
+ it('should log when OBJECTUI_DEBUG is true', () => {
32
+ (globalThis as any).OBJECTUI_DEBUG = true;
33
+ debugLog('schema', 'Resolving component');
34
+ expect(consoleSpy).toHaveBeenCalledWith('[ObjectUI Debug][schema] Resolving component');
35
+ });
36
+
37
+ it('should log with data when provided', () => {
38
+ (globalThis as any).OBJECTUI_DEBUG = true;
39
+ debugLog('registry', 'Registered', { type: 'Button' });
40
+ expect(consoleSpy).toHaveBeenCalledWith(
41
+ '[ObjectUI Debug][registry] Registered',
42
+ { type: 'Button' }
43
+ );
44
+ });
45
+
46
+ it('should not log when debug is undefined', () => {
47
+ debugLog('action', 'test');
48
+ expect(consoleSpy).not.toHaveBeenCalled();
49
+ });
50
+ });
51
+
52
+ describe('debugTime / debugTimeEnd', () => {
53
+ it('should not log timing when debug is disabled', () => {
54
+ (globalThis as any).OBJECTUI_DEBUG = false;
55
+ debugTime('test-timer');
56
+ debugTimeEnd('test-timer');
57
+ expect(consoleSpy).not.toHaveBeenCalled();
58
+ });
59
+
60
+ it('should measure and log elapsed time when debug is enabled', () => {
61
+ (globalThis as any).OBJECTUI_DEBUG = true;
62
+ debugTime('render-test');
63
+
64
+ // Simulate some delay via a busy loop
65
+ const start = performance.now();
66
+ while (performance.now() - start < 5) {
67
+ // wait ~5ms
68
+ }
69
+
70
+ debugTimeEnd('render-test');
71
+ expect(consoleSpy).toHaveBeenCalledTimes(1);
72
+ expect(consoleSpy).toHaveBeenCalledWith(
73
+ expect.stringMatching(/^\[ObjectUI Debug\]\[perf\] render-test: \d+\.\d{2}ms$/)
74
+ );
75
+ });
76
+
77
+ it('should not log if debugTimeEnd is called without debugTime', () => {
78
+ (globalThis as any).OBJECTUI_DEBUG = true;
79
+ debugTimeEnd('nonexistent');
80
+ expect(consoleSpy).not.toHaveBeenCalled();
81
+ });
82
+ });
83
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ type DebugCategory = 'schema' | 'registry' | 'expression' | 'action' | 'plugin' | 'render';
10
+
11
+ function isDebugEnabled(): boolean {
12
+ try {
13
+ const g = typeof globalThis !== 'undefined' && (globalThis as any).OBJECTUI_DEBUG;
14
+ return (
15
+ (g === true || g === 'true') ||
16
+ (typeof process !== 'undefined' && process.env?.OBJECTUI_DEBUG === 'true')
17
+ );
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Log a debug message when OBJECTUI_DEBUG is enabled.
25
+ * No-op in production or when debug mode is off.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * // Enable debug mode
30
+ * globalThis.OBJECTUI_DEBUG = true;
31
+ *
32
+ * debugLog('schema', 'Resolving component', { type: 'Button' });
33
+ * // [ObjectUI Debug][schema] Resolving component { type: 'Button' }
34
+ * ```
35
+ */
36
+ export function debugLog(category: DebugCategory, message: string, data?: unknown): void {
37
+ if (!isDebugEnabled()) return;
38
+ if (data !== undefined) {
39
+ console.log(`[ObjectUI Debug][${category}] ${message}`, data);
40
+ } else {
41
+ console.log(`[ObjectUI Debug][${category}] ${message}`);
42
+ }
43
+ }
44
+
45
+ const timers = new Map<string, number>();
46
+
47
+ /**
48
+ * Start a debug timer. Pair with {@link debugTimeEnd}.
49
+ */
50
+ export function debugTime(label: string): void {
51
+ if (!isDebugEnabled()) return;
52
+ timers.set(label, performance.now());
53
+ }
54
+
55
+ /**
56
+ * End a debug timer and log the elapsed time.
57
+ */
58
+ export function debugTimeEnd(label: string): void {
59
+ if (!isDebugEnabled()) return;
60
+ const start = timers.get(label);
61
+ if (start !== undefined) {
62
+ const elapsed = (performance.now() - start).toFixed(2);
63
+ console.log(`[ObjectUI Debug][perf] ${label}: ${elapsed}ms`);
64
+ timers.delete(label);
65
+ }
66
+ }
@@ -401,7 +401,15 @@ export class ValidationEngine {
401
401
  */
402
402
  private evaluateCondition(condition: any, values: Record<string, any>): boolean {
403
403
  if (typeof condition === 'function') {
404
- console.warn('Function-based conditions are deprecated and will be removed. Use declarative conditions instead.');
404
+ console.warn(
405
+ 'Function-based conditions are deprecated and will be removed. Use declarative conditions instead.\n\n' +
406
+ ' Migration:\n' +
407
+ ' // Before (deprecated):\n' +
408
+ ' { condition: (values) => values.age > 18 }\n\n' +
409
+ ' // After:\n' +
410
+ ' { condition: { field: "age", operator: ">", value: 18 } }\n\n' +
411
+ ' See: https://github.com/objectstack-ai/objectui/blob/main/MIGRATION_GUIDE.md'
412
+ );
405
413
  return false; // Security: reject function-based conditions
406
414
  }
407
415