@rdna/radiants 0.1.3 → 0.1.4

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.
Files changed (54) hide show
  1. package/base.css +1 -1
  2. package/components/core/AppWindow/AppWindow.meta.ts +69 -0
  3. package/components/core/AppWindow/AppWindow.schema.json +55 -0
  4. package/components/core/AppWindow/AppWindow.test.tsx +150 -0
  5. package/components/core/AppWindow/AppWindow.tsx +830 -0
  6. package/components/core/Button/Button.test.tsx +18 -0
  7. package/components/core/Button/Button.tsx +26 -16
  8. package/components/core/DialPanel/DialPanel.tsx +1 -1
  9. package/components/core/Separator/Separator.tsx +1 -1
  10. package/components/core/Tabs/Tabs.tsx +14 -2
  11. package/components/core/__tests__/smoke.test.tsx +2 -0
  12. package/components/core/index.ts +1 -0
  13. package/contract/system.ts +18 -4
  14. package/dark.css +11 -1
  15. package/eslint/contract.mjs +1 -1
  16. package/eslint/index.mjs +10 -0
  17. package/eslint/rules/no-raw-font-family.mjs +91 -0
  18. package/eslint/rules/no-raw-line-height.mjs +119 -0
  19. package/fonts-core.css +70 -0
  20. package/fonts-editorial.css +45 -0
  21. package/fonts.css +19 -89
  22. package/generated/ai-contract.json +11 -2
  23. package/generated/contract.freshness.json +2 -1
  24. package/generated/eslint-contract.json +35 -4
  25. package/generated/figma/contracts/app-window.contract.json +82 -0
  26. package/generated/figma/primitive/color.tokens.json +9 -0
  27. package/generated/figma/primitive/shape.tokens.json +0 -4
  28. package/generated/figma/primitive/typography.tokens.json +16 -4
  29. package/generated/figma/rdna.tokens.json +28 -11
  30. package/generated/figma/semantic/semantic.tokens.json +3 -3
  31. package/generated/figma/tokens.d.ts +1 -1
  32. package/generated/figma/validation-report.json +1 -1
  33. package/icons/DesktopIcons.tsx +4 -3
  34. package/icons/Icon.tsx +10 -2
  35. package/icons/types.ts +7 -1
  36. package/meta/index.ts +6 -0
  37. package/package.json +5 -3
  38. package/patterns/pretext-type-scale.ts +115 -0
  39. package/pixel-corners.generated.css +15 -0
  40. package/schemas/index.ts +2 -0
  41. package/tokens.css +47 -21
  42. package/typography.css +10 -5
  43. package/fonts/PixelCode-Black-Italic.woff2 +0 -0
  44. package/fonts/PixelCode-Black.woff2 +0 -0
  45. package/fonts/PixelCode-DemiBold-Italic.woff2 +0 -0
  46. package/fonts/PixelCode-DemiBold.woff2 +0 -0
  47. package/fonts/PixelCode-ExtraBlack-Italic.woff2 +0 -0
  48. package/fonts/PixelCode-ExtraBlack.woff2 +0 -0
  49. package/fonts/PixelCode-ExtraBold-Italic.woff2 +0 -0
  50. package/fonts/PixelCode-ExtraBold.woff2 +0 -0
  51. package/fonts/PixelCode-ExtraLight-Italic.woff2 +0 -0
  52. package/fonts/PixelCode-ExtraLight.woff2 +0 -0
  53. package/fonts/PixelCode-Thin-Italic.woff2 +0 -0
  54. package/fonts/PixelCode-Thin.woff2 +0 -0
@@ -0,0 +1,830 @@
1
+ 'use client';
2
+
3
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+ import Draggable, { DraggableData, DraggableEvent } from 'react-draggable';
5
+ import { Button } from '../Button/Button';
6
+ import { Icon } from '../Icon/Icon';
7
+ import { ScrollArea } from '../ScrollArea/ScrollArea';
8
+ import { Separator } from '../Separator/Separator';
9
+ import { Tooltip } from '../Tooltip/Tooltip';
10
+
11
+ type WindowDimension = number | string;
12
+ type AppWindowPresentation = 'window' | 'fullscreen' | 'mobile';
13
+
14
+ export interface AppWindowPosition {
15
+ x: number;
16
+ y: number;
17
+ }
18
+
19
+ export interface AppWindowSize {
20
+ width: WindowDimension;
21
+ height: WindowDimension;
22
+ }
23
+
24
+ interface AppWindowActionButton {
25
+ text: string;
26
+ iconName?: string;
27
+ onClick?: () => void;
28
+ href?: string;
29
+ target?: string;
30
+ }
31
+
32
+ export interface AppWindowProps {
33
+ id: string;
34
+ title: string;
35
+ children: React.ReactNode;
36
+ open?: boolean;
37
+ position?: AppWindowPosition;
38
+ defaultPosition?: AppWindowPosition;
39
+ size?: AppWindowSize;
40
+ defaultSize?: AppWindowSize;
41
+ resizable?: boolean;
42
+ className?: string;
43
+ icon?: React.ReactNode;
44
+ contentPadding?: boolean;
45
+ showWidgetButton?: boolean;
46
+ widgetActive?: boolean;
47
+ showCopyButton?: boolean;
48
+ showCloseButton?: boolean;
49
+ showFullscreenButton?: boolean;
50
+ showActionButton?: boolean;
51
+ actionButton?: AppWindowActionButton;
52
+ focused?: boolean;
53
+ zIndex?: number;
54
+ presentation?: AppWindowPresentation;
55
+ minSize?: { width: number; height: number };
56
+ viewportBottomInset?: number;
57
+ viewportMargin?: number;
58
+ autoCenter?: boolean;
59
+ cascadeIndex?: number;
60
+ onWidget?: () => void;
61
+ onClose?: () => void;
62
+ onFocus?: () => void;
63
+ onFullscreen?: () => void;
64
+ onPositionChange?: (position: AppWindowPosition) => void;
65
+ onSizeChange?: (size: { width: number; height: number }) => void;
66
+ }
67
+
68
+ export interface AppWindowBodyProps {
69
+ children: React.ReactNode;
70
+ className?: string;
71
+ padding?: 'none' | 'sm' | 'md' | 'lg';
72
+ bordered?: boolean;
73
+ bgClassName?: string;
74
+ noScroll?: boolean;
75
+ }
76
+
77
+ export interface AppWindowSplitViewProps {
78
+ children: React.ReactNode;
79
+ className?: string;
80
+ }
81
+
82
+ export interface AppWindowPaneProps extends AppWindowBodyProps {}
83
+
84
+ const DEFAULT_MIN_SIZE = { width: 300, height: 200 };
85
+ const DEFAULT_BOTTOM_INSET = 48;
86
+ const DEFAULT_VIEWPORT_MARGIN = 8;
87
+ const TITLE_BAR_HEIGHT = 40;
88
+ const CHROME_PADDING = 16;
89
+ const DEFAULT_CASCADE_OFFSET = 30;
90
+
91
+ const PADDING_MAP: Record<NonNullable<AppWindowBodyProps['padding']>, string> = {
92
+ none: '',
93
+ sm: 'p-2',
94
+ md: 'p-4',
95
+ lg: 'p-6',
96
+ };
97
+
98
+ function renderWindowBodyShell({
99
+ children,
100
+ padding = 'lg',
101
+ bordered = true,
102
+ bgClassName = 'bg-card',
103
+ noScroll = false,
104
+ }: AppWindowBodyProps) {
105
+ const shellClasses = [
106
+ 'h-full',
107
+ bordered ? 'border border-line rounded' : '',
108
+ bgClassName,
109
+ ]
110
+ .filter(Boolean)
111
+ .join(' ');
112
+
113
+ const paddingClass = PADDING_MAP[padding];
114
+
115
+ if (noScroll) {
116
+ return (
117
+ <div
118
+ className={[shellClasses, paddingClass].filter(Boolean).join(' ')}
119
+ style={{ maxHeight: 'var(--app-content-max-height, none)' }}
120
+ >
121
+ {children}
122
+ </div>
123
+ );
124
+ }
125
+
126
+ return (
127
+ <ScrollArea.Root
128
+ className={shellClasses}
129
+ style={{ maxHeight: 'var(--app-content-max-height, none)' } as React.CSSProperties}
130
+ >
131
+ <ScrollArea.Viewport>
132
+ {paddingClass ? <div className={paddingClass}>{children}</div> : children}
133
+ </ScrollArea.Viewport>
134
+ </ScrollArea.Root>
135
+ );
136
+ }
137
+
138
+ function getMaxWindowSize(viewportBottomInset: number, viewportMargin: number): { width: number; height: number } {
139
+ if (typeof window === 'undefined') {
140
+ return { width: 1200, height: 800 };
141
+ }
142
+
143
+ return {
144
+ width: Math.floor(window.innerWidth - viewportMargin * 2),
145
+ height: Math.floor(window.innerHeight - viewportBottomInset),
146
+ };
147
+ }
148
+
149
+ function dimensionToPx(value: WindowDimension | undefined): number | undefined {
150
+ if (typeof value === 'number') return value;
151
+ if (typeof value !== 'string') return undefined;
152
+
153
+ const trimmed = value.trim();
154
+ if (trimmed.endsWith('px')) {
155
+ return Number.parseFloat(trimmed);
156
+ }
157
+ if (trimmed.endsWith('rem')) {
158
+ const remValue = Number.parseFloat(trimmed);
159
+ if (Number.isNaN(remValue) || typeof window === 'undefined') return undefined;
160
+ const rootFontSize = Number.parseFloat(getComputedStyle(document.documentElement).fontSize || '16');
161
+ return remValue * rootFontSize;
162
+ }
163
+
164
+ return undefined;
165
+ }
166
+
167
+ function getMaxContentHeight(viewportBottomInset: number): number {
168
+ const maxWindow = getMaxWindowSize(viewportBottomInset, DEFAULT_VIEWPORT_MARGIN);
169
+ return maxWindow.height - TITLE_BAR_HEIGHT - CHROME_PADDING;
170
+ }
171
+
172
+ function AppWindowTitleBar({
173
+ id,
174
+ title,
175
+ icon: _icon,
176
+ showCopyButton = true,
177
+ showCloseButton = true,
178
+ showFullscreenButton = true,
179
+ showWidgetButton = false,
180
+ showActionButton = false,
181
+ actionButton,
182
+ widgetActive = false,
183
+ presentation,
184
+ onClose,
185
+ onFullscreen,
186
+ onWidget,
187
+ }: {
188
+ id: string;
189
+ title: string;
190
+ icon?: React.ReactNode;
191
+ showCopyButton?: boolean;
192
+ showCloseButton?: boolean;
193
+ showFullscreenButton?: boolean;
194
+ showWidgetButton?: boolean;
195
+ showActionButton?: boolean;
196
+ actionButton?: AppWindowActionButton;
197
+ widgetActive?: boolean;
198
+ presentation: AppWindowPresentation;
199
+ onClose?: () => void;
200
+ onFullscreen?: () => void;
201
+ onWidget?: () => void;
202
+ }) {
203
+ const [copied, setCopied] = useState(false);
204
+
205
+ const handleCopyLink = useCallback(async () => {
206
+ if (typeof window === 'undefined') return;
207
+
208
+ try {
209
+ await navigator.clipboard.writeText(`${window.location.origin}${window.location.pathname}#${id}`);
210
+ setCopied(true);
211
+ window.setTimeout(() => setCopied(false), 2000);
212
+ } catch {
213
+ return;
214
+ }
215
+ }, [id]);
216
+
217
+ return (
218
+ <div
219
+ className="flex items-center gap-3 pl-2 pr-1 py-1.5 h-fit cursor-move select-none"
220
+ data-drag-handle={presentation === 'window' ? '' : undefined}
221
+ style={presentation === 'window' ? { touchAction: 'none' } : undefined}
222
+ >
223
+ <span
224
+ id={`window-title-${id}`}
225
+ className="absolute left-1/2 -translate-x-1/2 font-joystix text-xs uppercase tracking-tight text-head whitespace-nowrap pointer-events-none bg-page px-2 py-0.5"
226
+ >
227
+ {title}
228
+ </span>
229
+
230
+ {/* Portal slot for app-injected title bar content */}
231
+ <div id={`window-titlebar-slot-${id}`} className="contents" />
232
+
233
+ <div className="flex-1">
234
+ <Separator />
235
+ </div>
236
+
237
+ <div className="flex items-center gap-1 text-head pr-1.5">
238
+ {showActionButton && actionButton ? (
239
+ actionButton.href ? (
240
+ <Button
241
+ mode="pattern"
242
+ size="sm"
243
+ icon={actionButton.iconName ?? undefined}
244
+ className="shrink-0"
245
+ href={actionButton.href}
246
+ target={actionButton.target}
247
+ rel={actionButton.target === '_blank' ? 'noopener noreferrer' : undefined}
248
+ onClick={actionButton.onClick}
249
+ >
250
+ {actionButton.text}
251
+ </Button>
252
+ ) : (
253
+ <Button
254
+ mode="pattern"
255
+ size="sm"
256
+ icon={actionButton.iconName ?? undefined}
257
+ className="shrink-0"
258
+ onClick={actionButton.onClick}
259
+ >
260
+ {actionButton.text}
261
+ </Button>
262
+ )
263
+ ) : null}
264
+
265
+ {showWidgetButton && onWidget ? (
266
+ <Tooltip content={widgetActive ? 'Exit widget mode' : 'Widget mode'}>
267
+ <Button
268
+ size="sm"
269
+ iconOnly
270
+ icon="picture-in-picture"
271
+ onClick={onWidget}
272
+ aria-label={`${widgetActive ? 'Exit' : 'Enter'} widget mode for ${title}`}
273
+ />
274
+ </Tooltip>
275
+ ) : null}
276
+
277
+ {showCopyButton ? (
278
+ <Tooltip content="Copy link">
279
+ <Button
280
+ tone="success"
281
+ size="sm"
282
+ rounded="sm"
283
+ iconOnly
284
+ icon={copied ? 'copied-to-clipboard' : 'copy-to-clipboard'}
285
+ onClick={handleCopyLink}
286
+ aria-label={`Copy link to ${title}`}
287
+ />
288
+ </Tooltip>
289
+ ) : null}
290
+
291
+ {showFullscreenButton && onFullscreen ? (
292
+ <Tooltip content={presentation === 'fullscreen' ? 'Exit fullscreen' : 'Enter fullscreen'}>
293
+ <Button
294
+ tone="accent"
295
+ size="sm"
296
+ rounded="sm"
297
+ iconOnly
298
+ icon={presentation === 'fullscreen' ? 'collapse' : 'expand'}
299
+ onClick={onFullscreen}
300
+ aria-label={`${presentation === 'fullscreen' ? 'Exit' : 'Enter'} fullscreen ${title}`}
301
+ />
302
+ </Tooltip>
303
+ ) : null}
304
+
305
+ {showCloseButton && onClose ? (
306
+ <Tooltip content="Close">
307
+ <Button
308
+ tone="danger"
309
+ size="sm"
310
+ rounded="sm"
311
+ iconOnly
312
+ icon="close"
313
+ onClick={onClose}
314
+ aria-label={`Close ${title}`}
315
+ />
316
+ </Tooltip>
317
+ ) : null}
318
+ </div>
319
+ </div>
320
+ );
321
+ }
322
+
323
+ export function AppWindowBody({
324
+ children,
325
+ className = '',
326
+ padding = 'lg',
327
+ bordered = true,
328
+ bgClassName = 'bg-card',
329
+ noScroll = false,
330
+ }: AppWindowBodyProps) {
331
+ return (
332
+ <div className={`flex-1 min-h-0 mx-2 ${className}`.trim()}>
333
+ {renderWindowBodyShell({
334
+ children,
335
+ padding,
336
+ bordered,
337
+ bgClassName,
338
+ noScroll,
339
+ })}
340
+ </div>
341
+ );
342
+ }
343
+
344
+ export function AppWindowSplitView({ children, className = '' }: AppWindowSplitViewProps) {
345
+ return (
346
+ <div
347
+ className={`flex flex-1 min-h-0 gap-1 px-2 pb-2 ${className}`.trim()}
348
+ data-window-layout="split"
349
+ >
350
+ {children}
351
+ </div>
352
+ );
353
+ }
354
+
355
+ export function AppWindowPane({
356
+ children,
357
+ className = '',
358
+ padding = 'lg',
359
+ bordered = true,
360
+ bgClassName = 'bg-card',
361
+ noScroll = false,
362
+ }: AppWindowPaneProps) {
363
+ return (
364
+ <div className={`flex-1 min-w-0 min-h-0 ${className}`.trim()}>
365
+ {renderWindowBodyShell({
366
+ children,
367
+ padding,
368
+ bordered,
369
+ bgClassName,
370
+ noScroll,
371
+ })}
372
+ </div>
373
+ );
374
+ }
375
+
376
+ export function AppWindow({
377
+ id,
378
+ title,
379
+ children,
380
+ open = true,
381
+ position,
382
+ defaultPosition = { x: 100, y: 50 },
383
+ size,
384
+ defaultSize,
385
+ resizable = true,
386
+ className = '',
387
+ icon,
388
+ contentPadding = true,
389
+ showWidgetButton = false,
390
+ widgetActive = false,
391
+ showCopyButton = true,
392
+ showCloseButton = true,
393
+ showFullscreenButton = true,
394
+ showActionButton = false,
395
+ actionButton,
396
+ focused = false,
397
+ zIndex = 100,
398
+ presentation = 'window',
399
+ minSize = DEFAULT_MIN_SIZE,
400
+ viewportBottomInset = DEFAULT_BOTTOM_INSET,
401
+ viewportMargin = DEFAULT_VIEWPORT_MARGIN,
402
+ autoCenter = false,
403
+ cascadeIndex = 0,
404
+ onWidget,
405
+ onClose,
406
+ onFocus,
407
+ onFullscreen,
408
+ onPositionChange,
409
+ onSizeChange,
410
+ }: AppWindowProps) {
411
+ const nodeRef = useRef<HTMLDivElement>(null);
412
+ const lastCenteredSizeRef = useRef<{ width: number; height: number } | null>(null);
413
+ const [internalPosition, setInternalPosition] = useState(defaultPosition);
414
+ const [internalSize, setInternalSize] = useState<AppWindowSize | undefined>(defaultSize);
415
+ const [isResizing, setIsResizing] = useState(false);
416
+ const [hasUserInteracted, setHasUserInteracted] = useState(false);
417
+ const [resizeDirection, setResizeDirection] = useState('');
418
+ const [resizeStart, setResizeStart] = useState({
419
+ x: 0,
420
+ y: 0,
421
+ width: 0,
422
+ height: 0,
423
+ positionX: 0,
424
+ positionY: 0,
425
+ });
426
+
427
+ const effectivePosition = position ?? internalPosition;
428
+ const effectiveSize = size ?? internalSize;
429
+ const isPositionControlled = position !== undefined;
430
+ const isSizeControlled = size !== undefined;
431
+ const effectiveMax = useMemo(
432
+ () => getMaxWindowSize(viewportBottomInset, viewportMargin),
433
+ [viewportBottomInset, viewportMargin],
434
+ );
435
+
436
+ const commitPosition = useCallback(
437
+ (next: AppWindowPosition) => {
438
+ if (!isPositionControlled) {
439
+ setInternalPosition(next);
440
+ }
441
+ onPositionChange?.(next);
442
+ },
443
+ [isPositionControlled, onPositionChange],
444
+ );
445
+
446
+ const commitSize = useCallback(
447
+ (next: { width: number; height: number }) => {
448
+ if (!isSizeControlled) {
449
+ setInternalSize(next);
450
+ }
451
+ onSizeChange?.(next);
452
+ },
453
+ [isSizeControlled, onSizeChange],
454
+ );
455
+
456
+ useEffect(() => {
457
+ if (open) return;
458
+ setHasUserInteracted(false);
459
+ setInternalPosition(defaultPosition);
460
+ setInternalSize(defaultSize);
461
+ lastCenteredSizeRef.current = null;
462
+ }, [defaultPosition, defaultSize, open]);
463
+
464
+ const handleFocus = useCallback(() => {
465
+ onFocus?.();
466
+ }, [onFocus]);
467
+
468
+ const handleDragStop = useCallback(
469
+ (_event: DraggableEvent, data: DraggableData) => {
470
+ setHasUserInteracted(true);
471
+ commitPosition({ x: data.x, y: data.y });
472
+ },
473
+ [commitPosition],
474
+ );
475
+
476
+ const handleResizeStart = useCallback(
477
+ (event: React.PointerEvent, direction: string) => {
478
+ event.preventDefault();
479
+ event.stopPropagation();
480
+
481
+ if (!nodeRef.current) return;
482
+
483
+ const rect = nodeRef.current.getBoundingClientRect();
484
+
485
+ setIsResizing(true);
486
+ setHasUserInteracted(true);
487
+ setResizeDirection(direction);
488
+ setResizeStart({
489
+ x: event.clientX,
490
+ y: event.clientY,
491
+ width: rect.width,
492
+ height: rect.height,
493
+ positionX: effectivePosition.x,
494
+ positionY: effectivePosition.y,
495
+ });
496
+
497
+ onFocus?.();
498
+ },
499
+ [effectivePosition.x, effectivePosition.y, onFocus],
500
+ );
501
+
502
+ useEffect(() => {
503
+ if (!isResizing || presentation !== 'window') return;
504
+
505
+ const handlePointerMove = (event: PointerEvent) => {
506
+ let newWidth = resizeStart.width;
507
+ let newHeight = resizeStart.height;
508
+ let newX = resizeStart.positionX;
509
+ let newY = resizeStart.positionY;
510
+
511
+ const deltaX = event.clientX - resizeStart.x;
512
+ const deltaY = event.clientY - resizeStart.y;
513
+
514
+ if (resizeDirection.includes('e')) {
515
+ newWidth = Math.min(Math.max(resizeStart.width + deltaX, minSize.width), effectiveMax.width);
516
+ }
517
+ if (resizeDirection.includes('w')) {
518
+ newWidth = Math.min(Math.max(resizeStart.width - deltaX, minSize.width), effectiveMax.width);
519
+ newX = resizeStart.positionX + (resizeStart.width - newWidth);
520
+ }
521
+ if (resizeDirection.includes('s')) {
522
+ newHeight = Math.min(Math.max(resizeStart.height + deltaY, minSize.height), effectiveMax.height);
523
+ }
524
+ if (resizeDirection.includes('n')) {
525
+ newHeight = Math.min(Math.max(resizeStart.height - deltaY, minSize.height), effectiveMax.height);
526
+ newY = resizeStart.positionY + (resizeStart.height - newHeight);
527
+ }
528
+
529
+ commitSize({ width: Math.round(newWidth), height: Math.round(newHeight) });
530
+
531
+ if (resizeDirection.includes('w') || resizeDirection.includes('n')) {
532
+ commitPosition({ x: Math.round(newX), y: Math.round(newY) });
533
+ }
534
+ };
535
+
536
+ const handlePointerUp = () => {
537
+ setIsResizing(false);
538
+ setResizeDirection('');
539
+ };
540
+
541
+ document.addEventListener('pointermove', handlePointerMove);
542
+ document.addEventListener('pointerup', handlePointerUp);
543
+
544
+ return () => {
545
+ document.removeEventListener('pointermove', handlePointerMove);
546
+ document.removeEventListener('pointerup', handlePointerUp);
547
+ };
548
+ }, [
549
+ commitPosition,
550
+ commitSize,
551
+ effectiveMax.height,
552
+ effectiveMax.width,
553
+ isResizing,
554
+ minSize.height,
555
+ minSize.width,
556
+ presentation,
557
+ resizeDirection,
558
+ resizeStart,
559
+ ]);
560
+
561
+ useEffect(() => {
562
+ if (!autoCenter || presentation !== 'window' || hasUserInteracted || effectiveSize || !nodeRef.current) {
563
+ return;
564
+ }
565
+
566
+ const centerWindow = (width: number, height: number) => {
567
+ if (typeof window === 'undefined') return;
568
+
569
+ const clampedWidth = Math.min(Math.max(width, minSize.width), effectiveMax.width);
570
+ const clampedHeight = Math.min(Math.max(height, minSize.height), effectiveMax.height);
571
+ const desktopHeight = window.innerHeight - viewportBottomInset;
572
+
573
+ let x = (window.innerWidth - clampedWidth) / 2 + cascadeIndex * DEFAULT_CASCADE_OFFSET;
574
+ let y = (desktopHeight - clampedHeight) / 2 + cascadeIndex * DEFAULT_CASCADE_OFFSET;
575
+
576
+ x = Math.max(0, Math.min(x, window.innerWidth - clampedWidth));
577
+ y = Math.max(0, Math.min(y, desktopHeight - clampedHeight));
578
+
579
+ commitPosition({ x: Math.round(x), y: Math.round(y) });
580
+ lastCenteredSizeRef.current = { width: clampedWidth, height: clampedHeight };
581
+ };
582
+
583
+ const observer = new ResizeObserver((entries) => {
584
+ const entry = entries[0];
585
+ if (!entry) return;
586
+
587
+ const { width, height } = entry.contentRect;
588
+ const lastSize = lastCenteredSizeRef.current;
589
+ if (
590
+ lastSize &&
591
+ Math.abs(width - lastSize.width) < 10 &&
592
+ Math.abs(height - lastSize.height) < 10
593
+ ) {
594
+ return;
595
+ }
596
+
597
+ centerWindow(width, height);
598
+ });
599
+
600
+ observer.observe(nodeRef.current);
601
+ const rect = nodeRef.current.getBoundingClientRect();
602
+ centerWindow(rect.width, rect.height);
603
+
604
+ return () => observer.disconnect();
605
+ }, [
606
+ autoCenter,
607
+ cascadeIndex,
608
+ commitPosition,
609
+ effectiveMax.height,
610
+ effectiveMax.width,
611
+ effectiveSize,
612
+ hasUserInteracted,
613
+ minSize.height,
614
+ minSize.width,
615
+ presentation,
616
+ viewportBottomInset,
617
+ ]);
618
+
619
+ if (!open || widgetActive) {
620
+ return null;
621
+ }
622
+
623
+ const actualWindowHeight = dimensionToPx(effectiveSize?.height);
624
+ const maxContentHeight = actualWindowHeight
625
+ ? actualWindowHeight - TITLE_BAR_HEIGHT - CHROME_PADDING
626
+ : getMaxContentHeight(viewportBottomInset);
627
+ const hasExplicitWidth = effectiveSize?.width !== undefined;
628
+
629
+ if (presentation === 'mobile') {
630
+ return (
631
+ <div
632
+ ref={nodeRef}
633
+ role="dialog"
634
+ aria-labelledby={`window-title-${id}`}
635
+ className={`fixed inset-0 bg-page flex flex-col pointer-events-auto ${className}`.trim()}
636
+ style={{ zIndex }}
637
+ data-app-window={id}
638
+ >
639
+ <header
640
+ className="flex items-center justify-between px-4 py-3 bg-page border-b flex-shrink-0"
641
+ style={{ borderBottomColor: 'var(--color-rule)' }}
642
+ >
643
+ <span id={`window-title-${id}`} className="font-joystix text-sm text-main uppercase">
644
+ {title}
645
+ </span>
646
+ {onClose ? (
647
+ <Button
648
+ type="button"
649
+ quiet
650
+ size="sm"
651
+ onClick={onClose}
652
+ className="w-11 h-11 flex items-center justify-center hover:bg-hover active:bg-active pixel-rounded-sm -mr-2"
653
+ aria-label={`Close ${title}`}
654
+ >
655
+ <Icon name="close" size={16} className="text-main" />
656
+ </Button>
657
+ ) : null}
658
+ </header>
659
+ <main className="flex-1 overflow-auto @container">{children}</main>
660
+ </div>
661
+ );
662
+ }
663
+
664
+ if (presentation === 'fullscreen') {
665
+ return (
666
+ <div
667
+ ref={nodeRef}
668
+ role="dialog"
669
+ aria-labelledby={`window-title-${id}`}
670
+ className={`
671
+ fixed inset-0 pointer-events-auto border border-line overflow-hidden flex flex-col p-0
672
+ focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus ${className}
673
+ `.trim()}
674
+ style={{
675
+ zIndex,
676
+ background: 'linear-gradient(0deg, var(--color-window-chrome-from) 0%, var(--color-window-chrome-to) 100%)',
677
+ }}
678
+ onPointerDown={handleFocus}
679
+ onClick={handleFocus}
680
+ tabIndex={-1}
681
+ data-app-window={id}
682
+ data-fullscreen="true"
683
+ data-focused={focused || undefined}
684
+ >
685
+ <AppWindowTitleBar
686
+ id={id}
687
+ title={title}
688
+ icon={icon}
689
+ showCopyButton={showCopyButton}
690
+ showCloseButton={showCloseButton}
691
+ showFullscreenButton={showFullscreenButton}
692
+ showWidgetButton={showWidgetButton}
693
+ showActionButton={showActionButton}
694
+ actionButton={actionButton}
695
+ widgetActive={widgetActive}
696
+ presentation={presentation}
697
+ onClose={onClose}
698
+ onFullscreen={onFullscreen}
699
+ onWidget={onWidget}
700
+ />
701
+ <div className="flex-1 min-h-0 @container">{children}</div>
702
+ </div>
703
+ );
704
+ }
705
+
706
+ return (
707
+ <Draggable
708
+ nodeRef={nodeRef}
709
+ handle="[data-drag-handle]"
710
+ position={effectivePosition}
711
+ onStop={handleDragStop}
712
+ bounds="parent"
713
+ disabled={isResizing}
714
+ >
715
+ <div
716
+ ref={nodeRef}
717
+ role="dialog"
718
+ aria-labelledby={`window-title-${id}`}
719
+ className={`
720
+ absolute pointer-events-auto pixel-rounded-md flex flex-col p-0
721
+ focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus ${className}
722
+ `.trim()}
723
+ style={{
724
+ position: 'absolute',
725
+ width: effectiveSize?.width ?? 'fit-content',
726
+ height: effectiveSize?.height ?? 'fit-content',
727
+ minWidth: minSize.width,
728
+ minHeight: minSize.height,
729
+ maxWidth: effectiveMax.width,
730
+ maxHeight: effectiveMax.height,
731
+ zIndex,
732
+ background: 'linear-gradient(0deg, var(--color-window-chrome-from) 0%, var(--color-window-chrome-to) 100%)',
733
+ boxShadow: 'var(--shadow-floating)',
734
+ }}
735
+ onPointerDown={handleFocus}
736
+ onClick={handleFocus}
737
+ tabIndex={-1}
738
+ data-app-window={id}
739
+ data-resizable={resizable}
740
+ data-focused={focused || undefined}
741
+ >
742
+ {!focused && (
743
+ <div
744
+ className="absolute inset-0 z-20 pointer-events-none"
745
+ style={{ backgroundImage: 'var(--pat-diagonal-dots)', backgroundRepeat: 'repeat' }}
746
+ />
747
+ )}
748
+
749
+ <AppWindowTitleBar
750
+ id={id}
751
+ title={title}
752
+ icon={icon}
753
+ showCopyButton={showCopyButton}
754
+ showCloseButton={showCloseButton}
755
+ showFullscreenButton={showFullscreenButton}
756
+ showWidgetButton={showWidgetButton}
757
+ showActionButton={showActionButton}
758
+ actionButton={actionButton}
759
+ widgetActive={widgetActive}
760
+ presentation={presentation}
761
+ onClose={onClose}
762
+ onFullscreen={onFullscreen}
763
+ onWidget={onWidget}
764
+ />
765
+
766
+ <div
767
+ className={`flex-1 min-h-0${hasExplicitWidth ? ' @container' : ''}${contentPadding ? ' pb-2' : ''}`}
768
+ style={{ '--app-content-max-height': `${maxContentHeight}px` } as React.CSSProperties}
769
+ >
770
+ {children}
771
+ </div>
772
+
773
+ {resizable ? (
774
+ <>
775
+ <div
776
+ className="absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-10"
777
+ data-resize-handle="nw"
778
+ style={{ touchAction: 'none' }}
779
+ onPointerDown={(event) => handleResizeStart(event, 'nw')}
780
+ />
781
+ <div
782
+ className="absolute top-0 right-0 w-3 h-3 cursor-nesw-resize z-10"
783
+ data-resize-handle="ne"
784
+ style={{ touchAction: 'none' }}
785
+ onPointerDown={(event) => handleResizeStart(event, 'ne')}
786
+ />
787
+ <div
788
+ className="absolute bottom-0 left-0 w-3 h-3 cursor-nesw-resize z-10"
789
+ data-resize-handle="sw"
790
+ style={{ touchAction: 'none' }}
791
+ onPointerDown={(event) => handleResizeStart(event, 'sw')}
792
+ />
793
+ <div
794
+ className="absolute bottom-0 right-0 w-3 h-3 cursor-nwse-resize z-10"
795
+ data-resize-handle="se"
796
+ style={{ touchAction: 'none' }}
797
+ onPointerDown={(event) => handleResizeStart(event, 'se')}
798
+ />
799
+ <div
800
+ className="absolute top-0 left-3 right-3 h-2 cursor-ns-resize z-10"
801
+ data-resize-handle="n"
802
+ style={{ touchAction: 'none' }}
803
+ onPointerDown={(event) => handleResizeStart(event, 'n')}
804
+ />
805
+ <div
806
+ className="absolute bottom-0 left-3 right-3 h-2 cursor-ns-resize z-10"
807
+ data-resize-handle="s"
808
+ style={{ touchAction: 'none' }}
809
+ onPointerDown={(event) => handleResizeStart(event, 's')}
810
+ />
811
+ <div
812
+ className="absolute left-0 top-3 bottom-3 w-2 cursor-ew-resize z-10"
813
+ data-resize-handle="w"
814
+ style={{ touchAction: 'none' }}
815
+ onPointerDown={(event) => handleResizeStart(event, 'w')}
816
+ />
817
+ <div
818
+ className="absolute right-0 top-3 bottom-3 w-2 cursor-ew-resize z-10"
819
+ data-resize-handle="e"
820
+ style={{ touchAction: 'none' }}
821
+ onPointerDown={(event) => handleResizeStart(event, 'e')}
822
+ />
823
+ </>
824
+ ) : null}
825
+ </div>
826
+ </Draggable>
827
+ );
828
+ }
829
+
830
+ export default AppWindow;