devnonla-ui 0.1.0

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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/package.json +66 -0
  4. package/src/alert/Alert.tsx +109 -0
  5. package/src/app/App.tsx +36 -0
  6. package/src/button/Button.tsx +278 -0
  7. package/src/button/ButtonCopy.tsx +60 -0
  8. package/src/calendar/Calendar.tsx +133 -0
  9. package/src/chat/AgentPanel.tsx +203 -0
  10. package/src/chat/common/bgTasks.ts +41 -0
  11. package/src/chat/common/sse.ts +156 -0
  12. package/src/chat/common/types.ts +43 -0
  13. package/src/chat/common/useAgentStream.ts +351 -0
  14. package/src/chat/common/utils.ts +73 -0
  15. package/src/chat/index.ts +56 -0
  16. package/src/chat/message-ui/ChatAgentMessage.tsx +29 -0
  17. package/src/chat/message-ui/ChatError.tsx +24 -0
  18. package/src/chat/message-ui/ChatInput.tsx +162 -0
  19. package/src/chat/message-ui/ChatMarkdown.tsx +128 -0
  20. package/src/chat/message-ui/ChatMarkdownTable.tsx +45 -0
  21. package/src/chat/message-ui/ChatSpinner.tsx +5 -0
  22. package/src/chat/message-ui/ChatThinking.tsx +61 -0
  23. package/src/chat/message-ui/ChatUserMessage.tsx +72 -0
  24. package/src/chat/message-ui/ChatWelcome.tsx +70 -0
  25. package/src/chat/message-ui/MermaidBlock.tsx +266 -0
  26. package/src/chat/message-ui/mermaidFence.ts +55 -0
  27. package/src/chat/message-ui/sanitizeMermaid.ts +135 -0
  28. package/src/chat/tool-ui/BackgroundTaskToolUI.tsx +36 -0
  29. package/src/chat/tool-ui/BackgroundTasksBar.tsx +152 -0
  30. package/src/chat/tool-ui/CallAgentToolUI.tsx +168 -0
  31. package/src/chat/tool-ui/ChatToolCall.tsx +77 -0
  32. package/src/chat/tool-ui/GetCurrentTimeToolUI.tsx +49 -0
  33. package/src/chat/tool-ui/ReadSkillToolUI.tsx +80 -0
  34. package/src/chat/tool-ui/RunJsToolUI.tsx +106 -0
  35. package/src/chat/tool-ui/ToolUiBadge.tsx +10 -0
  36. package/src/chat/tool-ui/ToolUiTrailing.tsx +24 -0
  37. package/src/chat/tool-ui/WebFetchToolUI.tsx +53 -0
  38. package/src/chat/tool-ui/registry.ts +29 -0
  39. package/src/chat/tool-ui/types.ts +22 -0
  40. package/src/checkbox/Checkbox.tsx +50 -0
  41. package/src/codeblock/CodeBlock.tsx +76 -0
  42. package/src/codeblock/highlight.ts +159 -0
  43. package/src/colorpicker/ColorPicker.tsx +45 -0
  44. package/src/datepicker/DatePicker.tsx +481 -0
  45. package/src/desktop/DesktopHeader.tsx +92 -0
  46. package/src/desktop/DesktopIcon.tsx +34 -0
  47. package/src/desktop/DesktopStage.tsx +6 -0
  48. package/src/desktop/DesktopWindow.tsx +504 -0
  49. package/src/desktop/MeadowDesktop.tsx +15 -0
  50. package/src/desktop/MeadowShell.tsx +12 -0
  51. package/src/desktop/MeadowWallpaper.tsx +5 -0
  52. package/src/desktop/assets/desktop-meadow.jpg +0 -0
  53. package/src/desktop/assets.d.ts +9 -0
  54. package/src/desktop/surface.ts +1 -0
  55. package/src/drawer/Drawer.tsx +125 -0
  56. package/src/dropdown/ContextMenu.tsx +97 -0
  57. package/src/dropdown/Dropdown.tsx +139 -0
  58. package/src/dropdown/menuClasses.ts +9 -0
  59. package/src/empty/Empty.tsx +47 -0
  60. package/src/form/Form.tsx +95 -0
  61. package/src/form/FormItem.tsx +145 -0
  62. package/src/form/common/context.ts +13 -0
  63. package/src/form/common/enum.ts +20 -0
  64. package/src/form/common/rules.ts +68 -0
  65. package/src/form/common/types.ts +169 -0
  66. package/src/form/index.ts +35 -0
  67. package/src/form/partials/FormItemHelps.tsx +31 -0
  68. package/src/form/variants/FieldCheckbox.tsx +20 -0
  69. package/src/form/variants/FieldColor.tsx +63 -0
  70. package/src/form/variants/FieldDateTime.tsx +25 -0
  71. package/src/form/variants/FieldHidden.tsx +9 -0
  72. package/src/form/variants/FieldInput.tsx +24 -0
  73. package/src/form/variants/FieldNumber.tsx +28 -0
  74. package/src/form/variants/FieldObject.tsx +29 -0
  75. package/src/form/variants/FieldRadio.tsx +40 -0
  76. package/src/form/variants/FieldRepeater.tsx +62 -0
  77. package/src/form/variants/FieldRepeaterItem.tsx +64 -0
  78. package/src/form/variants/FieldSelect.tsx +27 -0
  79. package/src/form/variants/FieldSelectMultiple.tsx +32 -0
  80. package/src/form/variants/FieldSelectRemote.tsx +66 -0
  81. package/src/form/variants/FieldSwitch.tsx +17 -0
  82. package/src/form/variants/FieldTextarea.tsx +25 -0
  83. package/src/form/variants/FieldTime.tsx +25 -0
  84. package/src/form-layout/FormLayout.tsx +74 -0
  85. package/src/icon/FluentIcon.tsx +28 -0
  86. package/src/icon/fluent.ts +75 -0
  87. package/src/index.ts +214 -0
  88. package/src/input/Input.tsx +371 -0
  89. package/src/input/SearchInput.tsx +73 -0
  90. package/src/lib/cn.ts +6 -0
  91. package/src/lib/placement.ts +44 -0
  92. package/src/lib/sizes.ts +117 -0
  93. package/src/lib/surface.ts +8 -0
  94. package/src/menu/Menu.tsx +98 -0
  95. package/src/message/message.tsx +214 -0
  96. package/src/modal/Modal.tsx +277 -0
  97. package/src/pagination/Pagination.tsx +103 -0
  98. package/src/popconfirm/Popconfirm.tsx +134 -0
  99. package/src/popover/Popover.tsx +116 -0
  100. package/src/scroll/OverlayScroll.tsx +141 -0
  101. package/src/segmented/Segmented.tsx +67 -0
  102. package/src/select/Select.tsx +319 -0
  103. package/src/skeleton/Skeleton.tsx +47 -0
  104. package/src/spin/Spin.tsx +160 -0
  105. package/src/styles.css +897 -0
  106. package/src/switch/Switch.tsx +58 -0
  107. package/src/table/Table.tsx +387 -0
  108. package/src/tag/Tag.tsx +109 -0
  109. package/src/theme.ts +131 -0
  110. package/src/timepicker/TimePicker.tsx +751 -0
  111. package/src/tooltip/Tooltip.tsx +59 -0
@@ -0,0 +1,504 @@
1
+ import { type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { cn } from "../lib/cn";
4
+ import { glassSurfaceClass } from "../lib/surface";
5
+
6
+ type Phase = "open" | "leaving";
7
+ type Size = { w: number; h: number };
8
+ type Point = { x: number; y: number };
9
+ type Rect = { x: number; y: number; w: number; h: number };
10
+ type Edge = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
11
+
12
+ const EASE = "var(--nonla-ease-out, cubic-bezier(0.16, 1, 0.3, 1))";
13
+ const MIN_W = 400;
14
+ const MIN_H = 240;
15
+
16
+ const HANDLES: { edge: Edge; className: string; cursor: string }[] = [
17
+ { edge: "n", className: "-top-1 right-3 left-3 h-2 cursor-n-resize", cursor: "n-resize" },
18
+ { edge: "s", className: "-bottom-1 right-3 left-3 h-2 cursor-s-resize", cursor: "s-resize" },
19
+ { edge: "e", className: "-right-1.5 top-3 bottom-3 w-1.5 cursor-e-resize", cursor: "e-resize" },
20
+ { edge: "w", className: "-left-1.5 top-3 bottom-3 w-1.5 cursor-w-resize", cursor: "w-resize" },
21
+ { edge: "ne", className: "-top-1 -right-1.5 size-3 cursor-nesw-resize", cursor: "nesw-resize" },
22
+ { edge: "nw", className: "-top-1 -left-1.5 size-3 cursor-nwse-resize", cursor: "nwse-resize" },
23
+ { edge: "se", className: "-bottom-1 -right-1.5 size-3 cursor-nwse-resize", cursor: "nwse-resize" },
24
+ { edge: "sw", className: "-bottom-1 -left-1.5 size-3 cursor-nesw-resize", cursor: "nesw-resize" },
25
+ ];
26
+
27
+ function prefersReducedMotion() {
28
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
29
+ }
30
+
31
+ function isEditableTarget(target: EventTarget | null) {
32
+ if (!(target instanceof HTMLElement)) return false;
33
+ if (target.isContentEditable) return true;
34
+ const tag = target.tagName;
35
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
36
+ return Boolean(target.closest(".monaco-editor"));
37
+ }
38
+
39
+ function originFromActiveIcon(overlay: HTMLElement, frame: HTMLElement) {
40
+ const source = document.querySelector('[aria-current="true"]');
41
+ if (!(source instanceof HTMLElement)) return "50% 50%";
42
+
43
+ const icon = source.getBoundingClientRect();
44
+ const overlayBox = overlay.getBoundingClientRect();
45
+ const x = icon.left + icon.width / 2 - overlayBox.left - frame.offsetLeft;
46
+ const y = icon.top + icon.height / 2 - overlayBox.top - frame.offsetTop;
47
+ return `${x}px ${y}px`;
48
+ }
49
+
50
+ function compactSize(view: Size): Size {
51
+ return {
52
+ w: Math.min(window.innerWidth * 0.8, view.w),
53
+ h: Math.max(0, view.h - 80),
54
+ };
55
+ }
56
+
57
+ function clampSize(view: Size, size: Size): Size {
58
+ return {
59
+ w: Math.min(view.w, Math.max(Math.min(MIN_W, view.w), size.w)),
60
+ h: Math.min(view.h, Math.max(Math.min(MIN_H, view.h), size.h)),
61
+ };
62
+ }
63
+
64
+ function windowSize(view: Size, size: Size | null): Size {
65
+ return size ? clampSize(view, size) : compactSize(view);
66
+ }
67
+
68
+ function clampOffset(view: Size, compact: Size, next: Point) {
69
+ const maxX = Math.max(0, (view.w - compact.w) / 2);
70
+ const maxY = Math.max(0, (view.h - compact.h) / 2);
71
+ return {
72
+ x: Math.min(maxX, Math.max(-maxX, next.x)),
73
+ y: Math.min(maxY, Math.max(-maxY, next.y)),
74
+ };
75
+ }
76
+
77
+ function collapsedRect(view: Size, offset: Point, size: Size | null): Rect {
78
+ const compact = windowSize(view, size);
79
+ const clamped = clampOffset(view, compact, offset);
80
+ return {
81
+ x: (view.w - compact.w) / 2 + clamped.x,
82
+ y: (view.h - compact.h) / 2 + clamped.y,
83
+ w: compact.w,
84
+ h: compact.h,
85
+ };
86
+ }
87
+
88
+ function applyResize(view: Size, orig: Rect, edge: Edge, dx: number, dy: number): Rect {
89
+ let w = orig.w;
90
+ let h = orig.h;
91
+ if (edge.includes("e")) w = orig.w + dx;
92
+ if (edge.includes("s")) h = orig.h + dy;
93
+ if (edge.includes("w")) w = orig.w - dx;
94
+ if (edge.includes("n")) h = orig.h - dy;
95
+
96
+ const minW = Math.min(MIN_W, view.w);
97
+ const minH = Math.min(MIN_H, view.h);
98
+ w = Math.min(view.w, Math.max(minW, w));
99
+ h = Math.min(view.h, Math.max(minH, h));
100
+
101
+ let x = orig.x;
102
+ let y = orig.y;
103
+ if (edge.includes("w")) {
104
+ x = orig.x + orig.w - w;
105
+ if (x < 0) {
106
+ w += x;
107
+ x = 0;
108
+ }
109
+ } else if (edge.includes("e")) {
110
+ if (x + w > view.w) w = view.w - x;
111
+ } else {
112
+ x = Math.min(Math.max(0, orig.x), Math.max(0, view.w - w));
113
+ }
114
+
115
+ if (edge.includes("n")) {
116
+ y = orig.y + orig.h - h;
117
+ if (y < 0) {
118
+ h += y;
119
+ y = 0;
120
+ }
121
+ } else if (edge.includes("s")) {
122
+ if (y + h > view.h) h = view.h - y;
123
+ } else {
124
+ y = Math.min(Math.max(0, orig.y), Math.max(0, view.h - h));
125
+ }
126
+
127
+ return { x, y, w, h };
128
+ }
129
+
130
+ function offsetFromRect(view: Size, rect: Rect): Point {
131
+ return {
132
+ x: rect.x - (view.w - rect.w) / 2,
133
+ y: rect.y - (view.h - rect.h) / 2,
134
+ };
135
+ }
136
+
137
+ function lockResizeCursor(cursor: string | null) {
138
+ document.body.style.cursor = cursor ?? "";
139
+ document.body.style.userSelect = cursor ? "none" : "";
140
+ }
141
+
142
+ function GlyphClose() {
143
+ return (
144
+ <svg width="7" height="7" viewBox="0 0 6 6" aria-hidden className="block">
145
+ <path d="M1.1 1.1l3.8 3.8M4.9 1.1L1.1 4.9" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
146
+ </svg>
147
+ );
148
+ }
149
+
150
+ function GlyphExpand({ restore }: { restore?: boolean }) {
151
+ if (restore) {
152
+ return (
153
+ <svg width="7" height="7" viewBox="0 0 6 6" aria-hidden className="block">
154
+ <path d="M1.1 2.4V.9H2.6M4.9 3.6v1.5H3.4" fill="none" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" strokeLinejoin="round" />
155
+ </svg>
156
+ );
157
+ }
158
+ return (
159
+ <svg width="7" height="7" viewBox="0 0 6 6" aria-hidden className="block">
160
+ <path d="M.8 3.4V.8H3.4zM5.2 2.6v2.6H2.6z" fill="currentColor" />
161
+ </svg>
162
+ );
163
+ }
164
+
165
+ function TrafficLight({
166
+ label,
167
+ onClick,
168
+ shortcut,
169
+ tone,
170
+ children,
171
+ }: {
172
+ label: string;
173
+ onClick: () => void;
174
+ shortcut?: string;
175
+ tone: "close" | "expand";
176
+ children: ReactNode;
177
+ }) {
178
+ return (
179
+ <button
180
+ type="button"
181
+ aria-label={label}
182
+ aria-keyshortcuts={shortcut}
183
+ onClick={onClick}
184
+ className={`inline-flex size-3.5 shrink-0 cursor-pointer appearance-none items-center justify-center rounded-full border-0 p-0 outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand ${tone === "close" ? "bg-[#ff5f57] text-[#7a1210]" : "bg-[#28c840] text-[#0d5c18]"}`}
185
+ >
186
+ <span className="flex opacity-0 transition-opacity duration-75 group-hover/traffic:opacity-100 group-focus-within/traffic:opacity-100">{children}</span>
187
+ </button>
188
+ );
189
+ }
190
+
191
+ type WindowHeaderSlot = {
192
+ slot: HTMLElement | null;
193
+ setCustom: (on: boolean) => void;
194
+ };
195
+
196
+ const WindowHeaderSlotContext = createContext<WindowHeaderSlot | null>(null);
197
+
198
+ export function WindowHeader({ children }: { children: ReactNode }) {
199
+ const ctx = useContext(WindowHeaderSlotContext);
200
+ useLayoutEffect(() => {
201
+ if (!ctx) return;
202
+ ctx.setCustom(true);
203
+ return () => ctx.setCustom(false);
204
+ }, [ctx]);
205
+ if (!ctx?.slot) return null;
206
+ return createPortal(children, ctx.slot);
207
+ }
208
+
209
+ export function DesktopWindow({
210
+ title,
211
+ expanded,
212
+ onClose,
213
+ onToggleExpand,
214
+ children,
215
+ }: {
216
+ title?: ReactNode;
217
+ expanded: boolean;
218
+ onClose: () => void;
219
+ onToggleExpand: () => void;
220
+ children: ReactNode;
221
+ }) {
222
+ const overlayRef = useRef<HTMLDivElement>(null);
223
+ const frameRef = useRef<HTMLElement>(null);
224
+ const closedRef = useRef(false);
225
+ const expandedRef = useRef(expanded);
226
+ const dragRef = useRef<{
227
+ pointerId: number;
228
+ startX: number;
229
+ startY: number;
230
+ origX: number;
231
+ origY: number;
232
+ } | null>(null);
233
+ const resizeRef = useRef<{
234
+ pointerId: number;
235
+ edge: Edge;
236
+ startX: number;
237
+ startY: number;
238
+ orig: Rect;
239
+ } | null>(null);
240
+ const [phase, setPhase] = useState<Phase>("open");
241
+ const [offset, setOffset] = useState({ x: 0, y: 0 });
242
+ const [size, setSize] = useState<Size | null>(null);
243
+ const [dragging, setDragging] = useState(false);
244
+ const [resizing, setResizing] = useState(false);
245
+ const sizeRef = useRef<Size | null>(null);
246
+ const [view, setView] = useState<Size>(() => ({
247
+ w: window.innerWidth,
248
+ h: Math.max(0, window.innerHeight - 42),
249
+ }));
250
+ const [headerSlot, setHeaderSlot] = useState<HTMLDivElement | null>(null);
251
+ const [customHeader, setCustomHeader] = useState(false);
252
+ const headerChrome = useMemo(() => ({ slot: headerSlot, setCustom: setCustomHeader }), [headerSlot]);
253
+ const requestCloseRef = useRef<() => void>(() => {});
254
+ const viewRef = useRef(view);
255
+ const offsetRef = useRef(offset);
256
+ const draggingRef = useRef(false);
257
+ const gestureCleanupRef = useRef<(() => void) | null>(null);
258
+ expandedRef.current = expanded;
259
+ sizeRef.current = size;
260
+ viewRef.current = view;
261
+ offsetRef.current = offset;
262
+
263
+ useLayoutEffect(() => {
264
+ const overlay = overlayRef.current;
265
+ const frame = frameRef.current;
266
+ if (overlay && frame) {
267
+ frame.style.transformOrigin = originFromActiveIcon(overlay, frame);
268
+ }
269
+ }, []);
270
+
271
+ const requestClose = () => {
272
+ if (closedRef.current || phase === "leaving") return;
273
+ if (prefersReducedMotion()) {
274
+ closedRef.current = true;
275
+ onClose();
276
+ return;
277
+ }
278
+ setPhase("leaving");
279
+ };
280
+ requestCloseRef.current = requestClose;
281
+
282
+ useEffect(() => {
283
+ if (phase !== "leaving") return;
284
+ const t = window.setTimeout(() => {
285
+ if (closedRef.current) return;
286
+ closedRef.current = true;
287
+ onClose();
288
+ }, 280);
289
+ return () => window.clearTimeout(t);
290
+ }, [phase, onClose]);
291
+
292
+ useLayoutEffect(() => {
293
+ const overlay = overlayRef.current;
294
+ if (!overlay) return;
295
+
296
+ const sync = () => {
297
+ const next = { w: overlay.clientWidth, h: overlay.clientHeight };
298
+ setView((prev) => (prev.w === next.w && prev.h === next.h ? prev : next));
299
+ if (expandedRef.current) return;
300
+ const fitted = windowSize(next, sizeRef.current);
301
+ if (sizeRef.current) {
302
+ setSize((current) => {
303
+ if (!current || (current.w === fitted.w && current.h === fitted.h)) return current;
304
+ return fitted;
305
+ });
306
+ }
307
+ setOffset((current) => {
308
+ const clamped = clampOffset(next, fitted, current);
309
+ if (clamped.x === current.x && clamped.y === current.y) return current;
310
+ return clamped;
311
+ });
312
+ };
313
+
314
+ sync();
315
+ const observer = new ResizeObserver(sync);
316
+ observer.observe(overlay);
317
+ return () => observer.disconnect();
318
+ }, []);
319
+
320
+ useEffect(() => {
321
+ const onKey = (e: KeyboardEvent) => {
322
+ if (e.key !== "Escape" || isEditableTarget(e.target)) return;
323
+ e.preventDefault();
324
+ requestCloseRef.current();
325
+ };
326
+ window.addEventListener("keydown", onKey);
327
+ return () => {
328
+ window.removeEventListener("keydown", onKey);
329
+ gestureCleanupRef.current?.();
330
+ gestureCleanupRef.current = null;
331
+ lockResizeCursor(null);
332
+ };
333
+ }, []);
334
+
335
+ const listenPointer = (pointerId: number, onMove: (e: PointerEvent) => void) => {
336
+ gestureCleanupRef.current?.();
337
+
338
+ const move = (e: PointerEvent) => {
339
+ if (e.pointerId !== pointerId) return;
340
+ if (e.buttons === 0) {
341
+ up(e);
342
+ return;
343
+ }
344
+ onMove(e);
345
+ };
346
+
347
+ const up = (e: PointerEvent) => {
348
+ if (e.pointerId !== pointerId) return;
349
+ window.removeEventListener("pointermove", move, true);
350
+ window.removeEventListener("pointerup", up, true);
351
+ window.removeEventListener("pointercancel", up, true);
352
+ gestureCleanupRef.current = null;
353
+ dragRef.current = null;
354
+ resizeRef.current = null;
355
+ draggingRef.current = false;
356
+ setDragging(false);
357
+ setResizing(false);
358
+ lockResizeCursor(null);
359
+ };
360
+
361
+ window.addEventListener("pointermove", move, true);
362
+ window.addEventListener("pointerup", up, true);
363
+ window.addEventListener("pointercancel", up, true);
364
+ gestureCleanupRef.current = () => {
365
+ window.removeEventListener("pointermove", move, true);
366
+ window.removeEventListener("pointerup", up, true);
367
+ window.removeEventListener("pointercancel", up, true);
368
+ };
369
+ };
370
+
371
+ const startDrag = (e: ReactPointerEvent<HTMLElement>) => {
372
+ if (e.button !== 0 || expanded || phase === "leaving" || resizeRef.current) return;
373
+ if ((e.target as HTMLElement).closest("button, a, input, textarea, select, [role='button']")) return;
374
+
375
+ dragRef.current = {
376
+ pointerId: e.pointerId,
377
+ startX: e.clientX,
378
+ startY: e.clientY,
379
+ origX: offsetRef.current.x,
380
+ origY: offsetRef.current.y,
381
+ };
382
+
383
+ listenPointer(e.pointerId, (ev) => {
384
+ const drag = dragRef.current;
385
+ if (!drag) return;
386
+ const dx = ev.clientX - drag.startX;
387
+ const dy = ev.clientY - drag.startY;
388
+ if (!draggingRef.current && dx * dx + dy * dy < 16) return;
389
+ if (!draggingRef.current) {
390
+ draggingRef.current = true;
391
+ setDragging(true);
392
+ }
393
+ const nextView = viewRef.current;
394
+ setOffset(
395
+ clampOffset(nextView, windowSize(nextView, sizeRef.current), {
396
+ x: drag.origX + dx,
397
+ y: drag.origY + dy,
398
+ }),
399
+ );
400
+ });
401
+ };
402
+
403
+ const startResize = (edge: Edge, e: ReactPointerEvent<HTMLElement>) => {
404
+ if (e.button !== 0 || expanded || phase === "leaving") return;
405
+ e.stopPropagation();
406
+ e.preventDefault();
407
+
408
+ resizeRef.current = {
409
+ pointerId: e.pointerId,
410
+ edge,
411
+ startX: e.clientX,
412
+ startY: e.clientY,
413
+ orig: collapsedRect(viewRef.current, offsetRef.current, sizeRef.current),
414
+ };
415
+ setResizing(true);
416
+ lockResizeCursor(HANDLES.find((handle) => handle.edge === edge)?.cursor ?? "nwse-resize");
417
+
418
+ listenPointer(e.pointerId, (ev) => {
419
+ const gesture = resizeRef.current;
420
+ if (!gesture) return;
421
+ const nextView = viewRef.current;
422
+ const next = applyResize(nextView, gesture.orig, gesture.edge, ev.clientX - gesture.startX, ev.clientY - gesture.startY);
423
+ setSize({ w: next.w, h: next.h });
424
+ setOffset(offsetFromRect(nextView, next));
425
+ });
426
+ };
427
+
428
+ const onTitleBarDoubleClick = (e: ReactMouseEvent<HTMLElement>) => {
429
+ if (phase === "leaving") return;
430
+ if ((e.target as HTMLElement).closest("button, a, input, textarea, select, [role='button']")) return;
431
+ e.preventDefault();
432
+ dragRef.current = null;
433
+ setDragging(false);
434
+ onToggleExpand();
435
+ };
436
+
437
+ const visible = phase === "open";
438
+ const leaving = phase === "leaving";
439
+ const rect = expanded ? { x: 0, y: 0, w: view.w, h: view.h } : collapsedRect(view, offset, size);
440
+ const reduce = prefersReducedMotion();
441
+ const duration = reduce ? "150ms" : "300ms";
442
+ const geom = !dragging && !resizing && !leaving && !reduce ? `top ${duration} ${EASE}, left ${duration} ${EASE}, width ${duration} ${EASE}, height ${duration} ${EASE}` : "";
443
+ const fade = leaving && !reduce ? `opacity ${duration} ${EASE}, transform ${duration} ${EASE}` : "";
444
+
445
+ return (
446
+ <WindowHeaderSlotContext.Provider value={headerChrome}>
447
+ <div ref={overlayRef} className="pointer-events-none absolute inset-x-0 bottom-0 top-desktop-bar z-30">
448
+ <section
449
+ ref={frameRef}
450
+ aria-label={typeof title === "string" && title ? title : "Window"}
451
+ onTransitionEnd={(e) => {
452
+ if (e.target !== e.currentTarget) return;
453
+ if (phase !== "leaving") return;
454
+ if (e.propertyName !== "opacity" && e.propertyName !== "transform") return;
455
+ if (closedRef.current) return;
456
+ closedRef.current = true;
457
+ onClose();
458
+ }}
459
+ style={{
460
+ top: rect.y,
461
+ left: rect.x,
462
+ width: rect.w,
463
+ height: rect.h,
464
+ opacity: visible ? 1 : 0,
465
+ transform: visible ? "scale(1)" : "scale(0.18)",
466
+ transition: [geom, fade].filter(Boolean).join(", ") || undefined,
467
+ ["--glass" as string]: "color-mix(in srgb, white 72%, transparent)",
468
+ }}
469
+ data-expanded={expanded || undefined}
470
+ className={cn("absolute flex flex-col rounded-xl pointer-events-auto transform-gpu", glassSurfaceClass, leaving && "pointer-events-none")}
471
+ >
472
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl">
473
+ <header
474
+ onPointerDown={startDrag}
475
+ onDoubleClick={onTitleBarDoubleClick}
476
+ className="flex h-8 shrink-0 cursor-default items-center gap-4 border-0 border-b border-solid border-glass-border px-3 select-none touch-none"
477
+ >
478
+ <div className="group/traffic flex shrink-0 items-center gap-2">
479
+ <TrafficLight label="Close window" tone="close" onClick={requestClose} shortcut="Esc">
480
+ <GlyphClose />
481
+ </TrafficLight>
482
+ <TrafficLight label={expanded ? "Restore window" : "Expand window"} tone="expand" onClick={onToggleExpand} shortcut="Shift + ↑">
483
+ <GlyphExpand restore={expanded} />
484
+ </TrafficLight>
485
+ </div>
486
+ <div className="flex min-w-0 flex-1 items-center">
487
+ {customHeader ? null : typeof title === "string" ? <span className="min-w-0 truncate text-xs font-semibold leading-none text-foreground/90">{title}</span> : title}
488
+ <div ref={setHeaderSlot} className="contents" />
489
+ </div>
490
+ </header>
491
+
492
+ <div className="min-h-0 flex-1 overflow-hidden">{children}</div>
493
+ </div>
494
+
495
+ {!expanded && !leaving
496
+ ? HANDLES.map((handle) => (
497
+ <div key={handle.edge} aria-hidden onPointerDown={(e) => startResize(handle.edge, e)} className={cn("absolute z-10 touch-none", handle.className)} />
498
+ ))
499
+ : null}
500
+ </section>
501
+ </div>
502
+ </WindowHeaderSlotContext.Provider>
503
+ );
504
+ }
@@ -0,0 +1,15 @@
1
+ import type { ReactNode } from "react";
2
+ import { MeadowWallpaper } from "./MeadowWallpaper";
3
+
4
+ export function MeadowDesktop({ children, src }: { children?: ReactNode; src?: string }) {
5
+ return (
6
+ <div className="absolute inset-0 bg-meadow">
7
+ <MeadowWallpaper src={src} />
8
+ <div className="absolute inset-x-0 bottom-0 top-desktop-bar z-20">
9
+ <nav aria-label="Desktop" className="absolute inset-0 overflow-auto px-3 pt-8 pb-3">
10
+ <div className="flex h-full flex-col flex-wrap content-start gap-x-2 gap-y-3">{children}</div>
11
+ </nav>
12
+ </div>
13
+ </div>
14
+ );
15
+ }
@@ -0,0 +1,12 @@
1
+ import type { ReactNode } from "react";
2
+ import { DesktopStage } from "./DesktopStage";
3
+ import { MeadowWallpaper } from "./MeadowWallpaper";
4
+
5
+ export function MeadowShell({ children }: { children: ReactNode }) {
6
+ return (
7
+ <DesktopStage>
8
+ <MeadowWallpaper />
9
+ <div className="relative z-10 flex h-full items-center justify-center overflow-y-auto p-6">{children}</div>
10
+ </DesktopStage>
11
+ );
12
+ }
@@ -0,0 +1,5 @@
1
+ import meadowWallpaper from "./assets/desktop-meadow.jpg";
2
+
3
+ export function MeadowWallpaper({ src = meadowWallpaper }: { src?: string }) {
4
+ return <img src={src} alt="" draggable={false} className="absolute inset-0 size-full object-cover object-center pointer-events-none select-none" />;
5
+ }
@@ -0,0 +1,9 @@
1
+ declare module "*.jpg" {
2
+ const src: string;
3
+ export default src;
4
+ }
5
+
6
+ declare module "*.png" {
7
+ const src: string;
8
+ export default src;
9
+ }
@@ -0,0 +1 @@
1
+ export { glassOverlayClass, glassSurfaceClass, meadowSurfaceClass } from "../lib/surface";
@@ -0,0 +1,125 @@
1
+ import * as Dialog from "@radix-ui/react-dialog";
2
+ import { type AnimationEvent, type CSSProperties, type ReactNode, useEffect, useRef, useState } from "react";
3
+ import { cn } from "../lib/cn";
4
+ import { glassSurfaceClass } from "../lib/surface";
5
+
6
+ /** Matches `--nonla-dur-fast` exit animation; fallback if animationend is skipped. */
7
+ const EXIT_MS = 200;
8
+
9
+ export type DrawerPlacement = "right" | "left" | "top" | "bottom";
10
+
11
+ export type DrawerProps = {
12
+ open?: boolean;
13
+ visible?: boolean;
14
+ onClose?: () => void;
15
+ onOpenChange?: (open: boolean) => void;
16
+ title?: ReactNode;
17
+ extra?: ReactNode;
18
+ children?: ReactNode;
19
+ width?: number | string;
20
+ /** antd: width for left/right, height for top/bottom. */
21
+ size?: number | string;
22
+ placement?: DrawerPlacement;
23
+ destroyOnClose?: boolean;
24
+ destroyOnHidden?: boolean;
25
+ className?: string;
26
+ footer?: ReactNode;
27
+ closable?: boolean;
28
+ styles?: { body?: CSSProperties; header?: CSSProperties; footer?: CSSProperties; content?: CSSProperties };
29
+ };
30
+
31
+ export function Drawer({
32
+ open,
33
+ visible,
34
+ onClose,
35
+ onOpenChange,
36
+ title,
37
+ extra,
38
+ children,
39
+ width,
40
+ size,
41
+ placement = "right",
42
+ destroyOnClose,
43
+ destroyOnHidden,
44
+ className,
45
+ footer,
46
+ closable = true,
47
+ styles,
48
+ }: DrawerProps) {
49
+ const isOpen = open ?? visible ?? false;
50
+ const shouldDestroy = Boolean(destroyOnHidden ?? destroyOnClose);
51
+ const [present, setPresent] = useState(isOpen);
52
+ const wasOpenRef = useRef(isOpen);
53
+ const vertical = placement === "top" || placement === "bottom";
54
+ const dim = size ?? width ?? (vertical ? 378 : 378);
55
+
56
+ useEffect(() => {
57
+ if (isOpen) {
58
+ wasOpenRef.current = true;
59
+ setPresent(true);
60
+ return;
61
+ }
62
+ if (!shouldDestroy || !wasOpenRef.current) return;
63
+ wasOpenRef.current = false;
64
+ const t = window.setTimeout(() => setPresent(false), EXIT_MS);
65
+ return () => window.clearTimeout(t);
66
+ }, [isOpen, shouldDestroy]);
67
+
68
+ const showBody = !shouldDestroy || present;
69
+
70
+ const onContentAnimationEnd = (e: AnimationEvent<HTMLDivElement>) => {
71
+ if (e.target !== e.currentTarget) return;
72
+ if (!isOpen && shouldDestroy) setPresent(false);
73
+ };
74
+
75
+ return (
76
+ <Dialog.Root
77
+ open={isOpen}
78
+ onOpenChange={(next) => {
79
+ onOpenChange?.(next);
80
+ if (!next) onClose?.();
81
+ }}
82
+ >
83
+ <Dialog.Portal>
84
+ <Dialog.Overlay className="nonla-overlay z-[9980]" />
85
+ <Dialog.Content
86
+ data-side={placement}
87
+ className={cn(
88
+ "nonla-drawer-panel fixed z-[9981] flex flex-col outline-none",
89
+ glassSurfaceClass,
90
+ placement === "right" && "top-0 right-0 bottom-0 max-w-[100vw]",
91
+ placement === "left" && "top-0 left-0 bottom-0 max-w-[100vw]",
92
+ placement === "bottom" && "right-0 bottom-0 left-0 max-h-[100vh]",
93
+ placement === "top" && "top-0 right-0 left-0 max-h-[100vh]",
94
+ className,
95
+ )}
96
+ style={{
97
+ ...(vertical ? { height: dim, width: "100%" } : { width: dim }),
98
+ ...styles?.content,
99
+ }}
100
+ onAnimationEnd={onContentAnimationEnd}
101
+ >
102
+ {(title || closable || extra) && (
103
+ <div className="flex items-center justify-between gap-3 border-b border-border px-5 py-4" style={styles?.header}>
104
+ <Dialog.Title className="m-0 min-w-0 flex-1 text-base font-semibold">{title}</Dialog.Title>
105
+ {extra ? <div className="flex shrink-0 items-center gap-2">{extra}</div> : null}
106
+ {closable ? (
107
+ <Dialog.Close className="text-muted-foreground hover:text-foreground text-lg leading-none" aria-label="Close">
108
+ ×
109
+ </Dialog.Close>
110
+ ) : null}
111
+ </div>
112
+ )}
113
+ <div className="min-h-0 flex-1 overflow-auto px-5 py-4" style={styles?.body}>
114
+ {showBody ? children : null}
115
+ </div>
116
+ {footer != null ? (
117
+ <div className="border-t border-border px-5 py-3" style={styles?.footer}>
118
+ {footer}
119
+ </div>
120
+ ) : null}
121
+ </Dialog.Content>
122
+ </Dialog.Portal>
123
+ </Dialog.Root>
124
+ );
125
+ }