@frontera-sdk/core 1.47.1 → 1.48.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/core",
3
- "version": "1.47.1",
3
+ "version": "1.48.0",
4
4
  "description": "Frontera app runtime: the platform bridge client, app bootstrap and typed platform client.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -108,19 +108,240 @@ class AppErrorBoundary extends Component<BoundaryProps, { error: Error | null }>
108
108
  }
109
109
  }
110
110
 
111
- function diagnostic(title: string, detail: string): ReactNode {
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
- role="alert"
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
- color: 'var(--foreground, #111)',
118
- padding: 24,
238
+ background: BOOT_PALETTE.background,
119
239
  lineHeight: 1.5,
120
240
  }}
121
241
  >
122
- <strong>{title}</strong>
123
- <div style={{ marginTop: 4, opacity: 0.75, fontSize: 14 }}>{detail}</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,