@flamingo-stack/openframe-frontend-core 0.0.544 → 0.0.545

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.544",
3
+ "version": "0.0.545",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,4 +1,4 @@
1
- <!-- source-hash: c07730f37a88d985ee9a4914a711ec71 -->
1
+ <!-- source-hash: 0c2069899d815c77a5b73d74742e3171 -->
2
2
  Accessible, animated modal dialog component with compound sub-components for building structured dialogs with backdrop, scroll-lock, and keyboard dismiss support.
3
3
 
4
4
  ## Key Components
@@ -72,6 +72,7 @@ function MyDialog() {
72
72
  - **Scroll lock** — `react-remove-scroll` (the same library Radix primitives use internally, so it composes with the lock a Select opened inside the modal adds on top). Rendered with `forwardProps` so no extra wrapper `<div>` lands in a flex/grid parent's flow
73
73
  - **Escape key** — document-level listener, but top-of-stack only: with stacked modals (a confirm above a form) one Escape closes just the top one, and a nested Radix layer's `preventDefault`ed Escape closes only that layer
74
74
  - **Focus** — focus moves into the panel on open, is CONTAINED there while it is topmost (with bounded re-asserts for focus dropped by node removal), Tab cycles inside it, and focus returns to the opener on close. Portaled Radix layers (`[data-radix-popper-content-wrapper]`, `[role=listbox]`, `[role=menu]`) are exempt
75
+ - **No focus ring on the panel** — the panel is `tabIndex={-1}`, a focus TARGET rather than a control, so it carries `outline-none`. Opening by click hid the UA ring via `:focus-visible` heuristics, but a modal opened straight from a URL on page load has no preceding pointer event and the browser drew a ring around the whole dialog on every refresh. Controls inside keep their own rings
75
76
  - **Backdrop click** — calls `onClose`
76
77
  - **Two-column scroll model** — below `lg` there is one column and the outer region scrolls (identical to a single-column modal on a phone); from `lg` up the outer region is `overflow-hidden` and each column scrolls independently
77
78
  - **Software keyboard** — the wrapper pads by `--of-keyboard-inset` rather than shrinking, since neither mobile shell shrinks the layout viewport when the IME opens
@@ -1,5 +1,5 @@
1
- <!-- source-hash: ed95b565acc4157d608a7b7a22ff8139 -->
2
- A deprecated modal component system providing an accessible, composable modal dialog with scroll locking, keyboard handling, and overlay dismissal. All exports are superseded by `ModalV2` from `./modal-v2`.
1
+ <!-- source-hash: ec832e4f08292ae27c4a94e9d050afaf -->
2
+ A deprecated modal component system providing an accessible, composable modal dialog with scroll locking, keyboard handling, and overlay dismissal. All exports are superseded by `ModalV2` from `./modal-v2`. What remains here is the compact legacy dialog used by the confirm modals that have not moved yet.
3
3
 
4
4
  ## Key Components
5
5
 
@@ -13,6 +13,15 @@ A deprecated modal component system providing an accessible, composable modal di
13
13
 
14
14
  All components use `React.forwardRef` and accept an optional `className` for Tailwind overrides.
15
15
 
16
+ ## Removed exports
17
+
18
+ There is no sizing or two-column layout here any more. `size` / `MODAL_SIZE_CLASSES` and the
19
+ `TWO_COLUMN_MODAL_BODY_CLASS` / `TWO_COLUMN_MODAL_GRID_CLASS` / `TWO_COLUMN_MODAL_COLUMN_CLASS`
20
+ class-string constants were removed once `ModalV2` gained a `size` prop and `ModalV2TwoColumn` —
21
+ the class strings were copied per call site and drifted, so a component owns the scroll model
22
+ instead. Width on this legacy dialog is a plain `className` override; anything that needs a size
23
+ variant or a two-column body belongs on `ModalV2`.
24
+
16
25
  ## Usage Example
17
26
 
18
27
  > ⚠️ **Deprecated.** Use [`ModalV2`](https://github.com/flamingo-stack/openframe-oss-lib/blob/main/src/components/modal-v2.tsx) instead.
@@ -1,5 +1,5 @@
1
1
  import type { Meta, StoryObj } from '@storybook/nextjs-vite'
2
- import { useCallback, useState } from 'react'
2
+ import { useState } from 'react'
3
3
  import { fn } from 'storybook/test'
4
4
  import { Autocomplete, type AutocompleteOption } from '../components/ui/autocomplete'
5
5
  import { Button } from '../components/ui/button'
@@ -12,6 +12,12 @@ const meta: ModalStoryMeta = {
12
12
  component: Modal,
13
13
  parameters: {
14
14
  layout: 'centered',
15
+ docs: {
16
+ description: {
17
+ component:
18
+ 'Deprecated. The compact legacy dialog kept for the confirm modals that have not moved yet — new work uses `ModalV2` (UI/ModalV2), which owns the focus trap, Escape stacking, keyboard-inset handling, the `size` variants and the two-column body. Sizing and two-column layout are NOT available here: the `size` prop and the `TWO_COLUMN_MODAL_*` class-string constants this component used to export were removed once `ModalV2` gained `size` + `ModalV2TwoColumn`. Width is a plain `className` override.',
19
+ },
20
+ },
15
21
  },
16
22
  argTypes: {
17
23
  isOpen: {
@@ -82,8 +88,9 @@ const autocompleteOptions: AutocompleteOption<string>[] = [
82
88
  ]
83
89
 
84
90
  /**
85
- * Modal with Autocomplete inside. Uses `portalContainer` to render the dropdown
86
- * within the modal's DOM tree, avoiding z-index conflicts.
91
+ * Modal with Autocomplete inside. The Autocomplete dropdown renders inline in
92
+ * the modal's own DOM tree (no portal), so the panel needs `overflow-visible`
93
+ * for the list to escape the dialog box.
87
94
  */
88
95
  export const WithAutocomplete: Story = {
89
96
  args: {
@@ -94,11 +101,6 @@ export const WithAutocomplete: Story = {
94
101
  render: function Render(args) {
95
102
  const [isOpen, setIsOpen] = useState(args.isOpen)
96
103
  const [selected, setSelected] = useState<string[]>(['enterprise'])
97
- const [modalElement, setModalElement] = useState<HTMLDivElement | null>(null)
98
-
99
- const modalRef = useCallback((node: HTMLDivElement | null) => {
100
- setModalElement(node)
101
- }, [])
102
104
 
103
105
  return (
104
106
  <>
@@ -106,7 +108,6 @@ export const WithAutocomplete: Story = {
106
108
  Open Modal with Autocomplete
107
109
  </Button>
108
110
  <Modal
109
- ref={modalRef}
110
111
  isOpen={isOpen}
111
112
  onClose={() => setIsOpen(false)}
112
113
  className="max-w-lg overflow-visible"
@@ -0,0 +1,421 @@
1
+ import type { Meta, StoryObj } from '@storybook/nextjs-vite';
2
+ import { useState } from 'react';
3
+ import { fn } from 'storybook/test';
4
+ import { Button } from '../components/ui/button';
5
+ import { Input } from '../components/ui/input';
6
+ import {
7
+ ModalV2,
8
+ ModalV2Content,
9
+ ModalV2Footer,
10
+ ModalV2Header,
11
+ ModalV2Title,
12
+ ModalV2TwoColumn,
13
+ } from '../components/ui/modal-v2';
14
+ import { Textarea } from '../components/ui/textarea';
15
+
16
+ type ModalV2StoryMeta = Meta<typeof ModalV2>;
17
+
18
+ const meta: ModalV2StoryMeta = {
19
+ title: 'UI/ModalV2',
20
+ component: ModalV2,
21
+ parameters: {
22
+ layout: 'fullscreen',
23
+ docs: {
24
+ description: {
25
+ component:
26
+ 'The current modal. `size` picks the panel width (`default` 28rem / `medium` 42rem / `wide` 1400px); `wide` also FIXES the desktop panel height so it stops jumping as content hydrates in. Use `ModalV2TwoColumn` instead of `ModalV2Content` for two-column editors — below `lg` it is one column with a single outer scroll, from `lg` up each column scrolls independently while header and footer stay pinned.',
27
+ },
28
+ },
29
+ },
30
+ decorators: [
31
+ Story => (
32
+ <div className="min-h-[100dvh] bg-ods-bg p-[var(--spacing-system-xl)]">
33
+ <Story />
34
+ </div>
35
+ ),
36
+ ],
37
+ argTypes: {
38
+ isOpen: {
39
+ control: 'boolean',
40
+ description: 'Whether the modal is open',
41
+ },
42
+ size: {
43
+ control: 'radio',
44
+ options: ['default', 'medium', 'wide'],
45
+ description:
46
+ 'Panel width. `default` = confirms, `medium` = single-column forms, `wide` = two-column editors (also fixes the desktop height)',
47
+ },
48
+ className: {
49
+ control: 'text',
50
+ description: 'Custom className for the modal panel',
51
+ },
52
+ },
53
+ };
54
+
55
+ export default meta;
56
+ type Story = StoryObj<typeof meta>;
57
+
58
+ /* ------------------------------------------------------------------ *
59
+ * Story content — deliberately long so the scroll model is reviewable.
60
+ * ------------------------------------------------------------------ */
61
+
62
+ const VENDOR_FIELDS: { label: string; value: string }[] = [
63
+ { label: 'Vendor name', value: 'Acme Endpoint Security' },
64
+ { label: 'Slug', value: 'acme-endpoint-security' },
65
+ { label: 'Website', value: 'https://acme-endpoint.example' },
66
+ { label: 'Support email', value: 'support@acme-endpoint.example' },
67
+ { label: 'Headquarters', value: 'Austin, TX' },
68
+ { label: 'Founded', value: '2014' },
69
+ { label: 'Employees', value: '240' },
70
+ { label: 'Category', value: 'Endpoint protection' },
71
+ { label: 'Sub-category', value: 'EDR' },
72
+ { label: 'Deployment', value: 'Cloud, on-premise' },
73
+ { label: 'Agent platforms', value: 'Windows, macOS, Linux' },
74
+ { label: 'Compliance', value: 'SOC 2 Type II, ISO 27001' },
75
+ { label: 'Integration partner', value: 'OpenFrame' },
76
+ { label: 'Account manager', value: 'Dana Whitfield' },
77
+ { label: 'Renewal date', value: '2026-11-30' },
78
+ { label: 'Contract owner', value: 'Procurement' },
79
+ ];
80
+
81
+ const ACTIVITY_ROWS: { title: string; meta: string; body: string }[] = [
82
+ {
83
+ title: 'Pricing refreshed',
84
+ meta: 'Today · 09:12',
85
+ body: 'Per-device tier moved from $4.20 to $4.60. Annual commitment unchanged.',
86
+ },
87
+ {
88
+ title: 'Feature matrix synced',
89
+ meta: 'Yesterday · 17:40',
90
+ body: 'Ransomware rollback and USB device control marked as generally available.',
91
+ },
92
+ {
93
+ title: 'Case study published',
94
+ meta: '3 days ago',
95
+ body: 'Regional MSP consolidated three agents onto one console after migration.',
96
+ },
97
+ {
98
+ title: 'Security review passed',
99
+ meta: '6 days ago',
100
+ body: 'SOC 2 Type II report re-issued; no exceptions carried forward.',
101
+ },
102
+ {
103
+ title: 'Support SLA updated',
104
+ meta: '2 weeks ago',
105
+ body: 'P1 response tightened to 30 minutes for partners on the managed plan.',
106
+ },
107
+ {
108
+ title: 'Partner tier changed',
109
+ meta: '3 weeks ago',
110
+ body: 'Promoted to Gold. Deal registration discount now applies automatically.',
111
+ },
112
+ {
113
+ title: 'Console redesign shipped',
114
+ meta: '1 month ago',
115
+ body: 'Alert triage moved into a single queue with saved views per technician.',
116
+ },
117
+ {
118
+ title: 'API v3 announced',
119
+ meta: '1 month ago',
120
+ body: 'Webhook payloads gain device tags; v2 stays supported through next year.',
121
+ },
122
+ {
123
+ title: 'Outage postmortem',
124
+ meta: '2 months ago',
125
+ body: 'Ingest backlog cleared in 90 minutes; regional failover added since.',
126
+ },
127
+ {
128
+ title: 'Onboarding docs rewritten',
129
+ meta: '2 months ago',
130
+ body: 'Silent-install flags documented for every supported RMM deployment tool.',
131
+ },
132
+ ];
133
+
134
+ function VendorFields({ count = VENDOR_FIELDS.length }: { count?: number }) {
135
+ return (
136
+ <>
137
+ <p className="text-h5 text-ods-text-secondary">Vendor profile</p>
138
+ {VENDOR_FIELDS.slice(0, count).map(field => (
139
+ <Input key={field.label} label={field.label} defaultValue={field.value} className="w-full" />
140
+ ))}
141
+ <Textarea
142
+ label="Internal notes"
143
+ rows={4}
144
+ defaultValue="Renewal negotiated alongside the backup vendor. Keep both contracts on the same anniversary so the bundle discount survives."
145
+ />
146
+ </>
147
+ );
148
+ }
149
+
150
+ function ActivityRows({ count = ACTIVITY_ROWS.length }: { count?: number }) {
151
+ return (
152
+ <>
153
+ <p className="text-h5 text-ods-text-secondary">Recent activity</p>
154
+ {ACTIVITY_ROWS.slice(0, count).map(row => (
155
+ <div key={row.title} className="rounded-md border border-ods-border p-[var(--spacing-system-mf)]">
156
+ <div className="flex items-baseline justify-between gap-[var(--spacing-system-xsf)]">
157
+ <span className="text-h3 text-ods-text-primary">{row.title}</span>
158
+ <span className="text-h6 text-ods-text-tertiary">{row.meta}</span>
159
+ </div>
160
+ <p className="mt-[var(--spacing-system-xs)] text-h6 text-ods-text-secondary">{row.body}</p>
161
+ </div>
162
+ ))}
163
+ </>
164
+ );
165
+ }
166
+
167
+ /**
168
+ * `size="default"` (28rem) — the confirm-dialog width. Header, one short
169
+ * paragraph, footer.
170
+ */
171
+ export const Default: Story = {
172
+ args: {
173
+ isOpen: true,
174
+ onClose: fn(),
175
+ size: 'default',
176
+ children: null,
177
+ },
178
+ render: function Render(args) {
179
+ const [isOpen, setIsOpen] = useState(args.isOpen);
180
+
181
+ return (
182
+ <>
183
+ <Button onClick={() => setIsOpen(true)} variant="outline">
184
+ Open default modal
185
+ </Button>
186
+ <ModalV2 isOpen={isOpen} onClose={() => setIsOpen(false)} size={args.size}>
187
+ <ModalV2Header>
188
+ <ModalV2Title>Archive vendor</ModalV2Title>
189
+ </ModalV2Header>
190
+ <ModalV2Content>
191
+ <p className="text-h6 text-ods-text-secondary">
192
+ Acme Endpoint Security will stop appearing in comparisons and search. Existing references keep working and
193
+ you can restore it at any time.
194
+ </p>
195
+ </ModalV2Content>
196
+ <ModalV2Footer className="justify-end">
197
+ <Button variant="outline" onClick={() => setIsOpen(false)}>
198
+ Cancel
199
+ </Button>
200
+ <Button variant="destructive" onClick={() => setIsOpen(false)}>
201
+ Archive
202
+ </Button>
203
+ </ModalV2Footer>
204
+ </ModalV2>
205
+ </>
206
+ );
207
+ },
208
+ };
209
+
210
+ /**
211
+ * `size="medium"` (42rem) — the single-column form width. The body is a plain
212
+ * `ModalV2Content`, so header and footer stay pinned while it scrolls.
213
+ */
214
+ export const Medium: Story = {
215
+ args: {
216
+ isOpen: true,
217
+ onClose: fn(),
218
+ size: 'medium',
219
+ children: null,
220
+ },
221
+ render: function Render(args) {
222
+ const [isOpen, setIsOpen] = useState(args.isOpen);
223
+
224
+ return (
225
+ <>
226
+ <Button onClick={() => setIsOpen(true)} variant="outline">
227
+ Open medium modal
228
+ </Button>
229
+ <ModalV2 isOpen={isOpen} onClose={() => setIsOpen(false)} size={args.size}>
230
+ <ModalV2Header>
231
+ <ModalV2Title>Edit vendor</ModalV2Title>
232
+ </ModalV2Header>
233
+ <ModalV2Content className="space-y-[var(--spacing-system-lf)] pr-[var(--spacing-system-sf)]">
234
+ <VendorFields count={8} />
235
+ </ModalV2Content>
236
+ <ModalV2Footer className="justify-end">
237
+ <Button variant="outline" onClick={() => setIsOpen(false)}>
238
+ Cancel
239
+ </Button>
240
+ <Button onClick={() => setIsOpen(false)}>Save</Button>
241
+ </ModalV2Footer>
242
+ </ModalV2>
243
+ </>
244
+ );
245
+ },
246
+ };
247
+
248
+ /**
249
+ * `size="wide"` (1400px) with a single-column body and deliberately sparse
250
+ * content: on desktop the panel keeps its full `min(90dvh,100%-2rem)` height
251
+ * instead of hugging the content, which is what stops it jumping as an entity
252
+ * hydrates in. Mobile still gets a content-sized bottom sheet.
253
+ */
254
+ export const Wide: Story = {
255
+ args: {
256
+ isOpen: true,
257
+ onClose: fn(),
258
+ size: 'wide',
259
+ children: null,
260
+ },
261
+ render: function Render(args) {
262
+ const [isOpen, setIsOpen] = useState(args.isOpen);
263
+
264
+ return (
265
+ <>
266
+ <Button onClick={() => setIsOpen(true)} variant="outline">
267
+ Open wide modal
268
+ </Button>
269
+ <ModalV2 isOpen={isOpen} onClose={() => setIsOpen(false)} size={args.size}>
270
+ <ModalV2Header>
271
+ <ModalV2Title>Vendor loading</ModalV2Title>
272
+ </ModalV2Header>
273
+ <ModalV2Content>
274
+ <p className="text-h6 text-ods-text-secondary">
275
+ Two fields of content in a panel that keeps its full height. Compare with the medium story above, which
276
+ shrinks to fit its content.
277
+ </p>
278
+ <div className="mt-[var(--spacing-system-lf)] space-y-[var(--spacing-system-lf)]">
279
+ <Input label="Vendor name" defaultValue="Acme Endpoint Security" className="w-full" />
280
+ <Input label="Slug" defaultValue="acme-endpoint-security" className="w-full" />
281
+ </div>
282
+ </ModalV2Content>
283
+ <ModalV2Footer className="justify-end">
284
+ <Button variant="outline" onClick={() => setIsOpen(false)}>
285
+ Cancel
286
+ </Button>
287
+ <Button onClick={() => setIsOpen(false)}>Save</Button>
288
+ </ModalV2Footer>
289
+ </ModalV2>
290
+ </>
291
+ );
292
+ },
293
+ };
294
+
295
+ /**
296
+ * The two-column editor: `size="wide"` + `ModalV2TwoColumn`. Both columns
297
+ * overflow, and by different amounts — scroll the left one to the bottom and
298
+ * the right one does not move. Narrow the viewport below `lg` and the two
299
+ * collapse into a single column with one outer scrollbar.
300
+ */
301
+ export const WideTwoColumn: Story = {
302
+ args: {
303
+ isOpen: true,
304
+ onClose: fn(),
305
+ size: 'wide',
306
+ children: null,
307
+ },
308
+ render: function Render(args) {
309
+ const [isOpen, setIsOpen] = useState(args.isOpen);
310
+
311
+ return (
312
+ <>
313
+ <Button onClick={() => setIsOpen(true)} variant="outline">
314
+ Open two-column editor
315
+ </Button>
316
+ <ModalV2 isOpen={isOpen} onClose={() => setIsOpen(false)} size={args.size}>
317
+ <ModalV2Header>
318
+ <ModalV2Title>Acme Endpoint Security</ModalV2Title>
319
+ <p className="text-h6 text-ods-text-secondary">
320
+ 16 fields on the left, 10 activity entries on the right. Each side scrolls on its own at desktop widths.
321
+ </p>
322
+ </ModalV2Header>
323
+ <ModalV2TwoColumn left={<VendorFields />} right={<ActivityRows />} />
324
+ <ModalV2Footer className="justify-end">
325
+ <Button variant="outline" onClick={() => setIsOpen(false)}>
326
+ Cancel
327
+ </Button>
328
+ <Button onClick={() => setIsOpen(false)}>Save</Button>
329
+ </ModalV2Footer>
330
+ </ModalV2>
331
+ </>
332
+ );
333
+ },
334
+ };
335
+
336
+ /**
337
+ * The regression case for the row sizing. The left column is very long and the
338
+ * right one holds two entries: with an implicit `auto` grid row the row would
339
+ * size to the TALLER column, the columns would get no bounded height to scroll
340
+ * inside, and the outer `overflow-hidden` would clip the bottom of the left
341
+ * column instead. `lg:grid-rows-[minmax(0,1fr)]` is what keeps the left column
342
+ * scrollable and the right one short.
343
+ */
344
+ export const TwoColumnUnequalColumns: Story = {
345
+ args: {
346
+ isOpen: true,
347
+ onClose: fn(),
348
+ size: 'wide',
349
+ children: null,
350
+ },
351
+ render: function Render(args) {
352
+ const [isOpen, setIsOpen] = useState(args.isOpen);
353
+
354
+ return (
355
+ <>
356
+ <Button onClick={() => setIsOpen(true)} variant="outline">
357
+ Open lopsided two-column editor
358
+ </Button>
359
+ <ModalV2 isOpen={isOpen} onClose={() => setIsOpen(false)} size={args.size}>
360
+ <ModalV2Header>
361
+ <ModalV2Title>Lopsided columns</ModalV2Title>
362
+ <p className="text-h6 text-ods-text-secondary">
363
+ Scroll the left column to its last field — the panel does not grow and nothing is clipped.
364
+ </p>
365
+ </ModalV2Header>
366
+ <ModalV2TwoColumn left={<VendorFields />} right={<ActivityRows count={2} />} />
367
+ <ModalV2Footer className="justify-end">
368
+ <Button variant="outline" onClick={() => setIsOpen(false)}>
369
+ Cancel
370
+ </Button>
371
+ <Button onClick={() => setIsOpen(false)}>Save</Button>
372
+ </ModalV2Footer>
373
+ </ModalV2>
374
+ </>
375
+ );
376
+ },
377
+ };
378
+
379
+ /**
380
+ * Header and footer live outside the scrolling region, so both stay pinned while
381
+ * the columns move under them. Both are given a divider here to make the seam
382
+ * between the pinned chrome and the scrolling body obvious during review.
383
+ */
384
+ export const PinnedHeaderAndFooter: Story = {
385
+ args: {
386
+ isOpen: true,
387
+ onClose: fn(),
388
+ size: 'wide',
389
+ children: null,
390
+ },
391
+ render: function Render(args) {
392
+ const [isOpen, setIsOpen] = useState(args.isOpen);
393
+
394
+ return (
395
+ <>
396
+ <Button onClick={() => setIsOpen(true)} variant="outline">
397
+ Open pinned header/footer editor
398
+ </Button>
399
+ <ModalV2 isOpen={isOpen} onClose={() => setIsOpen(false)} size={args.size}>
400
+ <ModalV2Header className="border-b border-ods-border pb-[var(--spacing-system-mf)]">
401
+ <ModalV2Title>Pinned chrome</ModalV2Title>
402
+ <p className="text-h6 text-ods-text-secondary">
403
+ Scroll either column to its end — this header and the footer below stay put, and the close button stays
404
+ reachable the whole time.
405
+ </p>
406
+ </ModalV2Header>
407
+ <ModalV2TwoColumn left={<VendorFields />} right={<ActivityRows count={6} />} />
408
+ <ModalV2Footer className="items-center justify-between border-t border-ods-border pt-[var(--spacing-system-mf)]">
409
+ <span className="text-h6 text-ods-text-secondary">Last saved 4 minutes ago</span>
410
+ <div className="flex gap-[var(--spacing-system-mf)]">
411
+ <Button variant="outline" onClick={() => setIsOpen(false)}>
412
+ Cancel
413
+ </Button>
414
+ <Button onClick={() => setIsOpen(false)}>Save</Button>
415
+ </div>
416
+ </ModalV2Footer>
417
+ </ModalV2>
418
+ </>
419
+ );
420
+ },
421
+ };