elowen-plugin-ui-kit 0.7.0 → 0.11.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.
Files changed (4) hide show
  1. package/index.d.ts +129 -5
  2. package/index.js +3 -3
  3. package/package.json +1 -1
  4. package/theme.css +164 -2
package/index.d.ts CHANGED
@@ -7,10 +7,11 @@ import type * as ReactDom from 'react-dom';
7
7
  import type * as JsxRuntime from 'react/jsx-runtime';
8
8
  import type { ComponentType } from 'react';
9
9
 
10
- /** See index.js — bump on incompatible changes to `ElowenUiRuntime`. Deliberately a LITERAL type:
11
- * the web app re-declares the value and annotates it with `typeof PLUGIN_UI_API_VERSION`, so a kit
12
- * bump that forgets the host fails the web typecheck instead of drifting silently. */
13
- export declare const PLUGIN_UI_API_VERSION: 12;
10
+ /** See index.js — bump whenever a released bundle requires a newly published runtime contract.
11
+ * Deliberately a LITERAL type: the web app re-declares the value and annotates it with
12
+ * `typeof PLUGIN_UI_API_VERSION`, so a kit bump that forgets the host fails the web typecheck instead
13
+ * of drifting silently. */
14
+ export declare const PLUGIN_UI_API_VERSION: 16;
14
15
 
15
16
  /** Public props of `ElowenUiRuntime.components.Slider`. */
16
17
  export interface SliderProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'min' | 'max' | 'step' | 'type'> {
@@ -29,6 +30,13 @@ export interface DirectoryPickerProps {
29
30
  onClose: () => void;
30
31
  }
31
32
 
33
+ /** Public props of the API 16 `ElowenUiRuntime.components.ProjectIcon`. */
34
+ export interface ProjectIconProps {
35
+ project: { id: number; icon?: string };
36
+ size?: number;
37
+ className?: string;
38
+ }
39
+
32
40
  export interface PluginConfigField {
33
41
  key: string;
34
42
  label: string;
@@ -45,7 +53,7 @@ export interface PluginConfigField {
45
53
  display?: { control?: 'input' | 'slider'; unit?: string; divisor?: number };
46
54
  browse?: 'directory';
47
55
  default?: string | number | boolean | string[];
48
- providerType?: string;
56
+ providerType?: string | string[];
49
57
  options?: { value: string; label: string }[];
50
58
  language?: string;
51
59
  help?: string;
@@ -120,6 +128,52 @@ export interface ConfirmDialogProps {
120
128
  onClose: () => void;
121
129
  }
122
130
 
131
+ /** The durable operation row a progress window renders, as the daemon publishes it. List projections
132
+ * omit `logTail`; the single-operation read and the live `environment-operation` frame carry it.
133
+ * The status union is the daemon's own (`EnvironmentOperation` in the wire contract): an environment
134
+ * operation is never cancelled, and a status this side invents is one a bundle would render as "still
135
+ * preparing" forever. */
136
+ export interface EnvironmentOperationView {
137
+ id: string;
138
+ projectId: number;
139
+ generation: number;
140
+ action: { kind: string };
141
+ status: 'pending' | 'running' | 'succeeded' | 'failed';
142
+ error: string | null;
143
+ /** The declared step list, in order. Each entry is a stable id the host localizes. */
144
+ steps: string[];
145
+ stepIndex: number;
146
+ stepTotal: number;
147
+ stepLabel: string | null;
148
+ percent: number | null;
149
+ logTail?: string[];
150
+ }
151
+
152
+ /** Public props of `ElowenUiRuntime.components.OperationProgressDialog`: one installer-style window over
153
+ * a durable environment operation, rendered as `Modal` content by the host.
154
+ *
155
+ * Dismissing it while the work runs HIDES it — the operation outlives the tab that started it — so
156
+ * `onClose` reports whether it was still running and the call site decides whether to keep following. */
157
+ export interface OperationProgressDialogProps {
158
+ open: boolean;
159
+ /** What the person asked for, in their words. The dialog adds the step, never the intent. */
160
+ title: string;
161
+ operation: EnvironmentOperationView | null;
162
+ logTail?: string[];
163
+ /** A transport failure of the read that follows the operation, distinct from a failed operation. */
164
+ loadError?: string | null;
165
+ /** Run the same intent again. Omitted, the retry action is not offered. */
166
+ onRetry?: () => void;
167
+ /** The stale-environment repair. Offered only while `recreatable` holds. */
168
+ onRecreate?: () => void;
169
+ recreatable?: boolean;
170
+ onClose: (info: { running: boolean }) => void;
171
+ /** Fired once when a succeeded operation closes itself. */
172
+ onSettled?: () => void;
173
+ /** How long a success stays on screen before it closes itself. */
174
+ successDelayMs?: number;
175
+ }
176
+
123
177
  /** Project metadata exposed to a contextual plugin panel. The Project remains core-owned; a panel uses
124
178
  * this identity to address only its own project-scoped API data. */
125
179
  export interface PluginUiProject {
@@ -187,6 +241,73 @@ export interface PluginUserPanelProps {
187
241
  surface: 'user';
188
242
  }
189
243
 
244
+ /** JSON payload published with a plugin-owned inline chat artifact. */
245
+ export type PluginChatArtifactData = null | boolean | number | string | PluginChatArtifactData[] | { [key: string]: PluginChatArtifactData };
246
+
247
+ /** An optional authenticated same-plugin SSE source the artifact component may consume. */
248
+ export interface PluginChatArtifactLiveMedia {
249
+ transport: 'sse';
250
+ path: string;
251
+ }
252
+
253
+ /** The normalized open artifact snapshot handed to a registered chat artifact component. */
254
+ export interface PluginChatArtifact {
255
+ id: string;
256
+ plugin: string;
257
+ sessionId: string;
258
+ toolCallId: string;
259
+ view: string;
260
+ fallback: string;
261
+ data?: PluginChatArtifactData;
262
+ media?: PluginChatArtifactLiveMedia;
263
+ expiresAt: string;
264
+ status: 'open';
265
+ createdAt: string;
266
+ updatedAt: string;
267
+ }
268
+
269
+ /** A prompt the host is waiting on, offered to an artifact that covers it (API 15).
270
+ *
271
+ * It carries no content at all — not the question, not its options, not an id. A surface that hides the
272
+ * host's question card only needs to say that one is waiting and to hand the reader back to it, and both
273
+ * of those are host-owned: `label` is the app's own translated line, `reveal` brings the real card into
274
+ * view and focuses it. Everything about answering — the options, validation, the request — stays where it
275
+ * already is, so nothing an artifact renders can drift from what the user is actually being asked. */
276
+ export interface PluginChatPendingInput {
277
+ /** The host's own user-visible line, already translated. Render it as-is. */
278
+ label: string;
279
+ /** Bring the host's prompt into view and focus it. A covering surface should close itself first. */
280
+ reveal: () => void;
281
+ }
282
+
283
+ /** Props for a plugin component rendered inline after its artifact's matching tool segment. */
284
+ export interface PluginChatArtifactProps {
285
+ plugin: string;
286
+ artifact: PluginChatArtifact;
287
+ /** API 15. Set while the host is waiting on an answer from the user, `null` when it is not.
288
+ *
289
+ * Same reason as `narration`: an artifact whose surface covers the dock covers the question card with
290
+ * it, and a reader who cannot see that they are being asked something will sit and wait for an agent
291
+ * that is waiting for them. Show that a prompt is waiting, then call `reveal` — after closing your own
292
+ * surface — to put the reader in front of the real card.
293
+ *
294
+ * It clears the moment the prompt is answered, discarded or the conversation is switched. Hosts older
295
+ * than API 15 pass nothing, so read it as optional. */
296
+ pendingInput?: PluginChatPendingInput | null;
297
+ /** API 14. The assistant prose the transcript is rendering RIGHT NOW, for an artifact that draws a
298
+ * surface over the dock and therefore hides the conversation it belongs to.
299
+ *
300
+ * It is the host's own visible text, projected — the newest assistant turn's latest text segment,
301
+ * whitespace-collapsed and capped at 240 characters — and deliberately nothing else: no tool payloads,
302
+ * no hidden reasoning, no system content, no history, and empty (`''`) as soon as the newest turn is
303
+ * the user's or carries no prose. A bundle renders it as PLAIN TEXT; the transcript owns markdown, and
304
+ * a second composition of it would be a second source of truth for what the user was told.
305
+ *
306
+ * It updates as the reply streams, so treat it as a live value: bound how much of it you draw, and do
307
+ * not accumulate it. Hosts older than API 14 pass nothing, so read it as optional. */
308
+ narration?: string;
309
+ }
310
+
190
311
  /** What a bundle hands to window.__elowenRegisterPluginUi. Routes are `/`-joined segment patterns
191
312
  * (`''` = the root page, `detail/:id` captures params). Contextual component maps are keyed by their
192
313
  * matching manifest panel ids and mount only in the corresponding host surface. */
@@ -202,6 +323,8 @@ export interface PluginUiRegistration {
202
323
  accountChip?: Record<string, ComponentType<PluginPageProps>>;
203
324
  user?: Record<string, ComponentType<PluginUserPanelProps>>;
204
325
  project?: Record<string, ComponentType<PluginProjectPanelProps>>;
326
+ /** Inline chat views keyed by the artifact `view` selected by the publishing plugin. */
327
+ chatArtifacts?: Record<string, ComponentType<PluginChatArtifactProps>>;
205
328
  settings?: Record<string, ComponentType<PluginPageProps>>;
206
329
  /** Ids of `settings` sections that draw their OWN page frame — the shell, the masthead and the save
207
330
  * indicator — and therefore want none from the host.
@@ -229,6 +352,7 @@ export interface ElowenUiRuntime {
229
352
  jsxRuntime: typeof JsxRuntime;
230
353
  components: Record<string, ComponentType<never>> & {
231
354
  AutoSaveStatus: ComponentType<AutoSaveStatusProps>;
355
+ ProjectIcon: ComponentType<ProjectIconProps>;
232
356
  };
233
357
  /** Curated React hooks (i18n, toasts, the app's react-query data hooks). Safe across the boundary:
234
358
  * the bundle runs on the HOST's React instance, so the rules of hooks hold. A bundle narrows each
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** The single source of truth for the plugin browser-UI contract version. The web app installs
2
2
  * `window.ElowenUiRuntime` stamped with this number; a bundle whose `requiresApiVersion` is NEWER
3
- * renders a placeholder instead of executing against a contract it was not built for. Bump on
4
- * incompatible changes to the `ElowenUiRuntime` surface (see index.d.ts). */
5
- export const PLUGIN_UI_API_VERSION = 12;
3
+ * renders a placeholder instead of executing against a contract it was not built for. Bump whenever
4
+ * a released bundle requires a newly published `ElowenUiRuntime` contract (see index.d.ts). */
5
+ export const PLUGIN_UI_API_VERSION = 16;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elowen-plugin-ui-kit",
3
- "version": "0.7.0",
3
+ "version": "0.11.0",
4
4
  "description": "Contract types and esbuild toolchain for building Elowen plugin browser-UI bundles.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/theme.css CHANGED
@@ -40,6 +40,62 @@
40
40
  cannot see, inherits a correct sticky colour without declaring one. */
41
41
  --color-sticky: color-mix(in srgb, var(--color-document) 94%, var(--color-foreground) 6%);
42
42
 
43
+ /* The primary navigation column's own surfaces — shadcn's `sidebar` namespace, which the adopted
44
+ `components/ui/shadcn/sidebar.tsx` compiles its `bg-sidebar` / `bg-sidebar-accent` utilities from.
45
+ A sidebar is a chrome region rather than a document surface: it sits one step quieter than the
46
+ content it insets from, its rows take a fill of their own that is not `--color-muted` (a CONTENT
47
+ step that moves with the cards), and its rules are drawn against its own ground.
48
+ A design with no opinion inherits the aliases below and looks exactly as it did, so this costs
49
+ nothing to a skin that does not want the distinction. They live in `@theme` rather than `:root`
50
+ because Tailwind only emits a `bg-sidebar` utility for a token it can see here; the plugin-ui-kit
51
+ mirror carries them for that reason alone — a plugin bundle still has no sidebar to paint. */
52
+ --color-sidebar: var(--color-background);
53
+ --color-sidebar-foreground: var(--color-muted-foreground);
54
+ --color-sidebar-accent: var(--color-muted);
55
+ --color-sidebar-accent-foreground: var(--color-foreground);
56
+ --color-sidebar-border: var(--color-border);
57
+ --color-sidebar-ring: var(--color-ring);
58
+ /* NOT a shadcn token. The rule down the column's OUTER edge, and it now ALIASES
59
+ --color-sidebar-border rather than holding a colour of its own: the frame the top bar draws across
60
+ the window turns the corner and runs the full height of the column, so the vertical line and the
61
+ horizontal one have to resolve to one value or the frame changes colour where they meet. Owner
62
+ decision, 6 Sep 2026, overriding the transparent outer edge of 5 Sep — the column is framed on its
63
+ right the way the page is framed on top. The name stays because the two edges are still separate
64
+ DECISIONS: a design that wants its column to melt into the canvas sets this one token to
65
+ `transparent` and keeps the internal hairlines (header, footer, search field). */
66
+ --color-sidebar-rule: var(--color-sidebar-border);
67
+
68
+ /* The column's GEOMETRY, as measured off the Cloudflare dashboard (plans/cloudflare-sidebar-spec.md).
69
+ Separate tokens from the colours above because they are a different kind of decision: a skin
70
+ repaints the sidebar constantly and moves these almost never, and the primitive's collapsed rail
71
+ has to agree with the shell's flex basis or the fold animates to the wrong width.
72
+ `--sidebar-width-icon` is read by `components/ui/shadcn/sidebar.tsx`, which upstream injects as an
73
+ inline style — it is declared here so a skin can still override it. Its value is the reference's
74
+ measured 57px and it is stated ONCE, here: both Studio skins used to restate the same number, which
75
+ is two sources of truth for one measurement and a drift nothing would have caught. */
76
+ --sidebar-width: 16.25rem; /* 260px, the expanded column */
77
+ --sidebar-width-icon: 3.5625rem; /* 57px, the collapsed rail */
78
+ --sidebar-width-mobile: 18rem; /* the off-canvas presentation, wider than the desktop column */
79
+ --sidebar-row-height: 2.125rem; /* 34px, `min-h-8.5` in the reference */
80
+ --sidebar-row-radius: 0.5rem; /* 8px, `rounded-lg` in the reference's own scale */
81
+ --sidebar-row-inset: 0.75rem; /* 12px horizontal padding inside a row */
82
+ --sidebar-sub-indent: 1.75rem; /* 28px, `pl-7` per nesting level */
83
+ --sidebar-text: 0.8125rem; /* 13px, every row and group label */
84
+ --sidebar-header-height: 3.625rem; /* 58px, the switcher row above the header hairline */
85
+ --sidebar-switcher-height: 2.5rem; /* 40px, the switcher itself, centred inside that header */
86
+ /* The page's top bar is the SAME height as the sidebar header, because the hairline under each is ONE
87
+ line across the whole window: the column's header and the page's bar are two halves of one frame,
88
+ and 58px beside a 50px bar is what left a visible step where the two met. Stated as an ALIAS rather
89
+ than as a second 58px — they are one measurement, so a design that retunes the frame moves both by
90
+ moving the sidebar header. */
91
+ --topbar-height: var(--sidebar-header-height);
92
+ --sidebar-footer-height: 3rem; /* 48px, the fold's row below the footer hairline */
93
+ --sidebar-search-height: 2rem; /* 32px, the command palette's field under the header */
94
+ /* The disclosure caret's own duration. Not `--motion-base`: the Studio skins retune that to 150ms for
95
+ navigation, and the reference turns this caret in 200ms regardless of how fast the rest of the
96
+ column moves. A rotation is the one motion in the menu the eye follows to its end. */
97
+ --sidebar-caret-motion: 200ms;
98
+
43
99
  --color-border: #242424;
44
100
  /* NOT a shadcn token: the heavier rule, for a scrollbar thumb and a focused edge. */
45
101
  --color-border-strong: #363636;
@@ -74,6 +130,25 @@
74
130
  --color-warning: #edae49;
75
131
  --color-info: #62a8ea;
76
132
 
133
+ /* Code and diff surfaces. Mirrored from the host so a plugin sheet compiled against this kit resolves
134
+ the same values; the app renders diffs itself, so a plugin bundle has none of its own to paint. */
135
+ --color-diff-canvas: #0d0d10;
136
+ --color-diff-add: color-mix(in srgb, #005f00 30%, var(--color-diff-canvas));
137
+ --color-diff-add-marker: #50c850;
138
+ --color-diff-del: color-mix(in srgb, #5f0000 30%, var(--color-diff-canvas));
139
+ --color-diff-del-marker: #dc5a5a;
140
+ --color-diff-del-foreground: #f8f8f2;
141
+ --color-diff-gutter: #7d7d88;
142
+
143
+ --color-code-plain: #f8f8f2;
144
+ --color-code-keyword: #f92672;
145
+ --color-code-string: #e6db74;
146
+ --color-code-number: #ae81ff;
147
+ --color-code-comment: #75715e;
148
+ --color-code-type: #66d9ef;
149
+ --color-code-function: #a6e22e;
150
+ --color-code-punct: #f8f8f2;
151
+
77
152
  /* The categorical series. Charts and legends read these and never a literal, so a repaint moves the
78
153
  whole set. 1–3 are the brand ramp; 4 and 5 are deliberately UNLIKE it, so a swatch stays
79
154
  distinguishable from its neighbours without borrowing `destructive` or `success` and implying a
@@ -95,8 +170,55 @@
95
170
  --radius-2xl: 1.25rem;
96
171
 
97
172
  --font-mono: var(--font-geist-mono), ui-monospace, "SF Mono", monospace;
98
- --font-sans: var(--font-geist-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
99
- --font-display: var(--font-geist-sans), var(--font-sans);
173
+
174
+ /* Inter Variable is the app's ONE sans face, headings included. It is self-hosted
175
+ (`@fontsource-variable/inter`, imported in app/layout.tsx), so no rule here reaches Google Fonts.
176
+
177
+ `--font-geist-sans` is a COMPATIBILITY ALIAS, not a Geist reference: Geist Sans is no longer loaded,
178
+ but plugin stylesheets compiled against an older plugin-ui-kit still say `var(--font-geist-sans)`,
179
+ and an undefined variable inside a font-family list invalidates the whole declaration — the plugin
180
+ would fall back to the browser default rather than to Inter. Declaring it here keeps those bundles
181
+ rendering in the app's face. It is not a name new code should use; `--font-sans` is.
182
+
183
+ `--font-display` is `--font-sans` on purpose. It stays a separate token because a skin is entitled
184
+ to give headings a face of their own, but the built-in designs no longer do. */
185
+ --font-geist-sans: "Inter Variable", Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
186
+ --font-sans: var(--font-geist-sans);
187
+ --font-display: var(--font-sans);
188
+
189
+ /* Inter is drawn on a wider sidebearing than the reference design wants. The measured dashboard runs
190
+ -0.16px at 16px and -0.13px at 13px, which is the same optical correction expressed twice; as a
191
+ ratio it is -0.01em, so ONE token tracks both sizes and every other size in between. */
192
+ --tracking-ui: -0.01em;
193
+
194
+ /* ---- The type scale -------------------------------------------------------------------------
195
+ Five steps, measured off the reference dashboard (plans/cloudflare-sidebar-spec.md). Each one is a
196
+ Tailwind v4 TEXT token, which means it carries its line height, its weight and — where it differs
197
+ from the inherited ratio — its tracking with it. That is the property the app was missing: a
198
+ heading used to be assembled from a size here, a weight there and a letter-spacing somewhere else,
199
+ so every surface that wanted "a section title" wrote its own three-quarters of one, and no two
200
+ agreed. A rule now reads `var(--text-section-title)` and the rest of the step is one variable away.
201
+
202
+ `--text-page-title` is the ONE step that states its tracking absolutely. `--tracking-ui` above is a
203
+ RATIO, which is what makes the reference's -0.16px at 16px and -0.13px at 13px the same correction;
204
+ at 30px that ratio gives -0.3px, while the reference still runs -0.16px on its page title. A ratio
205
+ cannot express both, so the outlier says so here rather than in whichever stylesheet noticed. */
206
+ --text-page-title: 1.875rem; /* 30px, the single <h1> a page has */
207
+ --text-page-title--line-height: 2.25rem; /* 36px */
208
+ --text-page-title--font-weight: 600;
209
+ --text-page-title--letter-spacing: -0.16px;
210
+ --text-section-title: 1rem; /* 16px, the heading of a section card */
211
+ --text-section-title--line-height: 1.5rem;
212
+ --text-section-title--font-weight: 600;
213
+ --text-card-title: 0.875rem; /* 14px, the name of one record inside a card */
214
+ --text-card-title--line-height: 1.25rem;
215
+ --text-card-title--font-weight: 500;
216
+ --text-meta: 0.8125rem; /* 13px, the quiet line under a title */
217
+ --text-meta--line-height: 1.25rem;
218
+ --text-meta--font-weight: 400;
219
+ --text-body: 1rem; /* 16px/24px, the document's own step */
220
+ --text-body--line-height: 1.5rem;
221
+ --text-body--font-weight: 400;
100
222
 
101
223
  --text-display: clamp(2rem, 1.55rem + 1.5vw, 3.25rem);
102
224
  --text-caption: 0.6875rem;
@@ -112,6 +234,46 @@
112
234
  --glow-soft: 0 0 44px rgb(var(--primary-rgb) / 0.1);
113
235
  --hairline: 1px solid rgb(255 255 255 / 0.075);
114
236
 
237
+ /* ---- Depth ----------------------------------------------------------------------------------
238
+ NOT shadcn tokens. shadcn's depth vocabulary is Tailwind's shadow scale, which is a set of fixed
239
+ black shadows — on a true-black canvas every one of them is a no-op, and on a near-white one they
240
+ are all too heavy. These four name the depth EFFECTS the app actually composes, so each design
241
+ states its own version of them once instead of every card guessing.
242
+
243
+ `--shadow-lift` is the hover step above `--shadow-card`: a card that rises under the pointer. It is
244
+ a peer of `--shadow-card`, not a replacement — a resting card keeps the quieter one.
245
+
246
+ `--glow-accent` is the accent halo a FOCUSED or ACTIVE surface takes: the composer under the caret,
247
+ the navigation row you are standing on. Distinct from `--glow-active`, which is the loud ember ring
248
+ the spatial design paints on its orbit nodes; this one has to stay usable behind body text.
249
+
250
+ `--glass-surface` + `--glass-blur` are the translucent working surface (the composer). The fill is a
251
+ MIX of a surface token rather than a fixed alpha white, so a design that has no light to scatter can
252
+ turn the effect off by mixing to 100% and setting the blur to 0 without touching a component. */
253
+ --shadow-lift: 0 1px 0 rgb(255 255 255 / 0.05), 0 24px 60px rgb(0 0 0 / 0.34);
254
+ --glow-accent: 0 0 0 1px rgb(var(--primary-rgb) / 0.28), 0 0 30px rgb(var(--primary-rgb) / 0.18);
255
+
256
+ /* NOT a shadcn token. The resting depth of the ONE loud control on a page: a 1px ring in the brand
257
+ colour plus a short drop under it. The reference dashboard's primary button is not a flat rectangle
258
+ of blue — it has a defined edge and something beneath it, and that is most of why it reads as the
259
+ action rather than as a coloured label.
260
+
261
+ A separate token from `--glow-accent`, which is the FOCUS/ACTIVE halo and is therefore soft and
262
+ wide; this one is tight, opaque and always on. Composed from `--primary-rgb` so a white-label theme
263
+ retints it by moving that one variable, and each design states its own drop, because the value that
264
+ reads on a near-white page is a no-op on a true-black one. */
265
+ --shadow-primary: 0 0 0 1px rgb(var(--primary-rgb) / 0.9), 0 1px 2px rgb(0 0 0 / 0.4);
266
+ --glass-surface: color-mix(in srgb, var(--color-card) 62%, transparent);
267
+ --glass-border: color-mix(in srgb, var(--color-foreground) 10%, transparent);
268
+ --glass-blur: 18px;
269
+
270
+ /* The accent atmosphere behind a hero. Two off-centre radial stops rather than one centred wash: a
271
+ single centred glow reads as a vignette bug, two offset ones read as light entering the page. It is
272
+ an IMAGE, so it composes over whatever canvas the design owns and needs no surface of its own. */
273
+ --mesh-accent:
274
+ radial-gradient(62% 78% at 16% -8%, rgb(var(--primary-rgb) / 0.16), transparent 68%),
275
+ radial-gradient(52% 68% at 86% 4%, rgb(var(--primary-rgb) / 0.1), transparent 70%);
276
+
115
277
  --motion-instant: 80ms;
116
278
  --motion-fast: 140ms;
117
279
  --motion-base: 240ms;