@mlola-ui/scene 1.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mlola
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # @mlola-ui/scene
2
+
3
+ Optional, lightweight spatial presentation for Mlola UI: perspective stages,
4
+ depth layers, card stacks, and mesh gradients. WebGL is not required, and a
5
+ scene effect always has a static fallback for reduced motion, reduced data, and
6
+ assistive technology.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install @mlola-ui/scene
12
+ ```
13
+
14
+ ## Use
15
+
16
+ ```tsx
17
+ import { CardStack3D, MeshGradient, Stage } from "@mlola-ui/scene";
18
+
19
+ <Stage depth={2}><CardStack3D cards={cards} /></Stage>;
20
+ ```
21
+
22
+ ## Exports
23
+
24
+ | Path | What it is |
25
+ | --- | --- |
26
+ | `@mlola-ui/scene` | `Stage`, `CardStack3D`, `MeshGradient`, `SpatialToggle` |
27
+ | `@mlola-ui/scene/scene.css` | the scene layer |
28
+
29
+ Requires React and ReactDOM 18 or newer as peers. Scene effects are
30
+ non-semantic and never the only way to reach content.
31
+
32
+ MIT licensed. Part of [Mlola UI](https://ui.mlola.com).
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@mlola-ui/scene",
3
+ "version": "1.0.0",
4
+ "description": "Optional lightweight spatial presentation for Mlola UI",
5
+ "main": "./src/index.ts",
6
+ "types": "./src/index.ts",
7
+ "sideEffects": [
8
+ "./src/scene.css"
9
+ ],
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "exports": {
14
+ ".": "./src/index.ts",
15
+ "./stage": "./src/stage.tsx",
16
+ "./primitives": "./src/primitives.tsx",
17
+ "./scene.css": "./src/scene.css"
18
+ },
19
+ "peerDependencies": {
20
+ "react": ">=18",
21
+ "react-dom": ">=18"
22
+ },
23
+ "license": "MIT",
24
+ "author": "Mlola",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/mlolahq/mlola-ui.git",
28
+ "directory": "packages/scene"
29
+ },
30
+ "homepage": "https://ui.mlola.com",
31
+ "bugs": {
32
+ "url": "https://github.com/mlolahq/mlola-ui/issues"
33
+ },
34
+ "keywords": [
35
+ "mlola",
36
+ "ui",
37
+ "3d",
38
+ "scene",
39
+ "perspective",
40
+ "css"
41
+ ],
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "type": "module"
46
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./primitives";
2
+ export * from "./spatial-toggle";
3
+ export * from "./stage";
@@ -0,0 +1,156 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { useReducedMotion } from "./use-reduced-motion";
5
+
6
+ export interface MeshGradientProps extends React.HTMLAttributes<HTMLDivElement> {
7
+ animated?: boolean;
8
+ blur?: number;
9
+ }
10
+
11
+ export const MeshGradient = React.forwardRef<HTMLDivElement, MeshGradientProps>(
12
+ ({ animated = true, blur = 50, style, ...props }, ref) => {
13
+ const reducedMotion = useReducedMotion();
14
+ const shouldAnimate = animated && !reducedMotion;
15
+ return (
16
+ <div
17
+ {...props}
18
+ ref={ref}
19
+ aria-hidden="true"
20
+ style={{
21
+ position: "absolute",
22
+ inset: 0,
23
+ pointerEvents: "none",
24
+ overflow: "hidden",
25
+ backgroundImage: `
26
+ radial-gradient(circle at 18% 25%, color-mix(in srgb, var(--ml-primary) 22%, transparent), transparent 55%),
27
+ radial-gradient(circle at 82% 70%, color-mix(in srgb, var(--ml-primary, var(--primary)) 14%, transparent), transparent 60%),
28
+ radial-gradient(circle at 50% 90%, color-mix(in srgb, var(--ml-accent, var(--accent)) 30%, transparent), transparent 70%)
29
+ `,
30
+ filter: `blur(${Math.max(0, Math.min(160, Number.isFinite(blur) ? blur : 50))}px)`,
31
+ animation: shouldAnimate ? "float-3d 14s ease-in-out infinite" : undefined,
32
+ ...style,
33
+ }}
34
+ />
35
+ );
36
+ }
37
+ );
38
+ MeshGradient.displayName = "MeshGradient";
39
+
40
+ export interface CardStack3DItem {
41
+ title: string;
42
+ subtitle: string;
43
+ tag: string;
44
+ }
45
+
46
+ export interface CardStack3DProps extends React.HTMLAttributes<HTMLDivElement> {
47
+ cards: CardStack3DItem[];
48
+ }
49
+
50
+ function joinClasses(...values: Array<string | false | null | undefined>) {
51
+ return values.filter(Boolean).join(" ");
52
+ }
53
+
54
+ export function CardStack3D({
55
+ cards,
56
+ className = "",
57
+ onPointerEnter,
58
+ onPointerLeave,
59
+ onFocus,
60
+ onBlur,
61
+ ...props
62
+ }: CardStack3DProps) {
63
+ const [active, setActive] = React.useState(false);
64
+ const reducedMotion = useReducedMotion();
65
+ return (
66
+ <div
67
+ {...props}
68
+ tabIndex={props.tabIndex ?? 0}
69
+ aria-label={props["aria-label"] ?? "Card stack"}
70
+ onPointerEnter={(event) => {
71
+ onPointerEnter?.(event);
72
+ if (event.pointerType !== "touch") setActive(true);
73
+ }}
74
+ onPointerLeave={(event) => {
75
+ onPointerLeave?.(event);
76
+ setActive(false);
77
+ }}
78
+ onFocus={(event) => {
79
+ onFocus?.(event);
80
+ setActive(true);
81
+ }}
82
+ onBlur={(event) => {
83
+ onBlur?.(event);
84
+ if (!event.currentTarget.contains(event.relatedTarget)) setActive(false);
85
+ }}
86
+ className={joinClasses("ml-scene-card-stack", className)}
87
+ >
88
+ {cards.slice(0, 3).map((card, index) => {
89
+ const layer = 2 - index;
90
+ const fanDown = active ? layer * 18 : layer * 6;
91
+ const fanScale = active ? 1 - layer * 0.03 : 1 - layer * 0.04;
92
+ const rotateZ = active ? (index - 1) * 2.5 : 0;
93
+ return (
94
+ <div
95
+ key={`${card.title}-${index}`}
96
+ className="ml-scene-card-stack-item"
97
+ style={{
98
+ transform: `translate3d(0, ${fanDown}px, ${index * 12}px) rotateZ(${rotateZ}deg) scale(${fanScale})`,
99
+ transformOrigin: "top center",
100
+ zIndex: index + 10,
101
+ boxShadow: active
102
+ ? "0 18px 38px -10px color-mix(in srgb, var(--foreground) 14%, transparent), 0 0 0 1px color-mix(in srgb, var(--border) 90%, transparent)"
103
+ : "0 8px 20px -6px color-mix(in srgb, var(--foreground) 8%, transparent), 0 0 0 1px color-mix(in srgb, var(--border) 70%, transparent)",
104
+ transition: reducedMotion
105
+ ? "none"
106
+ : "transform 300ms cubic-bezier(0.16, 1, 0.3, 1), box-shadow 300ms cubic-bezier(0.16, 1, 0.3, 1)",
107
+ }}
108
+ >
109
+ <div className="ml-scene-card-stack-header">
110
+ <span className="ml-scene-card-stack-tag">{card.tag}</span>
111
+ <span className="ml-scene-card-stack-index">0{index + 1}</span>
112
+ </div>
113
+ <div>
114
+ <p className="ml-scene-card-stack-title">{card.title}</p>
115
+ <p className="ml-scene-card-stack-subtitle">{card.subtitle}</p>
116
+ </div>
117
+ </div>
118
+ );
119
+ })}
120
+ </div>
121
+ );
122
+ }
123
+
124
+ export interface DeviceFrame3DProps extends React.HTMLAttributes<HTMLDivElement> {
125
+ tilt?: boolean;
126
+ }
127
+
128
+ export function DeviceFrame3D({
129
+ children,
130
+ className = "",
131
+ tilt = true,
132
+ style,
133
+ ...props
134
+ }: DeviceFrame3DProps) {
135
+ const reducedMotion = useReducedMotion();
136
+ return (
137
+ <div
138
+ {...props}
139
+ className={joinClasses("ml-scene-device-frame", className)}
140
+ style={{
141
+ background: "linear-gradient(145deg, oklch(35% 0.02 260), oklch(18% 0.015 260))",
142
+ boxShadow: "0 30px 70px -15px oklch(0% 0 0 / 0.5), inset 0 1px 2px oklch(100% 0 0 / 0.2)",
143
+ transform: tilt && !reducedMotion ? "rotateX(1deg) rotateY(-2deg)" : undefined,
144
+ transformStyle: "preserve-3d",
145
+ ...style,
146
+ }}
147
+ >
148
+ <div className="ml-scene-device-screen">
149
+ <div className="ml-scene-device-notch">
150
+ <span className="ml-scene-device-notch-dot" aria-hidden="true" />
151
+ </div>
152
+ <div className="ml-scene-device-content">{children}</div>
153
+ </div>
154
+ </div>
155
+ );
156
+ }
package/src/scene.css ADDED
@@ -0,0 +1,140 @@
1
+ @layer mlola.scene {
2
+ .ml-scene-card-stack {
3
+ position: relative;
4
+ width: 100%;
5
+ height: 11rem;
6
+ cursor: pointer;
7
+ perspective: 1000px;
8
+ user-select: none;
9
+ }
10
+
11
+ .ml-scene-card-stack-item {
12
+ position: absolute;
13
+ inset-inline: 0;
14
+ top: 0;
15
+ display: flex;
16
+ flex-direction: column;
17
+ justify-content: space-between;
18
+ height: 9rem;
19
+ padding: 1.25rem;
20
+ border: 1px solid color-mix(in srgb, var(--border) 90%, transparent);
21
+ border-radius: var(--ml-radius-lg, 1rem);
22
+ background: var(--card);
23
+ }
24
+
25
+ .ml-scene-card-stack-header {
26
+ display: flex;
27
+ align-items: center;
28
+ justify-content: space-between;
29
+ }
30
+
31
+ .ml-scene-card-stack-tag {
32
+ display: inline-flex;
33
+ align-items: center;
34
+ padding: 0.125rem 0.625rem;
35
+ border-radius: 9999px;
36
+ background: color-mix(in srgb, var(--signal) 15%, transparent);
37
+ color: var(--signal);
38
+ font-family: var(--ml-font-mono);
39
+ font-size: 10px;
40
+ font-weight: 600;
41
+ letter-spacing: 0.05em;
42
+ text-transform: uppercase;
43
+ }
44
+
45
+ .ml-scene-card-stack-index {
46
+ color: var(--muted-foreground);
47
+ font-family: var(--ml-font-mono);
48
+ font-size: 0.75rem;
49
+ }
50
+
51
+ .ml-scene-card-stack-title {
52
+ color: var(--foreground);
53
+ font-size: 0.875rem;
54
+ font-weight: 700;
55
+ letter-spacing: -0.025em;
56
+ }
57
+
58
+ .ml-scene-card-stack-subtitle {
59
+ margin-top: 0.125rem;
60
+ color: var(--muted-foreground);
61
+ font-size: 0.75rem;
62
+ }
63
+
64
+ .ml-scene-device-frame {
65
+ position: relative;
66
+ margin-inline: auto;
67
+ padding: 0.75rem;
68
+ border-radius: 38px;
69
+ perspective: 1000px;
70
+ box-shadow: 0 25px 50px -12px oklch(0% 0 0 / 0.25);
71
+ }
72
+
73
+ .ml-scene-device-screen {
74
+ position: relative;
75
+ overflow: hidden;
76
+ border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
77
+ border-radius: 28px;
78
+ background: var(--background);
79
+ }
80
+
81
+ .ml-scene-device-notch {
82
+ display: flex;
83
+ align-items: center;
84
+ justify-content: center;
85
+ width: 7rem;
86
+ height: 1rem;
87
+ margin: 0.625rem auto;
88
+ border-radius: 9999px;
89
+ background: color-mix(in srgb, var(--foreground) 20%, transparent);
90
+ }
91
+
92
+ .ml-scene-device-notch-dot {
93
+ width: 0.5rem;
94
+ height: 0.5rem;
95
+ border-radius: 9999px;
96
+ background: color-mix(in srgb, var(--foreground) 40%, transparent);
97
+ }
98
+
99
+ .ml-scene-device-content {
100
+ padding: 1rem;
101
+ }
102
+
103
+ .ml-scene-spatial-toggle {
104
+ display: inline-flex;
105
+ align-items: center;
106
+ gap: 0.75rem;
107
+ user-select: none;
108
+ }
109
+
110
+ .ml-scene-spatial-switch {
111
+ position: relative;
112
+ width: 3rem;
113
+ height: 1.75rem;
114
+ padding: 0.25rem;
115
+ border: none;
116
+ border-radius: 9999px;
117
+ box-shadow: inset 0 2px 4px oklch(0% 0 0 / 0.25);
118
+ cursor: pointer;
119
+ }
120
+
121
+ .ml-scene-spatial-switch:disabled {
122
+ cursor: not-allowed;
123
+ opacity: 0.5;
124
+ }
125
+
126
+ .ml-scene-spatial-thumb {
127
+ display: block;
128
+ width: 1.25rem;
129
+ height: 1.25rem;
130
+ border-radius: 9999px;
131
+ background: oklch(1 0 0);
132
+ box-shadow: 0 2px 6px oklch(0% 0 0 / 0.3);
133
+ }
134
+
135
+ .ml-scene-spatial-label {
136
+ color: var(--foreground);
137
+ font-size: 0.75rem;
138
+ font-weight: 600;
139
+ }
140
+ }
@@ -0,0 +1,78 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { useReducedMotion } from "./use-reduced-motion";
5
+
6
+ export interface SpatialToggleProps {
7
+ checked?: boolean;
8
+ onChange?: (checked: boolean) => void;
9
+ label?: React.ReactNode;
10
+ className?: string;
11
+ disabled?: boolean;
12
+ id?: string;
13
+ name?: string;
14
+ }
15
+
16
+ function joinClasses(...values: Array<string | false | null | undefined>) {
17
+ return values.filter(Boolean).join(" ");
18
+ }
19
+
20
+ export const SpatialToggle = React.forwardRef<HTMLButtonElement, SpatialToggleProps>(
21
+ (
22
+ {
23
+ checked = false,
24
+ onChange,
25
+ label,
26
+ className = "",
27
+ disabled = false,
28
+ id,
29
+ name,
30
+ },
31
+ ref
32
+ ) => {
33
+ const reducedMotion = useReducedMotion();
34
+ return (
35
+ <span className={joinClasses("ml-scene-spatial-toggle", className)}>
36
+ <button
37
+ ref={ref}
38
+ id={id}
39
+ name={name}
40
+ type="button"
41
+ role="switch"
42
+ aria-checked={checked}
43
+ aria-label={typeof label === "string" ? label : undefined}
44
+ disabled={disabled}
45
+ onClick={() => onChange?.(!checked)}
46
+ className="ml-scene-spatial-switch"
47
+ style={{
48
+ background: checked
49
+ ? "var(--ml-primary)"
50
+ : "var(--ml-background-subtle)",
51
+ transition: reducedMotion ? "none" : "background 240ms ease",
52
+ }}
53
+ >
54
+ <span
55
+ aria-hidden="true"
56
+ className="ml-scene-spatial-thumb"
57
+ style={{
58
+ transform: checked ? "translate3d(20px, 0, 0)" : "translate3d(0, 0, 0)",
59
+ transition: reducedMotion
60
+ ? "none"
61
+ : "transform 300ms cubic-bezier(0.16, 1, 0.3, 1)",
62
+ }}
63
+ />
64
+ </button>
65
+ {label ? (
66
+ <span aria-hidden="true" className="ml-scene-spatial-label">
67
+ {label}
68
+ </span>
69
+ ) : null}
70
+ </span>
71
+ );
72
+ }
73
+ );
74
+ SpatialToggle.displayName = "SpatialToggle";
75
+
76
+ /** @deprecated Use SpatialToggle. Kept as a source-compatible scene export. */
77
+ export const Toggle3D = SpatialToggle;
78
+ export type Toggle3DProps = SpatialToggleProps;
package/src/stage.tsx ADDED
@@ -0,0 +1,154 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { useReducedMotion } from "./use-reduced-motion";
5
+
6
+ function finite(value: number, fallback: number): number {
7
+ return Number.isFinite(value) ? value : fallback;
8
+ }
9
+
10
+ export interface Stage3DProps extends React.HTMLAttributes<HTMLDivElement> {
11
+ perspective?: number;
12
+ interactive?: boolean;
13
+ maxRotationX?: number;
14
+ maxRotationY?: number;
15
+ }
16
+
17
+ export const Stage3D = React.forwardRef<HTMLDivElement, Stage3DProps>(
18
+ (
19
+ {
20
+ perspective = 1200,
21
+ interactive = true,
22
+ maxRotationX = 12,
23
+ maxRotationY = 14,
24
+ children,
25
+ style,
26
+ onPointerMove,
27
+ onPointerLeave,
28
+ onPointerCancel,
29
+ ...props
30
+ },
31
+ forwardedRef
32
+ ) => {
33
+ const reducedMotion = useReducedMotion();
34
+ const stageRef = React.useRef<HTMLDivElement>(null);
35
+ const contentRef = React.useRef<HTMLDivElement>(null);
36
+ const frameRef = React.useRef<number | null>(null);
37
+ const setRef = React.useCallback(
38
+ (node: HTMLDivElement | null) => {
39
+ stageRef.current = node;
40
+ if (typeof forwardedRef === "function") forwardedRef(node);
41
+ else if (forwardedRef) forwardedRef.current = node;
42
+ },
43
+ [forwardedRef]
44
+ );
45
+
46
+ const renderRotation = React.useCallback((x: number, y: number, active: boolean) => {
47
+ const content = contentRef.current;
48
+ if (!content) return;
49
+ if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
50
+ if (typeof requestAnimationFrame === "undefined") return;
51
+ frameRef.current = requestAnimationFrame(() => {
52
+ content.style.transform = `rotateX(${x.toFixed(2)}deg) rotateY(${y.toFixed(2)}deg)`;
53
+ content.style.transition = active
54
+ ? "transform 80ms linear"
55
+ : "transform 360ms cubic-bezier(0.16, 1, 0.3, 1)";
56
+ frameRef.current = null;
57
+ });
58
+ }, []);
59
+
60
+ React.useEffect(
61
+ () => () => {
62
+ if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
63
+ },
64
+ []
65
+ );
66
+
67
+ React.useEffect(() => {
68
+ if (reducedMotion || !interactive) renderRotation(0, 0, false);
69
+ }, [interactive, reducedMotion, renderRotation]);
70
+
71
+ const reset = () => renderRotation(0, 0, false);
72
+
73
+ return (
74
+ <div
75
+ {...props}
76
+ ref={setRef}
77
+ onPointerMove={(event) => {
78
+ onPointerMove?.(event);
79
+ const stage = stageRef.current;
80
+ if (
81
+ event.defaultPrevented ||
82
+ !interactive ||
83
+ reducedMotion ||
84
+ event.pointerType === "touch" ||
85
+ !stage
86
+ ) return;
87
+ const rect = stage.getBoundingClientRect();
88
+ if (rect.width <= 0 || rect.height <= 0) return;
89
+ const px = (event.clientX - rect.left) / rect.width - 0.5;
90
+ const py = (event.clientY - rect.top) / rect.height - 0.5;
91
+ renderRotation(
92
+ -py * finite(maxRotationX, 12),
93
+ px * finite(maxRotationY, 14),
94
+ true
95
+ );
96
+ }}
97
+ onPointerLeave={(event) => {
98
+ onPointerLeave?.(event);
99
+ reset();
100
+ }}
101
+ onPointerCancel={(event) => {
102
+ onPointerCancel?.(event);
103
+ reset();
104
+ }}
105
+ style={{
106
+ perspective: `${Math.max(1, finite(perspective, 1200))}px`,
107
+ transformStyle: "preserve-3d",
108
+ ...style,
109
+ }}
110
+ >
111
+ <div
112
+ ref={contentRef}
113
+ style={{
114
+ width: "100%",
115
+ height: "100%",
116
+ transform: "rotateX(0deg) rotateY(0deg)",
117
+ transformStyle: "preserve-3d",
118
+ transition: reducedMotion
119
+ ? "none"
120
+ : "transform 360ms cubic-bezier(0.16, 1, 0.3, 1)",
121
+ willChange: interactive && !reducedMotion ? "transform" : undefined,
122
+ }}
123
+ >
124
+ {children}
125
+ </div>
126
+ </div>
127
+ );
128
+ }
129
+ );
130
+ Stage3D.displayName = "Stage3D";
131
+
132
+ export interface FloatingLayerProps extends React.HTMLAttributes<HTMLDivElement> {
133
+ depth?: number;
134
+ }
135
+
136
+ export const FloatingLayer = React.forwardRef<HTMLDivElement, FloatingLayerProps>(
137
+ ({ depth = 30, children, style, ...props }, ref) => {
138
+ const safeDepth = Math.max(-1000, Math.min(1000, finite(depth, 30)));
139
+ return (
140
+ <div
141
+ {...props}
142
+ ref={ref}
143
+ style={{
144
+ transform: `translateZ(${safeDepth}px)`,
145
+ transformStyle: "preserve-3d",
146
+ ...style,
147
+ }}
148
+ >
149
+ {children}
150
+ </div>
151
+ );
152
+ }
153
+ );
154
+ FloatingLayer.displayName = "FloatingLayer";
@@ -0,0 +1,18 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ const query = "(prefers-reduced-motion: reduce)";
6
+
7
+ export function useReducedMotion(): boolean {
8
+ return React.useSyncExternalStore(
9
+ (listener) => {
10
+ if (typeof window === "undefined") return () => {};
11
+ const media = window.matchMedia(query);
12
+ media.addEventListener("change", listener);
13
+ return () => media.removeEventListener("change", listener);
14
+ },
15
+ () => typeof window !== "undefined" && window.matchMedia(query).matches,
16
+ () => false
17
+ );
18
+ }