@frontera-sdk/core 1.47.1 → 1.47.2
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 +1 -1
- package/src/create-frontera-app.tsx +228 -7
package/package.json
CHANGED
|
@@ -108,19 +108,240 @@ class AppErrorBoundary extends Component<BoundaryProps, { error: Error | null }>
|
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
/**
|
|
112
|
+
* The keyframes the boot screen needs.
|
|
113
|
+
*
|
|
114
|
+
* Inlined as a <style> element rather than a CSS import because this package
|
|
115
|
+
* is consumed as source by app bundlers that are not all configured to handle
|
|
116
|
+
* a stylesheet import, and a boot screen must never be the thing that breaks
|
|
117
|
+
* the build it is meant to report on.
|
|
118
|
+
*/
|
|
119
|
+
const BOOT_KEYFRAMES =
|
|
120
|
+
'@keyframes frontera-boot-cube{0%,70%,100%{transform:scale3d(.5,.5,1)}35%{transform:scale3d(0,0,1)}}' +
|
|
121
|
+
'@keyframes frontera-boot-shimmer{0%{background-position:100% 0}100%{background-position:-100% 0}}' +
|
|
122
|
+
'@media(prefers-reduced-motion:reduce){' +
|
|
123
|
+
'[data-frontera-boot-cube]{animation-duration:3.9s!important}' +
|
|
124
|
+
'[data-frontera-boot-shimmer]{animation:none!important}}'
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Per-cube animation delays, in grid order.
|
|
128
|
+
*
|
|
129
|
+
* Copied from the platform's `.spinner-cube:nth-child(n)` rules rather than
|
|
130
|
+
* re-derived, so the two read as the same object in motion. The pattern is a
|
|
131
|
+
* diagonal wave: equal delays run bottom-left to top-right.
|
|
132
|
+
*/
|
|
133
|
+
const BOOT_CUBE_DELAYS = [0.2, 0.3, 0.4, 0.1, 0.2, 0.3, 0, 0.1, 0.2]
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Colours for the boot screen.
|
|
137
|
+
*
|
|
138
|
+
* Tokens, with literals only for the app that defines none of them.
|
|
139
|
+
*
|
|
140
|
+
* This paints in whatever scheme the document is already in, which before the
|
|
141
|
+
* handshake means the app's own `:root` — light, unless the app itself has put
|
|
142
|
+
* `.dark` on the document. Nothing here sets the scheme: `applyHostTheme` runs
|
|
143
|
+
* at handshake, by which point this screen is gone, and a guess made earlier
|
|
144
|
+
* would be a write that outlives the screen and fights the app for control of
|
|
145
|
+
* its own theme.
|
|
146
|
+
*
|
|
147
|
+
* There is deliberately no `foreground` entry. In the platform theme
|
|
148
|
+
* `--foreground` holds a bare HSL triplet (`47 13% 14%`) rather than a colour —
|
|
149
|
+
* it is wrapped as `hsl(var(--foreground))` at the point of use — and Tailwind's
|
|
150
|
+
* `@theme inline` does not emit a `--color-foreground` custom property to reach
|
|
151
|
+
* for instead. Naming either one here produces an invalid declaration that the
|
|
152
|
+
* browser drops. Text therefore INHERITS, which lands on the app's own body
|
|
153
|
+
* colour: already correct, in either scheme, with nothing to keep in sync.
|
|
154
|
+
*/
|
|
155
|
+
const BOOT_PALETTE = {
|
|
156
|
+
// `--surface`, not `--background`: this screen sits where the app's own
|
|
157
|
+
// surface will be, so booting on the surface colour means the handover to
|
|
158
|
+
// the real UI is not also a change of backdrop. It needs no `--color-*`
|
|
159
|
+
// form — unlike the two below it, `--surface` is already a colour.
|
|
160
|
+
background: 'var(--surface, #fafafa)',
|
|
161
|
+
muted: 'var(--muted-foreground, #6b7280)',
|
|
162
|
+
border: 'var(--border, #e5e7eb)',
|
|
163
|
+
surface: 'var(--surface-raised, var(--surface, #fafafa))',
|
|
164
|
+
destructive: 'var(--destructive, #dc2626)',
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The spinner shown while the session is being established.
|
|
169
|
+
*
|
|
170
|
+
* The platform's own `GridSpinner` — a 3×3 of pulsing cubes — rebuilt here in
|
|
171
|
+
* inline styles, because this package has neither Tailwind nor the stylesheet
|
|
172
|
+
* that rule lives in. Matching it matters more than the few lines it costs: an
|
|
173
|
+
* app booting inside the platform should not announce itself with a spinner
|
|
174
|
+
* shape that appears nowhere else in the product.
|
|
175
|
+
*
|
|
176
|
+
* Not a top bar: `AppFrame` already shows `LoadingBar` outside the iframe for
|
|
177
|
+
* exactly this wait, and a second bar inside the frame would draw the same
|
|
178
|
+
* progress twice.
|
|
179
|
+
*
|
|
180
|
+
* Sized in `em` like the original, so the cubes track the font size rather
|
|
181
|
+
* than needing a second number kept in sync.
|
|
182
|
+
*/
|
|
183
|
+
function bootSpinner(): ReactNode {
|
|
184
|
+
return (
|
|
185
|
+
<div
|
|
186
|
+
aria-hidden="true"
|
|
187
|
+
style={{
|
|
188
|
+
display: 'inline-grid',
|
|
189
|
+
gridTemplateColumns: 'repeat(3, 1fr)',
|
|
190
|
+
gap: '0.08em',
|
|
191
|
+
width: '1em',
|
|
192
|
+
height: '1em',
|
|
193
|
+
fontSize: 28,
|
|
194
|
+
color: BOOT_PALETTE.muted,
|
|
195
|
+
// Tops the container's 10 up to the 16 this sits above.
|
|
196
|
+
marginBottom: 6,
|
|
197
|
+
}}
|
|
198
|
+
>
|
|
199
|
+
{BOOT_CUBE_DELAYS.map((delay, index) => (
|
|
200
|
+
<span
|
|
201
|
+
key={index}
|
|
202
|
+
data-frontera-boot-cube=""
|
|
203
|
+
style={{
|
|
204
|
+
backgroundColor: 'currentColor',
|
|
205
|
+
transform: 'scale3d(0.5, 0.5, 1)',
|
|
206
|
+
animation: `frontera-boot-cube 1.3s ${delay}s infinite ease-in-out`,
|
|
207
|
+
}}
|
|
208
|
+
/>
|
|
209
|
+
))}
|
|
210
|
+
</div>
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The screens shown before an app can render: connecting, and the two ways
|
|
216
|
+
* starting can fail.
|
|
217
|
+
*
|
|
218
|
+
* Colours come from `BOOT_PALETTE` rather than being written inline, because
|
|
219
|
+
* this paints BEFORE `applyHostTheme` has run and the values have to hold up
|
|
220
|
+
* without it.
|
|
221
|
+
*/
|
|
222
|
+
function diagnostic(title: string, detail: string, variant: 'loading' | 'error' = 'error'): ReactNode {
|
|
223
|
+
const loading = variant === 'loading'
|
|
112
224
|
return (
|
|
113
225
|
<div
|
|
114
|
-
|
|
226
|
+
// A loading screen is a status, not an alert: `alert` is assertive and
|
|
227
|
+
// interrupts the screen-reader user on every single app boot.
|
|
228
|
+
role={loading ? 'status' : 'alert'}
|
|
229
|
+
aria-live={loading ? 'polite' : undefined}
|
|
115
230
|
style={{
|
|
231
|
+
boxSizing: 'border-box',
|
|
232
|
+
minHeight: '100dvh',
|
|
233
|
+
display: 'flex',
|
|
234
|
+
alignItems: 'center',
|
|
235
|
+
justifyContent: 'center',
|
|
236
|
+
padding: 32,
|
|
116
237
|
fontFamily: 'var(--font-sans, system-ui, sans-serif)',
|
|
117
|
-
|
|
118
|
-
padding: 24,
|
|
238
|
+
background: BOOT_PALETTE.background,
|
|
119
239
|
lineHeight: 1.5,
|
|
120
240
|
}}
|
|
121
241
|
>
|
|
122
|
-
<
|
|
123
|
-
<div
|
|
242
|
+
<style>{BOOT_KEYFRAMES}</style>
|
|
243
|
+
<div
|
|
244
|
+
style={{
|
|
245
|
+
display: 'flex',
|
|
246
|
+
flexDirection: 'column',
|
|
247
|
+
alignItems: 'center',
|
|
248
|
+
// The two gaps differ: 10 between the lines of text, 16 from the
|
|
249
|
+
// indicator down to them. `gap` sets the smaller one for every pair
|
|
250
|
+
// and the indicator adds the remainder below itself, so the two
|
|
251
|
+
// numbers stay readable instead of being one compromise value.
|
|
252
|
+
gap: 10,
|
|
253
|
+
textAlign: 'center',
|
|
254
|
+
maxWidth: 380,
|
|
255
|
+
}}
|
|
256
|
+
>
|
|
257
|
+
{loading ? (
|
|
258
|
+
bootSpinner()
|
|
259
|
+
) : (
|
|
260
|
+
<svg
|
|
261
|
+
aria-hidden="true"
|
|
262
|
+
viewBox="0 0 24 24"
|
|
263
|
+
width={30}
|
|
264
|
+
height={30}
|
|
265
|
+
fill="none"
|
|
266
|
+
stroke={BOOT_PALETTE.destructive}
|
|
267
|
+
strokeWidth={2}
|
|
268
|
+
strokeLinecap="round"
|
|
269
|
+
>
|
|
270
|
+
<circle cx="12" cy="12" r="9" />
|
|
271
|
+
<path d="M12 8v5" />
|
|
272
|
+
<path d="M12 16.5h.01" />
|
|
273
|
+
</svg>
|
|
274
|
+
)}
|
|
275
|
+
<div
|
|
276
|
+
data-frontera-boot-shimmer={loading ? '' : undefined}
|
|
277
|
+
style={{
|
|
278
|
+
// No font-size: `<strong>` inherited the body size on main, and
|
|
279
|
+
// matching it keeps this screen the size it has always been.
|
|
280
|
+
fontWeight: 'bold' as const,
|
|
281
|
+
// A highlight sweeping across the glyphs themselves, clipped to the
|
|
282
|
+
// text. Only while connecting: an error is not in progress, and
|
|
283
|
+
// animating it would suggest the app is still trying.
|
|
284
|
+
//
|
|
285
|
+
// The gradient rests on `currentColor`, which is the heading's own
|
|
286
|
+
// full-contrast colour — on main this was plain `--foreground`, and
|
|
287
|
+
// a muted base would leave it at the same weight as the line
|
|
288
|
+
// beneath it for most of the sweep.
|
|
289
|
+
//
|
|
290
|
+
// `currentColor` rather than the token, because a token can fail:
|
|
291
|
+
// in the platform theme `--foreground` holds a bare HSL triplet, so
|
|
292
|
+
// naming it inside `linear-gradient()` makes the whole declaration
|
|
293
|
+
// invalid. The browser then drops the gradient but KEEPS
|
|
294
|
+
// `-webkit-text-fill-color: transparent` — and the heading vanishes
|
|
295
|
+
// entirely. `currentColor` is always a valid colour, so the worst
|
|
296
|
+
// case is a shimmer that does not shimmer, never invisible text.
|
|
297
|
+
//
|
|
298
|
+
// The travelling stop is `muted-foreground`, NOT `primary`: primary
|
|
299
|
+
// is near-black in the default light theme, which is also what the
|
|
300
|
+
// heading already is, so that sweep was three identical stops and
|
|
301
|
+
// no visible motion. Muted is the one token guaranteed to differ
|
|
302
|
+
// from body text in both schemes — that is what it is for.
|
|
303
|
+
...(loading
|
|
304
|
+
? {
|
|
305
|
+
backgroundImage: `linear-gradient(90deg, currentColor 30%, ${BOOT_PALETTE.muted} 50%, currentColor 70%)`,
|
|
306
|
+
backgroundSize: '200% 100%',
|
|
307
|
+
WebkitBackgroundClip: 'text',
|
|
308
|
+
backgroundClip: 'text',
|
|
309
|
+
fontSize: 18,
|
|
310
|
+
// Transparent text is invisible if `background-clip: text` is
|
|
311
|
+
// not honoured, so the fill is set the same way and the
|
|
312
|
+
// colour underneath stays the readable one.
|
|
313
|
+
WebkitTextFillColor: 'transparent',
|
|
314
|
+
animation: 'frontera-boot-shimmer 2s linear infinite',
|
|
315
|
+
}
|
|
316
|
+
: {}),
|
|
317
|
+
}}
|
|
318
|
+
>
|
|
319
|
+
{title}
|
|
320
|
+
</div>
|
|
321
|
+
<div
|
|
322
|
+
style={{
|
|
323
|
+
fontSize: 14,
|
|
324
|
+
color: BOOT_PALETTE.muted,
|
|
325
|
+
// An error detail is a raw message or stack fragment: monospaced,
|
|
326
|
+
// left-aligned and boxed so a long one stays readable instead of
|
|
327
|
+
// becoming a centred wall of text.
|
|
328
|
+
...(loading
|
|
329
|
+
? {}
|
|
330
|
+
: {
|
|
331
|
+
fontFamily: 'var(--font-mono, ui-monospace, monospace)',
|
|
332
|
+
textAlign: 'left' as const,
|
|
333
|
+
background: BOOT_PALETTE.surface,
|
|
334
|
+
border: `1px solid ${BOOT_PALETTE.border}`,
|
|
335
|
+
borderRadius: 'calc(var(--radius, 8px) - 2px)',
|
|
336
|
+
padding: '10px 12px',
|
|
337
|
+
maxWidth: '100%',
|
|
338
|
+
overflowWrap: 'anywhere' as const,
|
|
339
|
+
}),
|
|
340
|
+
}}
|
|
341
|
+
>
|
|
342
|
+
{detail}
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
124
345
|
</div>
|
|
125
346
|
)
|
|
126
347
|
}
|
|
@@ -209,7 +430,7 @@ export interface FronteraAppProviderProps {
|
|
|
209
430
|
export function FronteraAppProvider({
|
|
210
431
|
children,
|
|
211
432
|
providers = [],
|
|
212
|
-
loading = diagnostic('Connecting to Frontera', 'Establishing an authenticated App session.'),
|
|
433
|
+
loading = diagnostic('Connecting to Frontera', 'Establishing an authenticated App session.', 'loading'),
|
|
213
434
|
errorFallback,
|
|
214
435
|
queryClient: providedQueryClient,
|
|
215
436
|
timeoutMs,
|