@bloomscorp/blooms-ai-embed 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@bloomscorp/blooms-ai-embed`.
4
+
5
+ ## 0.1.0
6
+
7
+ First release.
8
+
9
+ - Ships the prebuilt blooms.ai element bundle (`blooms-embed.js`) and its shared stylesheet
10
+ (`blooms-embed.css`) as an npm package, so hosts — Electron in particular — can bundle the
11
+ components locally instead of loading remote code into a renderer, and work offline.
12
+ - Importing the package defines the custom elements (`<blooms-login>`) as a side effect and
13
+ exports the typed `BloomsEmbed` SDK: `configure`, `mode`, `manifest`, `login`, `session`,
14
+ `resume`, `can`, `logout`, `on`.
15
+ - Hand-written TypeScript declarations for the whole public surface, including
16
+ `<blooms-login>` in `HTMLElementTagNameMap` and `BloomsEmbed` on `Window`.
17
+ - Importing outside a browser throws an explanatory error naming the likely cause (SSR, or the
18
+ Electron main process instead of the renderer) instead of failing on a missing `HTMLElement`.
19
+ - Integrator documentation: quick start, API reference, events and error codes, theming,
20
+ framework notes (plain HTML, React 19, React <= 18 wrapper, Angular), and Electron setup.
package/README.md ADDED
@@ -0,0 +1,456 @@
1
+ # `@bloomscorp/blooms-ai-embed`
2
+
3
+ The blooms.ai embeddable UI, as a prebuilt npm package: import it and you get blooms.ai
4
+ components as custom elements in your own dashboard, plus a small JS SDK for sign-in and
5
+ session state.
6
+
7
+ It is the same bundle blooms.ai serves from its own domain, published to npm so you can ship
8
+ it inside your app. That matters for **Electron** — loading remote code into a renderer is
9
+ against Electron's security guidance — and for anything that has to work offline or behind a
10
+ locked-down CSP.
11
+
12
+ ```
13
+ npm install @bloomscorp/blooms-ai-embed
14
+ ```
15
+
16
+ Requires a browser (or an Electron renderer). Node >= 18 to install. No runtime dependencies.
17
+
18
+ ---
19
+
20
+ ## The two credentials
21
+
22
+ A **project embed token** (`blm_embed_…`) identifies *your integration*. It is publishable —
23
+ it ships in your frontend — and on its own it grants nothing: no data, no project access, not
24
+ even a directory of users. It is only accepted from the web origins a blooms.ai admin has
25
+ registered on it.
26
+
27
+ A **user session** is what actually authorizes data. Each of your users signs in with their
28
+ own blooms.ai account, and the server returns only what that account is already allowed to see
29
+ in that project, narrowed further to the features enabled on your token. No session, no data.
30
+
31
+ > Ask your blooms.ai contact to register every origin you will load from (e.g.
32
+ > `https://app.example.com`, `http://localhost:5173`, `app://your-desktop-app`). An
33
+ > unregistered origin fails CORS, which browsers report to JavaScript as a generic network
34
+ > error — the SDK rewrites that into an explicit message, but the fix is always "register the
35
+ > origin".
36
+
37
+ ---
38
+
39
+ ## Quick start
40
+
41
+ ```html
42
+ <script type="module">
43
+ import { BloomsEmbed } from '@bloomscorp/blooms-ai-embed';
44
+
45
+ BloomsEmbed.configure({
46
+ token: 'blm_embed_9f2c…',
47
+ apiBase: 'https://api.blooms.ai',
48
+ });
49
+
50
+ BloomsEmbed.on('session', event => {
51
+ console.log(event.session ? `signed in as ${event.session.user.email}` : 'signed out');
52
+ });
53
+ </script>
54
+
55
+ <blooms-login></blooms-login>
56
+ ```
57
+
58
+ That is the whole integration. Importing the package defines the elements and installs the SDK;
59
+ `<blooms-login>` renders the sign-in form, remembers the session across reloads, and revalidates
60
+ it on the next load.
61
+
62
+ Three things worth knowing:
63
+
64
+ - **`configure()` once, early.** Elements placed in your HTML upgrade the moment the package
65
+ evaluates, which is before your `configure()` call runs. They wait for it rather than failing,
66
+ showing a skeleton meanwhile — so a slow `configure()` is a slow first paint, not an error.
67
+ - **`apiBase` is optional** when you load the bundle from blooms.ai, because it defaults to the
68
+ bundle's own origin. When you install from npm the bundle's origin is *your* app, so **pass
69
+ `apiBase` explicitly**.
70
+ - **Place elements unconditionally.** Feature elements render a sign-in prompt until a session
71
+ exists; you do not have to gate them yourself.
72
+
73
+ ---
74
+
75
+ ## `BloomsEmbed` API
76
+
77
+ | Member | Signature | Notes |
78
+ |---|---|---|
79
+ | `version` | `string` | Bundle version. |
80
+ | `configure` | `(config: BloomsEmbedConfig) => void` | Call first. Throws on a missing token or a token that is not `blm_embed_…`. |
81
+ | `mode` | `() => 'element' \| 'iframe' \| null` | The resolved rendering mode; `null` before `configure()`. |
82
+ | `manifest` | `() => Promise<EmbedManifest>` | Pre-login bootstrap: project name, enabled features, theme, branding flag, session TTL. |
83
+ | `login` | `({ email, password }) => Promise<EmbedSessionState>` | Programmatic alternative to `<blooms-login>`, for hosts with their own form. |
84
+ | `session` | `() => EmbedSessionState \| null` | The current session, restored from storage by `configure()`. Synchronous. |
85
+ | `resume` | `() => Promise<EmbedSessionState \| null>` | Revalidates a restored session server-side and re-reads permissions. `null` if it is no longer good. |
86
+ | `can` | `(featureKey: string) => boolean` | Local check against the session's `grantedKeys`. Use it for UI, never as your only gate — the server re-checks every request. |
87
+ | `logout` | `() => Promise<void>` | Revokes the session server-side and clears storage. |
88
+ | `on` | `(type, handler) => () => void` | Subscribe; returns an unsubscribe function. `type` is `'session'`, `'error'`, `'navigate'` or `'all'`. |
89
+
90
+ ### `configure()` options
91
+
92
+ | Option | Type | Default | Notes |
93
+ |---|---|---|---|
94
+ | `token` | `string` | — | Required. `blm_embed_…`. Never put a secret API key (`blm_live_…`) in a browser. |
95
+ | `apiBase` | `string` | the bundle's origin | Pass it explicitly when installing from npm. |
96
+ | `storage` | `'local' \| 'session' \| 'memory'` | `'local'` | `'local'` survives reloads; `'memory'` keeps nothing on disk. Every storage access is already guarded, so private windows and blocked site data degrade to memory rather than throwing. |
97
+ | `mode` | `'auto' \| 'element' \| 'iframe'` | `'auto'` | See [Rendering modes](#rendering-modes). |
98
+ | `theme` | `Record<string, string>` | `{}` | CSS custom properties, applied to each element's shadow root. Only `--…` names are accepted. |
99
+
100
+ ### Session shape
101
+
102
+ ```ts
103
+ {
104
+ token: string; // access token — the elements send it for you
105
+ refreshToken: string;
106
+ expiresAt: number; // epoch ms
107
+ user: { id, name, email, avatar: string | null };
108
+ project: { id, name };
109
+ grantedKeys: string[]; // feature keys, already narrowed to your token's ceiling
110
+ isProjectOwner: boolean;
111
+ }
112
+ ```
113
+
114
+ Refresh is automatic: the SDK refreshes stale access tokens and retries a 401 once, and
115
+ concurrent calls share a single in-flight refresh. You never need to touch `refreshToken`.
116
+
117
+ ---
118
+
119
+ ## Events
120
+
121
+ ```js
122
+ const off = BloomsEmbed.on('all', event => { /* … */ });
123
+ off(); // unsubscribe
124
+ ```
125
+
126
+ | Event | Payload | When |
127
+ |---|---|---|
128
+ | `session` | `{ type, session: EmbedSessionState \| null, mode }` | Sign-in, refresh, sign-out, expiry. `session: null` means signed out — show `<blooms-login>` again. |
129
+ | `error` | `{ type, code, message, status? }` | Any surfaced failure. Branch on `code`, not on `message`. |
130
+ | `navigate` | `{ type, feature, view, params }` | A component moved between its own views. Ignore it, or mirror it in your own URL. |
131
+
132
+ `<blooms-login>` also emits DOM events, so a host that never touches the SDK object still works:
133
+ `bloomssession` (`event.detail` is the session or `null`) and `bloomserror` (`event.detail` is the
134
+ error event above).
135
+
136
+ Codes you should actually handle:
137
+
138
+ | Code | What to do |
139
+ |---|---|
140
+ | `EMBED_CREDENTIALS_INVALID` | Nothing — the login element shows it. |
141
+ | `EMBED_NO_PROJECT_ACCESS` | The account is valid but has no access to this project. Point the user at whoever administers their blooms.ai access. |
142
+ | `EMBED_ORIGIN_REJECTED` | Your origin is not registered on the token. Contact blooms.ai. |
143
+ | `EMBED_TOKEN_REVOKED` / `EMBED_PROJECT_INACTIVE` | Hide the integration; retrying will not help. |
144
+ | `EMBED_SESSION_EXPIRED` / `EMBED_SESSION_REVOKED` | Show sign-in again. |
145
+ | `EMBED_RATE_LIMITED` | Back off; `Retry-After` is honoured internally for requests. |
146
+ | `EMBED_FEATURE_DISABLED` | The feature is not enabled on your integration. |
147
+ | `EMBED_NETWORK` | Unreachable API base — very often an unregistered origin (see above). |
148
+
149
+ ---
150
+
151
+ ## `<blooms-login>`
152
+
153
+ ```html
154
+ <blooms-login heading="Sign in to Acme Insights"></blooms-login>
155
+ ```
156
+
157
+ | Property | Attribute | Type | Purpose |
158
+ |---|---|---|---|
159
+ | `heading` | `heading` | `string` | Overrides the heading copy. |
160
+ | `subheading` | `subheading` | `string` | Overrides the default "Continue to *project*". |
161
+ | `compact` | `compact` | `boolean` | Drops the card chrome, for dropping into a panel you already styled. |
162
+
163
+ Attributes arrive as strings, so `compact="false"` is truthy. For booleans set the property
164
+ (`el.compact = true`) or leave the attribute off entirely.
165
+
166
+ The element handles its own states: loading skeleton, signed-out form, signed-in summary, and
167
+ distinct messages for a wrong password, an account without access to the project, a deactivated
168
+ or banned account, and rate limiting.
169
+
170
+ Google sign-in is not offered. Accounts created with Google must set a password in the blooms.ai
171
+ portal before they can use this form.
172
+
173
+ ---
174
+
175
+ ## Styles and theming
176
+
177
+ `<blooms-login>` is fully self-styled: it renders correctly with no stylesheet of yours and no
178
+ extra request. Every element uses shadow DOM, so your CSS cannot reach in and ours cannot leak
179
+ out.
180
+
181
+ Feature components additionally use the shared stylesheet `blooms-embed.css`. At runtime the
182
+ bundle fetches it from beside itself — `<the bundle's own directory>/blooms-embed.css`, which is
183
+ the CDN in production. This package ships an identical
184
+ copy so you can serve it from your own app instead — required if you must work offline, or if
185
+ your CSP will not allow the fetch:
186
+
187
+ ```js
188
+ import '@bloomscorp/blooms-ai-embed/blooms-embed.css'; // if your bundler handles CSS imports
189
+ ```
190
+
191
+ ```html
192
+ <!-- or copy it out of node_modules and serve it yourself -->
193
+ <link rel="stylesheet" href="/assets/blooms-embed.css" />
194
+ ```
195
+
196
+ Theme with CSS custom properties — either through `configure({ theme })` or on the element itself.
197
+ These are read with fallbacks, so set only what you care about:
198
+
199
+ | Property | Default |
200
+ |---|---|
201
+ | `--brand-primary` | `#5b3df6` (the accent: buttons, focus rings, links) |
202
+ | `--color-card-bg` | `#ffffff` light / `#1a1825` dark |
203
+ | `--color-text` | `#14121f` light / `#f4f3f8` dark |
204
+ | `--color-text-muted` | `#6b6880` light / `#a09db4` dark |
205
+ | `--color-border` | `#e7e5ee` light / `#2e2b3d` dark |
206
+ | `--radius-lg` | `16px` |
207
+ | `--shadow-md` | `0 8px 28px rgba(20, 18, 31, .10)` |
208
+ | `--font-sans` | system UI stack |
209
+
210
+ ```js
211
+ BloomsEmbed.configure({
212
+ token: 'blm_embed_…',
213
+ apiBase: 'https://api.blooms.ai',
214
+ theme: { '--brand-primary': '#0f766e', '--radius-lg': '10px' },
215
+ });
216
+ ```
217
+
218
+ Dark mode follows the host's `prefers-color-scheme` unless you set the properties explicitly.
219
+ Your blooms.ai admin can also set a default theme on the token; `configure({ theme })` wins over it.
220
+
221
+ ---
222
+
223
+ ## Framework notes
224
+
225
+ ### Plain HTML / Vue / Svelte / Solid
226
+
227
+ Nothing special. Import the package once at startup and use the tags.
228
+
229
+ ### React 19+
230
+
231
+ Works natively — React 19 sets unknown props as *properties* on custom elements, so object and
232
+ boolean props arrive intact. For events, subscribe through the SDK (the same code works in every
233
+ React version):
234
+
235
+ ```jsx
236
+ import { useEffect } from 'react';
237
+ import { BloomsEmbed } from '@bloomscorp/blooms-ai-embed';
238
+
239
+ BloomsEmbed.configure({ token: 'blm_embed_…', apiBase: 'https://api.blooms.ai' });
240
+
241
+ export function Login({ onSession }) {
242
+ useEffect(() => BloomsEmbed.on('session', e => onSession(e.session)), [onSession]);
243
+ return <blooms-login heading="Sign in" />;
244
+ }
245
+ ```
246
+
247
+ ### React <= 18
248
+
249
+ React 17/18 write every JSX prop as an **attribute** (strings only) and do not attach listeners
250
+ for lowercase custom events. Wrap the element once — properties via a ref, events via
251
+ `addEventListener`:
252
+
253
+ ```jsx
254
+ import { useEffect, useRef } from 'react';
255
+ import '@bloomscorp/blooms-ai-embed';
256
+
257
+ export function BloomsLogin({ heading, subheading, compact = false, onSession, onError }) {
258
+ const ref = useRef(null);
259
+
260
+ useEffect(() => {
261
+ const el = ref.current;
262
+ if (!el) return;
263
+ el.heading = heading ?? ''; // properties, not attributes
264
+ el.subheading = subheading ?? '';
265
+ el.compact = compact;
266
+ }, [heading, subheading, compact]);
267
+
268
+ useEffect(() => {
269
+ const el = ref.current;
270
+ if (!el) return;
271
+ const session = e => onSession?.(e.detail);
272
+ const error = e => onError?.(e.detail);
273
+ el.addEventListener('bloomssession', session);
274
+ el.addEventListener('bloomserror', error);
275
+ return () => {
276
+ el.removeEventListener('bloomssession', session);
277
+ el.removeEventListener('bloomserror', error);
278
+ };
279
+ }, [onSession, onError]);
280
+
281
+ return <blooms-login ref={ref} />;
282
+ }
283
+ ```
284
+
285
+ ### Angular host
286
+
287
+ Add `CUSTOM_ELEMENTS_SCHEMA` to the component or module that uses the tags, or Angular's
288
+ compiler rejects the unknown element and its properties:
289
+
290
+ ```ts
291
+ @Component({
292
+ selector: 'app-insights',
293
+ standalone: true,
294
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
295
+ template: `<blooms-login [compact]="true" (bloomssession)="onSession($event)"></blooms-login>`,
296
+ })
297
+ export class InsightsComponent {}
298
+ ```
299
+
300
+ The embed bundle is zoneless and boots its own Angular context, so it does not interfere with
301
+ your app's zone, router or DI — but include it **once** per page. Loading it twice (say, the npm
302
+ package plus a `<script>` tag) is detected and the second copy yields with a console warning,
303
+ which is a warning you should fix rather than live with.
304
+
305
+ ---
306
+
307
+ ## Electron
308
+
309
+ Element mode works in an Electron renderer as long as the renderer has a real, sendable origin.
310
+ Get a `clientType: 'desktop'` token from blooms.ai and register that origin on it.
311
+
312
+ **Install this package and bundle it.** Do not load the bundle over `<script src="https://…">`
313
+ into a renderer: remote code in a renderer is exactly what Electron's security checklist tells
314
+ you to avoid, and a bundled copy also works offline.
315
+
316
+ **Custom scheme (`app://`) — recommended.** Register the scheme as privileged before any window
317
+ loads, in the main process:
318
+
319
+ ```js
320
+ const { app, protocol, BrowserWindow } = require('electron');
321
+
322
+ protocol.registerSchemesAsPrivileged([{
323
+ scheme: 'app',
324
+ privileges: { standard: true, secure: true, corsEnabled: true, supportFetchAPI: true },
325
+ }]);
326
+ ```
327
+
328
+ Without `standard` + `secure` the renderer has an opaque origin, `fetch` is not available to it,
329
+ and the API will never authorize it. Then ask blooms.ai to register `app://your-app-name` on the
330
+ token.
331
+
332
+ **`http://localhost` renderers** work too — register the exact origin including the port.
333
+
334
+ **Never set `webSecurity: false`.** It is the usual "fix" for this class of problem and it
335
+ disables the protections that make embedding safe at all. If requests are being refused, the
336
+ cause is an unregistered origin or a missing privileged-scheme registration, not web security.
337
+
338
+ **CSP.** Your renderer's CSP must allow calls to the API and the loading of the stylesheet:
339
+
340
+ ```
341
+ default-src 'self' app:;
342
+ connect-src 'self' https://api.blooms.ai;
343
+ style-src 'self' 'unsafe-inline';
344
+ img-src 'self' data: https:;
345
+ ```
346
+
347
+ `connect-src` must name your `apiBase` exactly. `style-src 'unsafe-inline'` is needed because the
348
+ components inject their styles into shadow roots; if your policy forbids it, serve
349
+ `blooms-embed.css` from your own app and use a nonce-free `<link>` for it.
350
+
351
+ **`file://` renderers cannot use element mode.** A `file://` page has an opaque origin, so the
352
+ server has nothing to authorize, and the SDK falls back to the iframe automatically. That path
353
+ needs `allowFraming` and `allowOpaqueAncestor` set on your token by a blooms.ai admin — a
354
+ deliberate opt-in, because it means the frame cannot restrict who embeds it. Prefer a custom
355
+ scheme or `http://localhost` and keep element mode.
356
+
357
+ ---
358
+
359
+ ## Rendering modes
360
+
361
+ `configure({ mode: 'auto' })` is the default and picks:
362
+
363
+ | Situation | Mode |
364
+ |---|---|
365
+ | A normal page on a real origin (`https://…`, `http://localhost`, privileged `app://`) | `element` |
366
+ | `file://`, an opaque origin, or no `customElements` | `iframe` |
367
+
368
+ An explicit `mode` always wins. The resolved mode is reported on every `session` event and by
369
+ `BloomsEmbed.mode()`, so you can log which path a given install got. Both modes run the same
370
+ compiled components; the iframe exists so hosts that cannot present a registrable origin still
371
+ work.
372
+
373
+ ---
374
+
375
+ ## TypeScript
376
+
377
+ Types ship with the package — no `@types` install. The declarations also register
378
+ `<blooms-login>` in `HTMLElementTagNameMap`, so `document.querySelector('blooms-login')` is typed,
379
+ and add `BloomsEmbed` to `Window`.
380
+
381
+ ```ts
382
+ import { BloomsEmbed, type EmbedSessionState } from '@bloomscorp/blooms-ai-embed';
383
+
384
+ BloomsEmbed.configure({ token: 'blm_embed_…', apiBase: 'https://api.blooms.ai' });
385
+
386
+ const session: EmbedSessionState | null = await BloomsEmbed.resume();
387
+ if (session && BloomsEmbed.can('analytics')) { /* … */ }
388
+ ```
389
+
390
+ Exported types: `BloomsEmbedConfig`, `ResolvedEmbedConfig`, `EmbedMode`, `EmbedModeSetting`,
391
+ `EmbedStorage`, `EmbedUser`, `EmbedProject`, `EmbedSessionState`, `EmbedEvent` (plus
392
+ `EmbedSessionEvent`, `EmbedErrorEvent`, `EmbedNavigateEvent`), `EmbedManifest`, `BloomsEmbedSdk`,
393
+ `BloomsLoginElement`.
394
+
395
+ ---
396
+
397
+ ## Server-side rendering
398
+
399
+ The package touches the DOM as it loads, so importing it on a server throws a deliberate,
400
+ explanatory error rather than a confusing `ReferenceError`. Import it lazily on the client:
401
+
402
+ ```js
403
+ useEffect(() => { import('@bloomscorp/blooms-ai-embed').then(({ BloomsEmbed }) => BloomsEmbed.configure({ /* … */ })); }, []);
404
+ ```
405
+
406
+ In Electron, that also means: import it in the **renderer**, never in the main process.
407
+
408
+ ---
409
+
410
+ ## Security notes worth reading once
411
+
412
+ - The embed token is publishable, but it is not a secret to be careless with: it is what ties
413
+ usage back to your integration, and it is revocable. Secret API keys (`blm_live_…`) must never
414
+ reach a browser.
415
+ - Your users type blooms.ai credentials into a page you control. Shadow DOM is encapsulation, not
416
+ a security boundary — a compromised host page can read them. This is why embed tokens are
417
+ issued by blooms.ai admins to partners under contract rather than self-service.
418
+ - `BloomsEmbed.can()` is a UI convenience. The server re-authorizes every single request against
419
+ the user's live permissions, so a stale client-side `true` gets you a 403, not data.
420
+ - Every sign-in, refusal, refresh and feature action is logged against your integration and
421
+ visible to blooms.ai admins and to the project's owner.
422
+
423
+ ---
424
+
425
+ ## Support
426
+
427
+ Questions, origin registration, feature enablement and token rotation all go through your
428
+ blooms.ai contact. Include your project name and the integration name from the token.
429
+
430
+ ## Multiple projects
431
+
432
+ An integration can be scoped to several projects. The user signs in **once** and
433
+ switching is instant — no round trip, no re-authentication.
434
+
435
+ ```js
436
+ const projects = BloomsEmbed.projects(); // [{ id, name, grantedKeys, isOwner }, …]
437
+ BloomsEmbed.setProject(projects[1].id); // returns false if not permitted
438
+ BloomsEmbed.project(); // the active one
439
+ ```
440
+
441
+ Grants differ per project, so ask per project:
442
+
443
+ ```js
444
+ BloomsEmbed.can('keywords'); // the ACTIVE project
445
+ BloomsEmbed.can('keywords', someOtherId); // a specific one
446
+ ```
447
+
448
+ `can()` returns `false` for a project the session cannot act on — it does not
449
+ fall back to the active project's permissions.
450
+
451
+ `setProject()` emits a `session` event, so a listener added with
452
+ `BloomsEmbed.on('session', …)` re-renders on a switch without a separate
453
+ subscription.
454
+
455
+ A single-project integration reports one entry, and every field you already use
456
+ keeps its meaning — it describes the active project.
@@ -0,0 +1 @@
1
+ @charset "UTF-8";@font-face{font-family:Poppins;font-style:normal;font-weight:300;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v24/pxiByp8kv8JHgFVrLDz8Z11lFc-K.woff2) format("woff2");unicode-range:U+0900-097F,U+1CD0-1CF9,U+200C-200D,U+20A8,U+20B9,U+20F0,U+25CC,U+A830-A839,U+A8E0-A8FF,U+11B00-11B09}@font-face{font-family:Poppins;font-style:normal;font-weight:300;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v24/pxiByp8kv8JHgFVrLDz8Z1JlFc-K.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Poppins;font-style:normal;font-weight:300;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v24/pxiByp8kv8JHgFVrLDz8Z1xlFQ.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Poppins;font-style:normal;font-weight:400;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v24/pxiEyp8kv8JHgFVrJJbecmNE.woff2) format("woff2");unicode-range:U+0900-097F,U+1CD0-1CF9,U+200C-200D,U+20A8,U+20B9,U+20F0,U+25CC,U+A830-A839,U+A8E0-A8FF,U+11B00-11B09}@font-face{font-family:Poppins;font-style:normal;font-weight:400;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v24/pxiEyp8kv8JHgFVrJJnecmNE.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Poppins;font-style:normal;font-weight:400;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v24/pxiEyp8kv8JHgFVrJJfecg.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Poppins;font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v24/pxiByp8kv8JHgFVrLGT9Z11lFc-K.woff2) format("woff2");unicode-range:U+0900-097F,U+1CD0-1CF9,U+200C-200D,U+20A8,U+20B9,U+20F0,U+25CC,U+A830-A839,U+A8E0-A8FF,U+11B00-11B09}@font-face{font-family:Poppins;font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v24/pxiByp8kv8JHgFVrLGT9Z1JlFc-K.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Poppins;font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v24/pxiByp8kv8JHgFVrLGT9Z1xlFQ.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Poppins;font-style:normal;font-weight:600;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v24/pxiByp8kv8JHgFVrLEj6Z11lFc-K.woff2) format("woff2");unicode-range:U+0900-097F,U+1CD0-1CF9,U+200C-200D,U+20A8,U+20B9,U+20F0,U+25CC,U+A830-A839,U+A8E0-A8FF,U+11B00-11B09}@font-face{font-family:Poppins;font-style:normal;font-weight:600;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v24/pxiByp8kv8JHgFVrLEj6Z1JlFc-K.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Poppins;font-style:normal;font-weight:600;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v24/pxiByp8kv8JHgFVrLEj6Z1xlFQ.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Poppins;font-style:normal;font-weight:700;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v24/pxiByp8kv8JHgFVrLCz7Z11lFc-K.woff2) format("woff2");unicode-range:U+0900-097F,U+1CD0-1CF9,U+200C-200D,U+20A8,U+20B9,U+20F0,U+25CC,U+A830-A839,U+A8E0-A8FF,U+11B00-11B09}@font-face{font-family:Poppins;font-style:normal;font-weight:700;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v24/pxiByp8kv8JHgFVrLCz7Z1JlFc-K.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Poppins;font-style:normal;font-weight:700;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v24/pxiByp8kv8JHgFVrLCz7Z1xlFQ.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Poppins;font-style:normal;font-weight:800;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v24/pxiByp8kv8JHgFVrLDD4Z11lFc-K.woff2) format("woff2");unicode-range:U+0900-097F,U+1CD0-1CF9,U+200C-200D,U+20A8,U+20B9,U+20F0,U+25CC,U+A830-A839,U+A8E0-A8FF,U+11B00-11B09}@font-face{font-family:Poppins;font-style:normal;font-weight:800;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v24/pxiByp8kv8JHgFVrLDD4Z1JlFc-K.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Poppins;font-style:normal;font-weight:800;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v24/pxiByp8kv8JHgFVrLDD4Z1xlFQ.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{--color-primary-soft: #E3D8F1;--color-secondary: #DABECA;--color-accent: #E84855;--color-accent-soft: #f5c5c9;--color-text: #5D5F71;--color-text-muted: #9395a5;--color-bg: #f7f5fb;--color-card-bg: #FFFFFF;--color-sidebar-bg: #FFFFFF;--color-rail-bg: #f3f0f8;--color-border: #ede9f6;--color-hover: #f5f2fb;--font-sans: "Poppins", system-ui, -apple-system, sans-serif;--radius-sm: 6px;--radius-md: 10px;--radius-lg: 16px;--radius-full: 9999px;--shadow-sm: 0 1px 4px rgba(0, 0, 0, .06);--shadow-md: 0 4px 16px rgba(0, 0, 0, .08);--shadow-lg: 0 8px 32px rgba(0, 0, 0, .1);--z-agent-button: 9000;--z-agent-widget: 9100;--z-agent-menu: 9200;--z-agent-banner: 9300}@keyframes agent-halo-spin{to{transform:rotate(360deg)}}@keyframes agent-halo-pulse{0%,to{opacity:.8}50%{opacity:1}}*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}html{font-size:16px;-webkit-text-size-adjust:100%;scroll-behavior:smooth}body{font-family:var(--font-sans);font-size:14px;color:var(--color-text);background-color:var(--color-bg);line-height:1.6;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.bui-tooltip{position:fixed;z-index:100000;max-width:260px;padding:7px 11px;border-radius:12px;background:#fff;color:#1d1d1f;border:1px solid rgba(0,0,0,.06);box-shadow:0 8px 26px #00000029,0 2px 6px #0000001a;font-family:var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);font-size:12.5px;font-weight:500;line-height:1.45;letter-spacing:.01em;text-align:left;white-space:normal;word-break:break-word;pointer-events:none;opacity:0;transform:scale(.92);transform-origin:center;transition:opacity .13s ease,transform .14s cubic-bezier(.34,1.56,.64,1)}.bui-tooltip--in{opacity:1;transform:scale(1)}.bui-tooltip__tail{position:absolute;z-index:-1;width:9px;height:9px;background:#fff;border:1px solid rgba(0,0,0,.06);border-radius:2px;transform:rotate(45deg)}.bui-tooltip__tail.is-bottom{bottom:-5px}.bui-tooltip__tail.is-top{top:-5px}.bui-tooltip__tail.is-right{right:-5px}.bui-tooltip__tail.is-left{left:-5px}@media (prefers-reduced-motion: reduce){.bui-tooltip{transition:opacity .1s ease;transform:none}.bui-tooltip--in{transform:none}}h1,h2,h3,h4,h5,h6{font-family:var(--font-sans);font-weight:700;line-height:1.3;color:var(--color-text)}a{color:var(--color-accent);text-decoration:none}a:hover{text-decoration:underline}p{line-height:1.7}.btn{display:inline-flex;align-items:center;gap:6px;padding:9px 18px;border-radius:var(--radius-md);font-family:var(--font-sans);font-size:13px;font-weight:600;cursor:pointer;border:1px solid transparent;transition:background .15s,border-color .15s,box-shadow .15s,opacity .15s;text-decoration:none;white-space:nowrap}.btn:disabled{opacity:.5;cursor:not-allowed;pointer-events:none}.btn-primary{background:var(--color-accent);color:#fff;border-color:var(--color-accent)}.btn-primary:hover{background:#d13d4a;border-color:#d13d4a;box-shadow:0 2px 8px #e848554d;text-decoration:none}.btn-outline{background:transparent;color:var(--color-text);border-color:var(--color-border)}.btn-outline:hover{background:var(--color-hover);border-color:#d5d0e4;text-decoration:none}.btn-ghost{background:transparent;color:var(--color-text-muted);border-color:transparent}.btn-ghost:hover{background:var(--color-hover);color:var(--color-text)}.btn-sm{padding:6px 12px;font-size:12px;border-radius:var(--radius-sm)}.btn-lg{padding:12px 24px;font-size:15px;border-radius:12px}input,textarea,select{font-family:var(--font-sans);font-size:14px;color:var(--color-text)}.text-muted{color:var(--color-text-muted)}.text-accent{color:var(--color-accent)}.text-sm{font-size:12px}.text-xs{font-size:11px}.font-semibold{font-weight:600}.font-bold{font-weight:700}.flex{display:flex}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-2{gap:8px}.gap-4{gap:16px}.w-full{width:100%}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--color-border);border-radius:3px}::-webkit-scrollbar-thumb:hover{background:var(--color-secondary)}router-outlet+*{animation:fadeInUp .2s ease}@keyframes fadeInUp{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}.pane-section{display:flex;flex-direction:column;gap:24px;max-width:520px}.pane-hd{display:flex;flex-direction:column;gap:4px;padding-bottom:20px;border-bottom:1px solid var(--color-border)}.pane-hd--row{flex-direction:row;align-items:flex-start;justify-content:space-between;gap:16px}.pane-title{font-size:15px;font-weight:700;color:var(--color-text);margin:0}.pane-desc{font-size:12px;color:var(--color-text-muted);margin:0;line-height:1.5}.pane-form{display:flex;flex-direction:column;gap:18px}.pane-success{font-size:12px;font-weight:500;color:#065f46;background:#d1fae5;border:1px solid #a7f3d0;border-radius:var(--radius-sm);padding:8px 12px;margin:0}.field{display:flex;flex-direction:column;gap:6px}.field__label{font-size:12px;font-weight:600;color:var(--color-text);letter-spacing:.02em;text-transform:uppercase}.field__input{padding:9px 13px;border:1px solid var(--color-border);border-radius:var(--radius-md);font-family:var(--font-sans);font-size:13px;color:var(--color-text);background:var(--color-bg);outline:none;transition:border-color .15s,box-shadow .15s;width:100%}.field__input:focus{border-color:var(--color-accent);box-shadow:0 0 0 3px #e8485514}.field__input--locked{opacity:.55;cursor:not-allowed;color:var(--color-text-muted)}.field__input:disabled{cursor:not-allowed}.field textarea.field__input{resize:vertical}.field__hint{font-size:11px;color:var(--color-text-muted)}@media (max-width: 768px){.btn{padding:8px 14px}.detail-table-wrap,.pages-table-wrap,.diff-table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch}.result-summary,.insight-grid,.pr-stats-grid{grid-template-columns:1fr 1fr!important}.sitemap-form{flex-direction:column}.pr-detail__cols,.diff-screen__body{grid-template-columns:1fr!important}.diff-files{display:none}.diff-files.mobile-show{display:block}.pr-filters__top{flex-direction:column;align-items:flex-start}.pr-filters__right{width:100%}.pr-search{max-width:100%!important;flex:1}.repo-card{flex-direction:column;align-items:flex-start}.repo-card__right{align-self:flex-start;flex-wrap:wrap}.history-item{flex-direction:column}.results-toolbar{flex-direction:column;align-items:flex-start}.diff-screen__title-row{flex-direction:column}.diff-screen__header-right{align-self:flex-start}}