@proto-labs-inc/rig-core 0.1.1 → 0.2.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.
@@ -0,0 +1,2 @@
1
+ /** Stable bootstrap only. Selection behavior is deployed with the Frame app. */
2
+ export declare function loadFrameTools(frameOrigin?: string): () => void;
@@ -0,0 +1,47 @@
1
+ /** Stable bootstrap only. Selection behavior is deployed with the Frame app. */
2
+ export function loadFrameTools(frameOrigin) {
3
+ if (window.parent === window)
4
+ return () => { };
5
+ let disposed = false;
6
+ let unmount;
7
+ const report = (status, origin = "*") => {
8
+ if (!disposed)
9
+ window.parent.postMessage({ type: "frame-tools-status", status }, origin);
10
+ };
11
+ const dispose = () => { disposed = true; unmount?.(); };
12
+ if (!frameOrigin) {
13
+ report("unavailable");
14
+ return dispose;
15
+ }
16
+ let origin;
17
+ try {
18
+ const url = new URL(frameOrigin);
19
+ const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
20
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && local))
21
+ throw new Error("Invalid Frame origin");
22
+ if (url.username || url.password || url.origin !== frameOrigin)
23
+ throw new Error("Expected an origin");
24
+ origin = url.origin;
25
+ // The origin is pinned in the prototype, never taken from a message or URL parameter.
26
+ if (!document.referrer || new URL(document.referrer).origin !== origin)
27
+ return dispose;
28
+ }
29
+ catch {
30
+ report("unavailable");
31
+ return dispose;
32
+ }
33
+ report("loading", origin);
34
+ const url = new URL("/frame-tools/v1.js", origin).href;
35
+ void import(/* @vite-ignore */ url).then((runtime) => {
36
+ if (disposed)
37
+ return;
38
+ unmount = runtime.mountFrameTools({ frameOrigin: origin });
39
+ report("ready", origin);
40
+ }).catch((error) => {
41
+ if (disposed)
42
+ return;
43
+ console.warn("Proto selection tools failed to load", error);
44
+ report("unavailable", origin);
45
+ });
46
+ return dispose;
47
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * The rig's framework-free core: URL-state sync, the bridge, and comment
3
- * mode. Everything here is plain DOM + history + postMessage — no framework.
2
+ * The rig's framework-free core: URL-state sync, the bridge, and the Frame tools
3
+ * loader. Everything here is plain DOM + history + postMessage — no framework.
4
4
  * Framework adapters (@proto-labs-inc/rig for React, @proto-labs-inc/rig-vue for Vue) wrap
5
5
  * this with their own idioms and nothing else.
6
6
  */
package/dist/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
- * The rig's framework-free core: URL-state sync, the bridge, and comment
3
- * mode. Everything here is plain DOM + history + postMessage — no framework.
2
+ * The rig's framework-free core: URL-state sync, the bridge, and the Frame tools
3
+ * loader. Everything here is plain DOM + history + postMessage — no framework.
4
4
  * Framework adapters (@proto-labs-inc/rig for React, @proto-labs-inc/rig-vue for Vue) wrap
5
5
  * this with their own idioms and nothing else.
6
6
  */
7
7
  import { PROTOCOL_VERSION, STATE_PARAM, variantParam, applyVariantSelection, isFrameToRig, } from "@proto-labs-inc/wire";
8
- import { ElementPreview } from "./element-preview.js";
8
+ import { loadFrameTools } from "./frame-tools-loader.js";
9
9
  export { STATE_PARAM, variantParam } from "@proto-labs-inc/wire";
10
- const RIG_VERSION = "0.1.1";
10
+ const RIG_VERSION = "0.2.0";
11
11
  export const INTERNAL_PREVIEW_THEME_PARAM = "__protoTheme";
12
12
  export function applyPreviewTheme(theme) {
13
13
  if (typeof document === "undefined")
@@ -131,48 +131,6 @@ export function postToFrame(msg) {
131
131
  if (typeof window !== "undefined" && window.parent !== window)
132
132
  window.parent.postMessage(msg, "*");
133
133
  }
134
- // ─── comment mode ──────────────────────────────────────────────────
135
- // The Frame can't touch this document (cross-origin iframe), so the blue
136
- // selector runs here: hit-test [data-proto-id] under the cursor, draw the
137
- // highlight, and on click capture the view with the highlight baked in.
138
- // The highlight is Proto blue (#0088FF): 2px solid at 55%, a 6% fill,
139
- // radius 4px, 0.12s ease-out entry; the picker layer sits at z-index 99997.
140
- const COMMENT_BLUE = "#0088FF";
141
- const TARGET_ATTR = "data-proto-id";
142
- function selectorFor(element) {
143
- if (element === document.documentElement)
144
- return "html";
145
- if (element === document.body)
146
- return "body";
147
- const protoId = element.getAttribute(TARGET_ATTR);
148
- if (protoId)
149
- return `[${TARGET_ATTR}="${CSS.escape(protoId)}"]`;
150
- if (element.id) {
151
- const idSelector = `#${CSS.escape(element.id)}`;
152
- if (document.querySelectorAll(idSelector).length === 1)
153
- return idSelector;
154
- }
155
- const owner = element.closest(`[${TARGET_ATTR}]`);
156
- const boundary = owner ?? document.body;
157
- const segments = [];
158
- let current = element;
159
- while (current && current !== boundary) {
160
- const tag = current.tagName.toLowerCase();
161
- const currentTagName = current.tagName;
162
- const siblings = current.parentElement
163
- ? [...current.parentElement.children].filter((sibling) => sibling.tagName === currentTagName)
164
- : [];
165
- const index = siblings.indexOf(current);
166
- segments.unshift(siblings.length > 1 && index >= 0 ? `${tag}:nth-of-type(${index + 1})` : tag);
167
- current = current.parentElement;
168
- }
169
- const base = owner
170
- ? `[${TARGET_ATTR}="${CSS.escape(owner.getAttribute(TARGET_ATTR) ?? "")}"]`
171
- : boundary === element
172
- ? element.tagName.toLowerCase()
173
- : "body";
174
- return segments.length > 0 ? `${base} > ${segments.join(" > ")}` : base;
175
- }
176
134
  async function waitForCaptureReady() {
177
135
  const pendingImages = [...document.images]
178
136
  .filter((image) => !image.complete)
@@ -190,47 +148,6 @@ async function waitForCaptureReady() {
190
148
  // resting state before the gallery captures them.
191
149
  await new Promise((resolve) => window.setTimeout(resolve, 1500));
192
150
  }
193
- async function captureViewport(scale = Math.min(window.devicePixelRatio || 1, 2)) {
194
- try {
195
- // Optional peer provided by the prototype, not the rig.
196
- const mod = (await import("modern-screenshot"));
197
- const root = document.body;
198
- const viewportWidth = document.documentElement.clientWidth;
199
- const viewportHeight = document.documentElement.clientHeight;
200
- const scrollX = window.scrollX || document.documentElement.scrollLeft;
201
- const scrollY = window.scrollY || document.documentElement.scrollTop;
202
- const contentWidth = Math.max(viewportWidth, document.documentElement.scrollWidth, root.scrollWidth);
203
- const contentHeight = Math.max(viewportHeight, document.documentElement.scrollHeight, root.scrollHeight);
204
- return await mod.domToDataUrl(root, {
205
- scale,
206
- width: viewportWidth,
207
- height: viewportHeight,
208
- features: { restoreScrollPosition: true },
209
- style: {
210
- margin: "0",
211
- width: `${contentWidth}px`,
212
- height: `${contentHeight}px`,
213
- transform: `translate(${-scrollX}px, ${-scrollY}px)`,
214
- transformOrigin: "top left",
215
- overflow: "visible",
216
- },
217
- onCloneNode: (cloned) => {
218
- if (!(cloned instanceof HTMLElement) || (!scrollX && !scrollY))
219
- return;
220
- const fixed = [cloned, ...cloned.querySelectorAll("*")].filter((element) => element.style.position === "fixed");
221
- for (const element of fixed) {
222
- const existing = element.style.transform;
223
- element.style.transform =
224
- `translate(${scrollX}px, ${scrollY}px)` +
225
- (existing && existing !== "none" ? ` ${existing}` : "");
226
- }
227
- },
228
- });
229
- }
230
- catch {
231
- return null;
232
- }
233
- }
234
151
  async function captureThumbnail() {
235
152
  const restingState = document.createElement("style");
236
153
  restingState.textContent = `
@@ -277,229 +194,13 @@ async function captureThumbnail() {
277
194
  restingState.remove();
278
195
  }
279
196
  }
280
- const elementPreview = new ElementPreview(postToFrame);
281
- class ElementPicker {
282
- overlay = null;
283
- highlight = null;
284
- cursorStyle = null;
285
- target = null;
286
- active = false;
287
- mode = null;
288
- enable(mode) {
289
- if (typeof document === "undefined")
290
- return;
291
- elementPreview.release();
292
- if (this.active || this.overlay)
293
- this.disable();
294
- this.active = true;
295
- this.mode = mode;
296
- this.cursorStyle = document.createElement("style");
297
- this.cursorStyle.setAttribute("data-proto-picker-cursor", "");
298
- this.cursorStyle.textContent =
299
- "html, body, body * { cursor: crosshair !important; }";
300
- document.head.appendChild(this.cursorStyle);
301
- const blue = COMMENT_BLUE;
302
- this.overlay = document.createElement("div");
303
- this.overlay.setAttribute("data-proto-rig-overlay", "");
304
- Object.assign(this.overlay.style, {
305
- position: "fixed",
306
- inset: "0",
307
- zIndex: "99997",
308
- pointerEvents: "none",
309
- });
310
- this.highlight = document.createElement("div");
311
- Object.assign(this.highlight.style, {
312
- position: "absolute",
313
- border: `2px solid color-mix(in srgb, ${blue} 55%, transparent)`,
314
- borderRadius: "4px",
315
- backgroundColor: `color-mix(in srgb, ${blue} 6%, transparent)`,
316
- pointerEvents: "none",
317
- boxSizing: "border-box",
318
- opacity: "0",
319
- transition: "opacity 0.12s ease-out",
320
- willChange: "opacity",
321
- });
322
- this.overlay.appendChild(this.highlight);
323
- document.body.appendChild(this.overlay);
324
- document.addEventListener("pointermove", this.onMove, true);
325
- document.addEventListener("click", this.onClick, true);
326
- document.addEventListener("keydown", this.onKey, true);
327
- }
328
- disable() {
329
- if (!this.active && !this.overlay && !this.cursorStyle)
330
- return;
331
- this.active = false;
332
- document.removeEventListener("pointermove", this.onMove, true);
333
- document.removeEventListener("click", this.onClick, true);
334
- document.removeEventListener("keydown", this.onKey, true);
335
- this.overlay?.remove();
336
- this.cursorStyle?.remove();
337
- this.overlay = null;
338
- this.highlight = null;
339
- this.cursorStyle = null;
340
- this.target = null;
341
- this.mode = null;
342
- }
343
- freezeSelection() {
344
- this.active = false;
345
- document.removeEventListener("pointermove", this.onMove, true);
346
- document.removeEventListener("click", this.onClick, true);
347
- document.removeEventListener("keydown", this.onKey, true);
348
- this.cursorStyle?.remove();
349
- this.cursorStyle = null;
350
- }
351
- onMove = (e) => {
352
- const hit = this.targetAt(e.clientX, e.clientY);
353
- if (hit === this.target)
354
- return;
355
- this.target = hit;
356
- if (!this.highlight)
357
- return;
358
- const rect = this.rectFor(hit);
359
- Object.assign(this.highlight.style, {
360
- left: `${rect.left}px`,
361
- top: `${rect.top}px`,
362
- width: `${rect.width}px`,
363
- height: `${rect.height}px`,
364
- opacity: "1",
365
- });
366
- };
367
- onClick = (e) => {
368
- e.preventDefault();
369
- e.stopPropagation();
370
- const clickedTarget = this.targetAt(e.clientX, e.clientY);
371
- this.target = clickedTarget;
372
- const targetId = clickedTarget?.getAttribute(TARGET_ATTR) ?? null;
373
- if (this.mode === "element") {
374
- const rect = this.rectFor(clickedTarget);
375
- const text = clickedTarget.textContent?.replace(/\s+/g, " ").trim() || null;
376
- const inspection = elementPreview.inspect(clickedTarget);
377
- this.disable();
378
- postToFrame({
379
- type: "element-target-picked",
380
- inspection,
381
- selector: selectorFor(clickedTarget),
382
- tagName: clickedTarget.tagName.toLowerCase(),
383
- text: text?.slice(0, 160) ?? null,
384
- anchor: {
385
- x: rect.left,
386
- y: rect.top,
387
- width: rect.width,
388
- height: rect.height,
389
- },
390
- });
391
- return;
392
- }
393
- if (this.mode === "variantSet") {
394
- if (!targetId)
395
- return;
396
- const rect = this.rectFor(clickedTarget);
397
- this.disable();
398
- postToFrame({
399
- type: "variant-set-target-picked",
400
- targetId,
401
- anchor: {
402
- x: rect.left,
403
- y: rect.top,
404
- width: rect.width,
405
- height: rect.height,
406
- },
407
- });
408
- return;
409
- }
410
- const rect = this.rectFor(clickedTarget);
411
- if (this.highlight) {
412
- Object.assign(this.highlight.style, {
413
- left: `${rect.left}px`,
414
- top: `${rect.top}px`,
415
- width: `${rect.width}px`,
416
- height: `${rect.height}px`,
417
- opacity: "1",
418
- });
419
- }
420
- this.freezeSelection();
421
- postToFrame({
422
- type: "comment-target-picked",
423
- targetId,
424
- anchor: {
425
- x: rect.left,
426
- y: rect.top,
427
- width: rect.width,
428
- height: rect.height,
429
- },
430
- });
431
- };
432
- targetAt(x, y) {
433
- const hit = document.elementFromPoint(x, y);
434
- if (this.mode === "element")
435
- return hit ?? document.body;
436
- return hit?.closest(`[${TARGET_ATTR}]`) ?? document.body;
437
- }
438
- rectFor(target) {
439
- if (target === document.body || target === document.documentElement) {
440
- return {
441
- left: 0,
442
- top: 0,
443
- width: document.documentElement.clientWidth,
444
- height: document.documentElement.clientHeight,
445
- };
446
- }
447
- return target.getBoundingClientRect();
448
- }
449
- onKey = (e) => {
450
- if (e.key !== "Escape")
451
- return;
452
- e.preventDefault();
453
- const mode = this.mode;
454
- this.disable();
455
- postToFrame({
456
- type: mode === "variantSet"
457
- ? "variant-set-picker-exited"
458
- : mode === "element"
459
- ? "element-picker-exited"
460
- : "comment-mode-exited",
461
- });
462
- };
463
- async captureSelection(requestId) {
464
- if (!this.target)
465
- return;
466
- const targetId = this.target.getAttribute(TARGET_ATTR);
467
- const rect = this.rectFor(this.target);
468
- const anchor = {
469
- x: rect.left,
470
- y: rect.top,
471
- width: rect.width,
472
- height: rect.height,
473
- };
474
- if (this.highlight) {
475
- Object.assign(this.highlight.style, {
476
- left: `${rect.left}px`,
477
- top: `${rect.top}px`,
478
- width: `${rect.width}px`,
479
- height: `${rect.height}px`,
480
- });
481
- }
482
- // The page is already interactive when the user picks a comment target.
483
- // Capture immediately: thumbnail readiness delays can consume the Frame's
484
- // request timeout on image-heavy prototypes.
485
- const image = await captureViewport();
486
- this.disable();
487
- postToFrame({
488
- type: "comment-capture",
489
- targetId,
490
- image,
491
- requestId,
492
- anchor,
493
- });
494
- }
495
- }
496
- const elementPicker = typeof window === "undefined" ? null : new ElementPicker();
497
197
  /**
498
198
  * Wire a store to the bridge: listen for Frame messages, announce the rig,
499
199
  * and report the boot-time state (the deep-link fix). Returns a disposer.
500
200
  * Call client-side only.
501
201
  */
502
202
  export function wireRig(store, manifest) {
203
+ const disposeTools = loadFrameTools(manifest.frameOrigin);
503
204
  let controlledTheme = false;
504
205
  const media = window.matchMedia("(prefers-color-scheme: dark)");
505
206
  const onSystemTheme = () => {
@@ -512,16 +213,6 @@ export function wireRig(store, manifest) {
512
213
  return;
513
214
  if (!isFrameToRig(e.data))
514
215
  return; // tolerate unknown messages silently
515
- if (e.data.type === "preview-element-styles") {
516
- elementPreview.preview(e.data.selectionId, e.data.styles);
517
- return;
518
- }
519
- if (e.data.type === "release-element") {
520
- elementPreview.release(e.data.selectionId);
521
- return;
522
- }
523
- if (["set-state", "set-variant", "set-theme"].includes(e.data.type))
524
- elementPreview.release();
525
216
  if (e.data.type === "set-state") {
526
217
  const state = manifest.states.find((s) => s.id === e.data.id);
527
218
  store.setState(e.data.id, state?.variants ?? {});
@@ -532,42 +223,6 @@ export function wireRig(store, manifest) {
532
223
  }
533
224
  else if (e.data.type === "set-variant")
534
225
  store.set(variantParam(e.data.component), e.data.variant);
535
- else if (e.data.type === "measure-target") {
536
- const target = Array.from(document.querySelectorAll(`[${TARGET_ATTR}]`)).find((candidate) => candidate.getAttribute(TARGET_ATTR) === e.data.id);
537
- const rect = target?.getBoundingClientRect();
538
- postToFrame({
539
- type: "target-measured",
540
- targetId: e.data.id,
541
- anchor: rect
542
- ? {
543
- x: rect.left,
544
- y: rect.top,
545
- width: rect.width,
546
- height: rect.height,
547
- }
548
- : null,
549
- });
550
- }
551
- else if (e.data.type === "set-comment-mode") {
552
- if (e.data.on)
553
- elementPicker?.enable("comment");
554
- else
555
- elementPicker?.disable();
556
- }
557
- else if (e.data.type === "capture-comment")
558
- void elementPicker?.captureSelection(e.data.requestId);
559
- else if (e.data.type === "set-variant-set-picker") {
560
- if (e.data.on)
561
- elementPicker?.enable("variantSet");
562
- else
563
- elementPicker?.disable();
564
- }
565
- else if (e.data.type === "set-element-picker") {
566
- if (e.data.on)
567
- elementPicker?.enable("element");
568
- else
569
- elementPicker?.disable();
570
- }
571
226
  };
572
227
  window.addEventListener("message", onMessage);
573
228
  postToFrame({
@@ -590,7 +245,6 @@ export function wireRig(store, manifest) {
590
245
  return () => {
591
246
  media.removeEventListener("change", onSystemTheme);
592
247
  window.removeEventListener("message", onMessage);
593
- elementPicker?.disable();
594
- elementPreview.release();
248
+ disposeTools();
595
249
  };
596
250
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proto-labs-inc/rig-core",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Proto's rig, framework-free: URL-state sync, the postMessage bridge to the Frame, and comment mode.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,7 +28,7 @@
28
28
  }
29
29
  },
30
30
  "dependencies": {
31
- "@proto-labs-inc/wire": "^0.1.1"
31
+ "@proto-labs-inc/wire": "^0.2.0"
32
32
  },
33
33
  "devDependencies": {
34
34
  "typescript": "~6.0.2",
@@ -1,20 +0,0 @@
1
- import { type ElementStyles, type RigToFrame } from "@proto-labs-inc/wire";
2
- /** Owns one temporary edit. Never resolves a selector back to a different element. */
3
- export declare class ElementPreview {
4
- private post;
5
- private session;
6
- private observer;
7
- private resize;
8
- private animationFrame;
9
- private lastBounds;
10
- constructor(post: (message: RigToFrame) => void);
11
- inspect(target: Element): {
12
- selectionId: `${string}-${string}-${string}-${string}-${string}`;
13
- styles: Partial<Record<"color" | "background-color" | "opacity" | "font-family" | "font-size" | "font-weight" | "line-height" | "letter-spacing" | "padding-top" | "padding-right" | "padding-bottom" | "padding-left" | "border-radius" | "gap", string>>;
14
- } | undefined;
15
- preview(id: string, styles: ElementStyles): void;
16
- private restore;
17
- release(id?: string): void;
18
- private scheduleBounds;
19
- private measure;
20
- }
@@ -1,95 +0,0 @@
1
- import { ELEMENT_STYLE_PROPERTIES } from "@proto-labs-inc/wire";
2
- /** Owns one temporary edit. Never resolves a selector back to a different element. */
3
- export class ElementPreview {
4
- post;
5
- session = null;
6
- observer = null;
7
- resize = null;
8
- animationFrame = 0;
9
- lastBounds = "";
10
- constructor(post) {
11
- this.post = post;
12
- }
13
- inspect(target) {
14
- this.release();
15
- if (!(target instanceof HTMLElement || target instanceof SVGElement))
16
- return undefined;
17
- const computed = getComputedStyle(target);
18
- const hasText = Array.from(target.childNodes).some((node) => node.nodeType === Node.TEXT_NODE && Boolean(node.textContent?.trim())) || target.matches("input, textarea, select");
19
- const typography = new Set(["color", "font-family", "font-size", "font-weight", "line-height", "letter-spacing"]);
20
- const styles = {};
21
- for (const property of ELEMENT_STYLE_PROPERTIES) {
22
- if (typography.has(property) && !hasText)
23
- continue;
24
- if (property === "gap" && !/flex|grid/.test(computed.display))
25
- continue;
26
- styles[property] = computed.getPropertyValue(property);
27
- }
28
- const selectionId = crypto.randomUUID();
29
- this.session = { id: selectionId, target, original: target.getAttribute("style") };
30
- this.lastBounds = "";
31
- this.observer = new MutationObserver(this.scheduleBounds);
32
- this.observer.observe(document.body, { childList: true, subtree: true, attributes: true });
33
- this.resize = new ResizeObserver(this.scheduleBounds);
34
- this.resize.observe(target);
35
- window.addEventListener("scroll", this.scheduleBounds, true);
36
- window.addEventListener("resize", this.scheduleBounds);
37
- return { selectionId, styles };
38
- }
39
- preview(id, styles) {
40
- const session = this.session;
41
- if (!session || session.id !== id)
42
- return;
43
- this.restore();
44
- for (const property of ELEMENT_STYLE_PROPERTIES) {
45
- const value = styles[property];
46
- if (value && CSS.supports(property, value))
47
- session.target.style.setProperty(property, value, "important");
48
- }
49
- this.scheduleBounds();
50
- }
51
- restore() {
52
- if (!this.session)
53
- return;
54
- const { target, original } = this.session;
55
- if (original === null)
56
- target.removeAttribute("style");
57
- else
58
- target.setAttribute("style", original);
59
- }
60
- release(id) {
61
- if (!this.session || (id && id !== this.session.id))
62
- return;
63
- const selectionId = this.session.id;
64
- this.observer?.disconnect();
65
- this.resize?.disconnect();
66
- window.removeEventListener("scroll", this.scheduleBounds, true);
67
- window.removeEventListener("resize", this.scheduleBounds);
68
- cancelAnimationFrame(this.animationFrame);
69
- this.animationFrame = 0;
70
- this.restore();
71
- this.session = null;
72
- this.post({ type: "element-preview-bounds", selectionId, anchor: null });
73
- }
74
- scheduleBounds = () => {
75
- if (!this.animationFrame)
76
- this.animationFrame = requestAnimationFrame(this.measure);
77
- };
78
- measure = () => {
79
- this.animationFrame = 0;
80
- if (!this.session)
81
- return;
82
- const { target, id } = this.session;
83
- if (!target.isConnected) {
84
- this.release();
85
- return;
86
- }
87
- const rect = target.getBoundingClientRect();
88
- const anchor = { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
89
- const bounds = JSON.stringify(anchor);
90
- if (bounds === this.lastBounds)
91
- return;
92
- this.lastBounds = bounds;
93
- this.post({ type: "element-preview-bounds", selectionId: id, anchor });
94
- };
95
- }