infaira-canvas 0.1.9

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.
@@ -0,0 +1,890 @@
1
+ # ICan Widget Styling Patterns
2
+
3
+ > **Read this before you write a single line of widget CSS.**
4
+ > This guide explains the four ways to style an ICan widget, what theme support you get with each, and the gotchas to know about. It complements [`ICan-Widget-Theming-Guide.md`](./ICan-Widget-Theming-Guide.md) (the exhaustive variable reference) — this file is about *decisions and concepts*, not variable lookups.
5
+
6
+ ---
7
+
8
+ ## 1. Purpose & how to use this doc
9
+
10
+ When you build an ICan widget, you have to choose *how much* of the ICan design system to adopt. There are four reasonable approaches. Each has different theme behavior and different gotchas. This doc walks you through them so you can pick the right one for your widget — and so an AI coding agent helping you can pick correctly without trial and error.
11
+
12
+ If you're an AI agent, jump to **section 10** first.
13
+
14
+ ---
15
+
16
+ ## 2. TL;DR — decision tree
17
+
18
+ Ask yourself three questions in order:
19
+
20
+ 1. **Do I want my widget to retint when the user switches theme?**
21
+ - **Yes** → use `var(--ican-*)` for every color. Go to question 2.
22
+ - **No** → use whatever colors you want. You're done. Go to **Scenario 3**.
23
+
24
+ 2. **Do I need ICan's prebuilt components (Card, Button, DataTable, StatCard, Modal, etc.)?**
25
+ - **Yes** → import them from `'ican/components'`. Go to question 3.
26
+ - **No** → build everything yourself. Go to **Scenario 4**.
27
+
28
+ 3. **Are the prebuilt components enough, or do I want a more custom layout?**
29
+ - **They're enough** → **Scenario 1**.
30
+ - **I want custom layout but mixed with ICan components** → **Scenario 2** (but use vars in your wrappers if you want full theme support).
31
+
32
+ | You want… | Pick |
33
+ |---|---|
34
+ | Standard widget that adapts to all 4 themes with minimum effort | **Scenario 1** |
35
+ | ICan components but with a fixed branded "console" look | **Scenario 2** |
36
+ | Standalone widget with a deliberate retro/specialized look that never changes | **Scenario 3** |
37
+ | Maximum design control AND theme adaptation | **Scenario 4** |
38
+
39
+ ---
40
+
41
+ ## 3. Concepts
42
+
43
+ ### 3.1 What is the DOM?
44
+
45
+ Imagine a webpage as a **LEGO castle**. Every visible thing — buttons, headings, divs, images — is a LEGO brick. The bricks are snapped together in a tree: a `<div>` brick contains other bricks inside it, those contain more, and so on. The whole castle of snapped-together bricks is the **DOM** (Document Object Model). The browser stares at this castle and paints what it sees.
46
+
47
+ When JavaScript says `document.querySelector('.my-button')`, it's a hand reaching into the castle to grab one specific brick.
48
+
49
+ ### 3.2 What is Shadow DOM?
50
+
51
+ A **Shadow DOM** is a *secret inner castle hidden inside one brick*. From the outside it looks like one solid LEGO piece, but with the right key you can open it up and there's a whole little world inside with its own bricks, its own rules, its own paint. The outside castle's paint can't drip in, and the inside castle's paint can't leak out. They're **isolated**.
52
+
53
+ Why would anyone want that? Because if you build a `<calendar-widget>` and ship it to ten different websites, you don't want each website's CSS messing up your calendar. Shadow DOM gives your component its own private styling bubble.
54
+
55
+ ### 3.3 ICan widgets DO NOT use Shadow DOM today
56
+
57
+ This is important and easy to get wrong.
58
+
59
+ The codebase contains a `ShadowContainer.tsx` file that *could* mount widgets in a Shadow DOM — but **nothing imports it**. Production widgets render directly inside the portal's `[data-module="ican"]` wrapper, in the regular DOM:
60
+
61
+ ```
62
+ <div data-module="ican" data-theme="light"> ← portal sets this
63
+ └── <main class="ican-main">
64
+ └── <div class="widget-cell"> ← grid tile (portal)
65
+ └── <YourWidget> ← your code, plain React
66
+ └── .my-card ← your DOM
67
+ ```
68
+
69
+ There is **no isolation boundary**. CSS variables cascade in cleanly (which is what you want), but a small set of element-level rules from `globals.scss` (`button`, `input`, `h1`–`h6`, `a`) also reach your widget's elements. **Section 6** lists them all.
70
+
71
+ ### 3.4 How CSS variables cascade
72
+
73
+ Picture each LEGO brick as having a **sticky pad** where it can write notes like *"my favorite color is purple"*. When a deeper brick asks "what color am I?" using `var(--my-favorite-color)`, the browser walks **upward** through the brick's parents looking for a sticky note with that label. The first match wins.
74
+
75
+ The portal puts ICan's sticky notes on `[data-module="ican"]`:
76
+
77
+ ```css
78
+ [data-module="ican"] {
79
+ --ican-accent: #7c6aff; /* dark theme default */
80
+ --ican-card-bg: #141417;
81
+ /* ...77 more notes... */
82
+ }
83
+ ```
84
+
85
+ Your widget renders *inside* `[data-module="ican"]`. So when your CSS says `color: var(--ican-accent)`, the browser walks up, finds the note on `[data-module="ican"]`, resolves to `#7c6aff`. **Variables are inert lookup tokens** — they don't override anything by themselves, they're only resolved when *your* CSS uses them.
86
+
87
+ When the user switches theme, the portal sets `data-theme="light"` on the same element. The `[data-module="ican"][data-theme='light']` rule fires with new sticky-note values:
88
+
89
+ ```css
90
+ [data-module="ican"][data-theme='light'] {
91
+ --ican-accent: #5b4cd9; /* now this */
92
+ --ican-card-bg: #ffffff;
93
+ }
94
+ ```
95
+
96
+ Your `var(--ican-accent)` re-resolves. Your widget repaints. **Zero JavaScript on your side.**
97
+
98
+ ### 3.5 Why CSS variables cross Shadow DOM boundaries
99
+
100
+ Even if ICan ever did adopt Shadow DOM, your widget would still see `--ican-*` correctly — because CSS variables are explicitly designed to **leak into Shadow DOM**. It's the one mechanism that intentionally crosses the isolation boundary, so theme systems can work. *Selector-based rules* (like `button { red }`) cannot cross. Variables can.
101
+
102
+ So even with hypothetical future Shadow-DOM-based widgets, the four scenarios in this doc behave the same way.
103
+
104
+ ---
105
+
106
+ ## 4. The 4 widget styling scenarios
107
+
108
+ Each scenario below is a full, runnable example. Drop into `src/index.tsx` + `src/styles.scss` of any `infaira-canvas init` project.
109
+
110
+ ---
111
+
112
+ ### 4.1 Scenario 1 — ICan components + `--ican-*` variables (recommended)
113
+
114
+ **What it does:** Use ICan's prebuilt components for everything that has one, theme-adapt via `--ican-*` everywhere else.
115
+
116
+ **Theme behavior:** ✅ All four themes adapt automatically.
117
+
118
+ **Effort:** Lowest — components handle accessibility, keyboard, state.
119
+
120
+ **`src/index.tsx`**
121
+
122
+ ```tsx
123
+ import * as React from 'react';
124
+ import { Card, Button, StatCard, Badge, useToast } from 'ican/components';
125
+ import { registerWidget } from './ican';
126
+ import type { IContextProvider } from './ican';
127
+ import './styles.scss';
128
+
129
+ interface IProps { icanContext?: IContextProvider }
130
+
131
+ const SalesWidget: React.FC<IProps> = () => {
132
+ const toast = useToast();
133
+ return (
134
+ <div className="sales-widget">
135
+ <Card>
136
+ <header className="sales-widget__head">
137
+ <h3 className="sales-widget__title">Q2 Sales</h3>
138
+ <Badge variant="success">Live</Badge>
139
+ </header>
140
+
141
+ <div className="sales-widget__stats">
142
+ <StatCard label="Revenue" value="$48,200" trend="+18.4%" trendDirection="up" />
143
+ <StatCard label="Orders" value="1,284" trend="+6.2%" trendDirection="up" />
144
+ <StatCard label="Returns" value="3.1%" trend="-0.4%" trendDirection="down" />
145
+ </div>
146
+
147
+ <Button variant="primary" label="View Full Report" onClick={() => toast.success('Report opened')} />
148
+ </Card>
149
+ </div>
150
+ );
151
+ };
152
+
153
+ registerWidget({ id: 'sales-widget', widget: SalesWidget });
154
+ ```
155
+
156
+ **`src/styles.scss`**
157
+
158
+ ```scss
159
+ .sales-widget {
160
+ padding: 16px;
161
+ font-family: var(--ican-font-body);
162
+ color: var(--ican-primary-text);
163
+
164
+ &__head {
165
+ display: flex;
166
+ justify-content: space-between;
167
+ align-items: center;
168
+ margin-bottom: 16px;
169
+ }
170
+
171
+ &__title {
172
+ margin: 0;
173
+ font-family: var(--ican-font-brand);
174
+ font-size: 16px;
175
+ font-weight: 600;
176
+ color: var(--ican-primary-text);
177
+ }
178
+
179
+ &__stats {
180
+ display: grid;
181
+ grid-template-columns: repeat(3, 1fr);
182
+ gap: 12px;
183
+ margin-bottom: 16px;
184
+ }
185
+ }
186
+ ```
187
+
188
+ **Why it works:** `Card`, `Button`, `StatCard`, `Badge` already use `--ican-*` internally. Your custom rules (`__head`, `__title`) also use `--ican-*`. Every theme switch retints everything. Zero issues.
189
+
190
+ ---
191
+
192
+ ### 4.2 Scenario 2 — ICan components + custom class names (no `var(--ican-*)`)
193
+
194
+ **What it does:** Use ICan's components, but wrap them in your own classes with hardcoded colors.
195
+
196
+ **Theme behavior:** ⚠️ Partial — ICan components adapt; your wrapper stays fixed. Looks intentional on dark theme, *visually inconsistent* on light/glass themes.
197
+
198
+ **`src/index.tsx`**
199
+
200
+ ```tsx
201
+ import * as React from 'react';
202
+ import { Card, Button, DataTable } from 'ican/components';
203
+ import { registerWidget } from './ican';
204
+ import type { IContextProvider } from './ican';
205
+ import './styles.scss';
206
+
207
+ interface IProps { icanContext?: IContextProvider }
208
+
209
+ const InventoryWidget: React.FC<IProps> = () => {
210
+ const rows = [
211
+ { sku: 'A-100', stock: 42, status: 'OK' },
212
+ { sku: 'A-101', stock: 7, status: 'LOW' },
213
+ { sku: 'A-102', stock: 0, status: 'OUT' },
214
+ ];
215
+
216
+ return (
217
+ <div className="inventory-shell">
218
+ <div className="inventory-banner">Inventory Overview — branch #4</div>
219
+ <Card>
220
+ <DataTable rows={rows} columns={[
221
+ { key: 'sku', label: 'SKU' },
222
+ { key: 'stock', label: 'Stock' },
223
+ { key: 'status', label: 'Status' },
224
+ ]} />
225
+ <div className="inventory-footer">
226
+ <Button variant="primary" label="Reorder" onClick={() => {}} />
227
+ </div>
228
+ </Card>
229
+ </div>
230
+ );
231
+ };
232
+
233
+ registerWidget({ id: 'inventory-widget', widget: InventoryWidget });
234
+ ```
235
+
236
+ **`src/styles.scss`**
237
+
238
+ ```scss
239
+ /* No var(--ican-*) anywhere — your own values, fixed across themes. */
240
+ .inventory-shell {
241
+ padding: 16px;
242
+ font-family: 'Inter', sans-serif;
243
+ color: #fafafa;
244
+ background: #0e0e14; /* always this color, even on light theme */
245
+ border-radius: 12px;
246
+ }
247
+
248
+ .inventory-banner {
249
+ background: linear-gradient(90deg, #4f46e5, #ec4899);
250
+ color: #fff;
251
+ padding: 10px 14px;
252
+ border-radius: 8px;
253
+ font-weight: 600;
254
+ margin-bottom: 14px;
255
+ }
256
+
257
+ .inventory-footer {
258
+ display: flex;
259
+ justify-content: flex-end;
260
+ margin-top: 12px;
261
+ }
262
+ ```
263
+
264
+ **Why it works:** ICan components use their own internal `--ican-*` rules; your stylesheet never touches them. The mixing is fine — they live in separate selectors.
265
+
266
+ **Caveat:** On **light theme** your dark `#0e0e14` shell looks wrong on a white dashboard. On **glass themes** your opaque shell blocks the dashboard's gradient — the entire glass aesthetic breaks (see section 5). Use this scenario when you specifically want a fixed branded look (a "console" panel), not when you want the widget to feel native.
267
+
268
+ ---
269
+
270
+ ### 4.3 Scenario 3 — No ICan components, custom classes, no vars
271
+
272
+ **What it does:** Pure custom React with bespoke class names. No imports from `'ican/components'`. No `var(--ican-*)`.
273
+
274
+ **Theme behavior:** ❌ Static — looks identical across all 4 themes. That's a *feature* if you want a deliberately stylized widget (terminal, retro UI, embedded tool).
275
+
276
+ **`src/index.tsx`**
277
+
278
+ ```tsx
279
+ import * as React from 'react';
280
+ import { registerWidget } from './ican';
281
+ import type { IContextProvider } from './ican';
282
+ import './styles.scss';
283
+
284
+ interface IProps { icanContext?: IContextProvider }
285
+
286
+ const RetroTickerWidget: React.FC<IProps> = () => {
287
+ const [search, setSearch] = React.useState('');
288
+ const items = [
289
+ { sym: 'AAPL', price: 187.42, chg: '+1.24%' },
290
+ { sym: 'MSFT', price: 412.10, chg: '-0.32%' },
291
+ { sym: 'NVDA', price: 921.55, chg: '+3.18%' },
292
+ ].filter((i) => i.sym.includes(search.toUpperCase()));
293
+
294
+ return (
295
+ <div className="retro-ticker">
296
+ <div className="retro-ticker__crt">
297
+ <div className="retro-ticker__title">▌ MARKET FEED ▐</div>
298
+
299
+ {/* Class on the input → my rules can override the portal */}
300
+ <input
301
+ className="retro-ticker__search"
302
+ placeholder="filter symbol…"
303
+ value={search}
304
+ onChange={(e) => setSearch(e.target.value)}
305
+ />
306
+
307
+ <ul className="retro-ticker__list">
308
+ {items.map((i) => (
309
+ <li key={i.sym} className="retro-ticker__row">
310
+ <span className="retro-ticker__sym">{i.sym}</span>
311
+ <span className="retro-ticker__price">{i.price.toFixed(2)}</span>
312
+ <span className={`retro-ticker__chg retro-ticker__chg--${i.chg.startsWith('+') ? 'up' : 'down'}`}>{i.chg}</span>
313
+ </li>
314
+ ))}
315
+ </ul>
316
+
317
+ <button className="retro-ticker__refresh" onClick={() => setSearch('')}>REFRESH</button>
318
+ </div>
319
+ </div>
320
+ );
321
+ };
322
+
323
+ registerWidget({ id: 'retro-ticker', widget: RetroTickerWidget });
324
+ ```
325
+
326
+ **`src/styles.scss`**
327
+
328
+ ```scss
329
+ .retro-ticker {
330
+ height: 100%;
331
+ padding: 12px;
332
+ background: #000;
333
+ font-family: 'Courier New', monospace;
334
+ }
335
+
336
+ .retro-ticker__crt {
337
+ height: 100%;
338
+ background: #001a00;
339
+ border: 2px solid #00ff66;
340
+ border-radius: 6px;
341
+ padding: 14px;
342
+ color: #00ff66;
343
+ text-shadow: 0 0 4px #00ff66;
344
+ box-shadow: inset 0 0 24px rgba(0, 255, 102, 0.18);
345
+ }
346
+
347
+ .retro-ticker__title {
348
+ font-size: 14px;
349
+ font-weight: 700;
350
+ letter-spacing: 0.2em;
351
+ margin-bottom: 12px;
352
+ }
353
+
354
+ /* IMPORTANT: portal applies !important to all <input> bg/color globally.
355
+ Your class alone has lower specificity than [data-module="ican"] input
356
+ PLUS the portal uses !important. You must use !important to win. */
357
+ .retro-ticker__search {
358
+ display: block;
359
+ width: 100%;
360
+ background: #001a00 !important;
361
+ color: #00ff66 !important;
362
+ border: 1px solid #00ff66 !important;
363
+ border-radius: 0 !important;
364
+ padding: 6px 10px !important;
365
+ font-family: inherit !important;
366
+ margin-bottom: 12px;
367
+ outline: none;
368
+
369
+ &::placeholder { color: rgba(0, 255, 102, 0.4); }
370
+ }
371
+
372
+ .retro-ticker__list { list-style: none; padding: 0; margin: 0 0 12px; }
373
+
374
+ .retro-ticker__row {
375
+ display: grid;
376
+ grid-template-columns: 60px 1fr 80px;
377
+ gap: 8px;
378
+ padding: 4px 0;
379
+ border-bottom: 1px dashed rgba(0, 255, 102, 0.3);
380
+ font-size: 13px;
381
+ }
382
+
383
+ .retro-ticker__chg--up { color: #66ff99; }
384
+ .retro-ticker__chg--down { color: #ff6666; text-shadow: 0 0 4px #ff6666; }
385
+
386
+ .retro-ticker__refresh {
387
+ background: transparent;
388
+ color: #00ff66;
389
+ border: 1px solid #00ff66;
390
+ padding: 6px 14px;
391
+ font-family: inherit;
392
+ letter-spacing: 0.1em;
393
+ cursor: pointer;
394
+
395
+ &:hover { background: rgba(0, 255, 102, 0.1); }
396
+ }
397
+ ```
398
+
399
+ **Why it works:** Every element has a class. The portal's `globals.scss` has zero rules targeting *your* class names — only generic element types. Class names you invent are inert as far as the portal is concerned.
400
+
401
+ **Two gotchas:**
402
+ 1. **`<input>` needs `!important`** — the portal's `[data-module="ican"] input { background-color: !important; color: !important }` rule beats your class on specificity AND uses `!important`. `!important` on your side wins because of source order.
403
+ 2. **`<button>` is fine** — the portal's `button` reset doesn't use `!important`, just specificity. Any class on a `<button>` defeats it.
404
+
405
+ **Bare elements (no class) — what happens:**
406
+ - `<input />` with no class: gets the portal's `--ican-input-bg` background and `--ican-primary-text` color, no override possible.
407
+ - `<button>...</button>` with no class: gets the portal's reset (transparent, no border, inherits color/font). Will look invisible.
408
+
409
+ **Always put a class on `<input>` and `<button>`** — even if you never style them. That gives you an escape hatch.
410
+
411
+ ---
412
+
413
+ ### 4.4 Scenario 4 — No ICan components, custom classes, but `var(--ican-*)` (most common power-user pattern)
414
+
415
+ **What it does:** Build every UI primitive yourself, but pull every color/radius/font from `var(--ican-*)` so the portal's theme switch retints your widget for free.
416
+
417
+ **Theme behavior:** ✅ All four themes adapt.
418
+
419
+ **Effort:** Medium-high — you're rebuilding primitives, but theme support is automatic.
420
+
421
+ **`src/index.tsx`**
422
+
423
+ ```tsx
424
+ import * as React from 'react';
425
+ import { registerWidget } from './ican';
426
+ import type { IContextProvider } from './ican';
427
+ import './styles.scss';
428
+
429
+ interface IProps { icanContext?: IContextProvider }
430
+
431
+ interface IActivity { id: string; user: string; action: string; time: string; status: 'ok' | 'warn' | 'err' }
432
+
433
+ const ActivityFeedWidget: React.FC<IProps> = () => {
434
+ const [filter, setFilter] = React.useState('');
435
+ const all: IActivity[] = [
436
+ { id: '1', user: 'Alex', action: 'Deployed v2.4.1', time: '2m ago', status: 'ok' },
437
+ { id: '2', user: 'Bea', action: 'Reviewed PR #482', time: '14m ago', status: 'ok' },
438
+ { id: '3', user: 'Cy', action: 'Build #91 failed', time: '32m ago', status: 'err' },
439
+ { id: '4', user: 'Dee', action: 'Slow query on orders', time: '1h ago', status: 'warn' },
440
+ ];
441
+ const items = all.filter((a) => a.user.toLowerCase().includes(filter.toLowerCase()));
442
+
443
+ return (
444
+ <div className="feed">
445
+ <header className="feed__head">
446
+ <h3 className="feed__title">Activity</h3>
447
+ <span className="feed__count">{items.length}</span>
448
+ </header>
449
+
450
+ <input
451
+ className="feed__search"
452
+ placeholder="Filter by user…"
453
+ value={filter}
454
+ onChange={(e) => setFilter(e.target.value)}
455
+ />
456
+
457
+ <ul className="feed__list">
458
+ {items.map((a) => (
459
+ <li key={a.id} className={`feed__row feed__row--${a.status}`}>
460
+ <span className="feed__avatar">{a.user[0]}</span>
461
+ <div className="feed__body">
462
+ <div className="feed__line">
463
+ <span className="feed__user">{a.user}</span>
464
+ <span className="feed__action">{a.action}</span>
465
+ </div>
466
+ <div className="feed__time">{a.time}</div>
467
+ </div>
468
+ <span className={`feed__pill feed__pill--${a.status}`}>{a.status.toUpperCase()}</span>
469
+ </li>
470
+ ))}
471
+ </ul>
472
+
473
+ <button className="feed__refresh" onClick={() => setFilter('')}>Clear filter</button>
474
+ </div>
475
+ );
476
+ };
477
+
478
+ registerWidget({ id: 'activity-feed', widget: ActivityFeedWidget });
479
+ ```
480
+
481
+ **`src/styles.scss`**
482
+
483
+ ```scss
484
+ .feed {
485
+ height: 100%;
486
+ display: flex;
487
+ flex-direction: column;
488
+ padding: 16px;
489
+ font-family: var(--ican-font-body);
490
+ color: var(--ican-primary-text);
491
+ background: var(--ican-card-bg);
492
+ border: 1px solid var(--ican-border);
493
+ border-radius: var(--ican-radius-lg);
494
+ /* Single line that frosts on glass themes, no-op on dark */
495
+ backdrop-filter: var(--ican-backdrop-filter);
496
+ -webkit-backdrop-filter: var(--ican-backdrop-filter);
497
+
498
+ &__head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
499
+
500
+ &__title {
501
+ margin: 0;
502
+ font-family: var(--ican-font-brand);
503
+ font-size: 15px;
504
+ font-weight: 600;
505
+ color: var(--ican-primary-text);
506
+ }
507
+
508
+ &__count {
509
+ font-size: 11px; font-weight: 600;
510
+ color: var(--ican-accent);
511
+ background: var(--ican-accent-dim);
512
+ padding: 2px 8px;
513
+ border-radius: var(--ican-radius-xl);
514
+ }
515
+
516
+ /* INPUT — !important needed because portal forces input bg/color !important */
517
+ &__search {
518
+ margin-bottom: 12px;
519
+ background: var(--ican-input-bg) !important;
520
+ color: var(--ican-primary-text) !important;
521
+ border: 1px solid var(--ican-border) !important;
522
+ border-radius: var(--ican-radius-sm) !important;
523
+ padding: 8px 10px !important;
524
+ font-family: var(--ican-font-body) !important;
525
+ font-size: 13px;
526
+ outline: none;
527
+ transition: border-color var(--ican-transition-fast);
528
+
529
+ &:focus { border-color: var(--ican-border-focus) !important; }
530
+ &::placeholder { color: var(--ican-secondary-text); }
531
+ }
532
+
533
+ &__list { list-style: none; padding: 0; margin: 0; flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; }
534
+
535
+ &__row {
536
+ display: grid;
537
+ grid-template-columns: 32px 1fr auto;
538
+ gap: 10px;
539
+ align-items: center;
540
+ padding: 10px;
541
+ background: var(--ican-secondary-bg);
542
+ border: 1px solid var(--ican-border);
543
+ border-radius: var(--ican-radius-md);
544
+ transition: background var(--ican-transition-fast);
545
+
546
+ &:hover { background: var(--ican-hover); }
547
+ }
548
+
549
+ &__avatar {
550
+ width: 32px; height: 32px;
551
+ display: grid; place-items: center;
552
+ border-radius: 50%;
553
+ background: var(--ican-accent-dim);
554
+ color: var(--ican-accent);
555
+ font-weight: 700; font-size: 13px;
556
+ }
557
+
558
+ &__body { min-width: 0; }
559
+ &__line { display: flex; gap: 6px; align-items: baseline; }
560
+ &__user { font-weight: 600; color: var(--ican-primary-text); font-size: 13px; }
561
+ &__action { color: var(--ican-secondary-text); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
562
+ &__time { color: var(--ican-tertiary-text); font-size: 11px; margin-top: 2px; }
563
+
564
+ &__pill {
565
+ font-size: 10px;
566
+ font-weight: 700;
567
+ letter-spacing: 0.06em;
568
+ padding: 3px 8px;
569
+ border-radius: var(--ican-radius-xl);
570
+
571
+ &--ok { color: var(--ican-success); background: var(--ican-success-dim); }
572
+ &--warn { color: var(--ican-warning); background: var(--ican-warning-dim); }
573
+ &--err { color: var(--ican-error); background: var(--ican-error-dim); }
574
+ }
575
+
576
+ &__refresh {
577
+ margin-top: 12px;
578
+ align-self: flex-start;
579
+ background: transparent;
580
+ color: var(--ican-accent);
581
+ border: 1px solid var(--ican-border);
582
+ border-radius: var(--ican-radius-md);
583
+ padding: 6px 12px;
584
+ font-family: var(--ican-font-body);
585
+ font-size: 12px;
586
+ cursor: pointer;
587
+ transition: all var(--ican-transition-fast);
588
+
589
+ &:hover { background: var(--ican-hover); border-color: var(--ican-border-bright); color: var(--ican-primary-text); }
590
+ }
591
+ }
592
+ ```
593
+
594
+ **Why it works:**
595
+ - Theme awareness: every color is a `var(--ican-*)` lookup. The portal flips `data-theme`, variables remap, your widget repaints. No JS, no observers.
596
+ - No ICan-component dependency: zero imports from `'ican/components'`.
597
+ - Class names are yours: portal has zero rules targeting them.
598
+
599
+ **When to pick Scenario 4 over Scenario 1:**
600
+ - You want a layout that ICan's components don't already give you (custom timelines, dashboards-within-dashboards, retro UIs that *should* still respect theme).
601
+ - You're porting an existing standalone React component into a widget and want minimal refactoring — just swap colors to vars.
602
+ - You want consistency with the user's portal but with branding/structure that's distinctly yours.
603
+
604
+ **Most production widgets mix Scenario 1 and Scenario 4** — components for the chrome, custom + vars for unique parts. Both mechanisms sit on the same `--ican-*` cascade.
605
+
606
+ ---
607
+
608
+ ## 5. Why Scenarios 2 and 3 fail on glass themes
609
+
610
+ The four themes look like this on the dashboard:
611
+
612
+ | Theme | Dashboard background | Card visual style |
613
+ |---|---|---|
614
+ | Dark (default) | Near-black `#09090b` | Solid dark surface |
615
+ | Light | Near-white `#f5f5f7` | Solid white surface |
616
+ | Glass Dark | Vivid violet/blue radial gradient on `#08061a` | Frosted glass panel — *gradient must show through* |
617
+ | Glass Light | Pastel sky gradient (lavender/pink/mint) on `#d0e4ff` | Frosted glass panel — *gradient must show through* |
618
+
619
+ The glass aesthetic is "I see a colorful soup behind, and my card is a foggy pane of glass over it." It needs **three coordinated vars**:
620
+
621
+ ```scss
622
+ .my-card {
623
+ background: var(--ican-card-bg); /* translucent on glass themes */
624
+ border: 1px solid var(--ican-glass-border);
625
+ backdrop-filter: var(--ican-backdrop-filter);
626
+ -webkit-backdrop-filter: var(--ican-backdrop-filter);
627
+ }
628
+ ```
629
+
630
+ | Var | Dark | Light | Glass Dark | Glass Light |
631
+ |---|---|---|---|---|
632
+ | `--ican-card-bg` | `#141417` (opaque) | `#ffffff` (opaque) | `rgba(255,255,255,0.08)` (almost see-through) | `rgba(255,255,255,0.48)` (half see-through) |
633
+ | `--ican-backdrop-filter` | `none` | `blur(20px) saturate(180%)` | `blur(24px) saturate(200%) brightness(1.12)` | `blur(22px) saturate(190%) brightness(1.08)` |
634
+
635
+ If you skip these (Scenarios 2 and 3), your card is **always opaque**. On glass themes that's the entire problem — the portal's gradient never shows through, the frost never happens, the design language breaks. You get an opaque dark blob sitting on a vivid purple page.
636
+
637
+ **The clever bit:** `var(--ican-backdrop-filter)` is `none` on dark theme, so applying it always is *free of cost* on dark. You don't need a theme check in your CSS — one line works everywhere.
638
+
639
+ ### Hybrid escape hatch — keep custom interior, swap only the outer surface
640
+
641
+ If you love your Scenario 3 retro look but want it not to break on glass themes:
642
+
643
+ ```scss
644
+ /* Before — Scenario 3 outer shell */
645
+ .retro-ticker { background: #000; }
646
+ .retro-ticker__crt { background: #001a00; border: 2px solid #00ff66; }
647
+
648
+ /* After — frosts on glass themes, still feels CRT inside */
649
+ .retro-ticker {
650
+ background: var(--ican-card-bg);
651
+ backdrop-filter: var(--ican-backdrop-filter);
652
+ -webkit-backdrop-filter: var(--ican-backdrop-filter);
653
+ border-radius: var(--ican-radius-lg);
654
+ }
655
+ .retro-ticker__crt {
656
+ background: rgba(0, 26, 0, 0.85); /* slight transparency lets the frost show */
657
+ border: 2px solid #00ff66;
658
+ }
659
+ ```
660
+
661
+ You keep ~90% of your custom palette and only opt into vars on the **outermost surface + the frost**. That's enough to make glass themes work and light theme not look broken, while your interior stays as-stylized as you want.
662
+
663
+ ---
664
+
665
+ ## 6. Portal element-level rules that reach your widget
666
+
667
+ Because there's no Shadow DOM, a small set of rules from `globals.scss` reach your widget's elements. Knowing them makes overriding deterministic.
668
+
669
+ | Selector | What the portal applies | How to override |
670
+ |---|---|---|
671
+ | `button` | Resets `border`, `background`, `cursor`; sets `font-family`; inherits `color` and `font-size` | Add a class — your rules win on specificity (no `!important` needed) |
672
+ | `input, select, textarea, option` | Forces `background-color` and `color` via `!important`; sets default border + padding + radius | Use `!important` on `background`, `color`, `border`, `padding` |
673
+ | `h1`–`h6` | Sets `font-family` + tightened margins | Set your own `margin` / `font-family` in your widget CSS |
674
+ | `a` | Sets accent color + hover underline | Override `color` / `text-decoration` in your `.my-widget a {}` rule |
675
+ | `*, *::before, *::after` | `box-sizing: border-box` | Leave it — this is the convention you want |
676
+
677
+ **The `<input>` rule is the only one that uses `!important`.** Everything else is plain specificity, defeated by adding a class.
678
+
679
+ **Class-name collisions are not a concern.** The portal targets element types only. Any class name you invent is yours — `.my-fancy-card`, `.revenue-value`, anything.
680
+
681
+ **The minimum input override snippet** (memorize this):
682
+
683
+ ```scss
684
+ .my-input {
685
+ background: transparent !important; /* or var(--ican-input-bg) */
686
+ color: var(--ican-primary-text) !important;
687
+ border: 1px solid var(--ican-border) !important;
688
+ padding: 8px 12px !important;
689
+ }
690
+ ```
691
+
692
+ ---
693
+
694
+ ## 7. Decision matrix
695
+
696
+ | | Scenario 1 | Scenario 2 | Scenario 3 | Scenario 4 |
697
+ |---|---|---|---|---|
698
+ | ICan components | ✅ | ⚠️ partial | ❌ | ❌ |
699
+ | ICan vars in your CSS | ✅ | ❌ | ❌ | ✅ |
700
+ | Custom class names | a few | many | full custom | full custom |
701
+ | Theme adapts (dark) | ✅ | ⚠️ | (static) | ✅ |
702
+ | Theme adapts (light) | ✅ | ❌ wrong | (static, looks displaced) | ✅ |
703
+ | Theme adapts (glass dark) | ✅ | ❌ broken | ❌ broken | ✅ |
704
+ | Theme adapts (glass light) | ✅ | ❌ broken | ❌ broken | ✅ |
705
+ | `<input>` needs `!important` | n/a (use ICan `<Input>`) | n/a (use ICan `<Input>`) | yes | yes |
706
+ | Effort | Lowest | Medium | Highest | Medium-High |
707
+ | Best for | Standard widgets | Branded console widgets | Retro/specialized "island" widgets | Custom layouts that respect theme |
708
+
709
+ ---
710
+
711
+ ## 8. Pre-publish checklist
712
+
713
+ Before you publish a widget, verify:
714
+
715
+ - [ ] **No hardcoded hex.** Search your `.scss` for `#`, `rgb(`, `hsl(`. Every match should either be a deliberate fixed-look choice (Scenario 3 territory) OR replaced with `var(--ican-*)`.
716
+ - [ ] **Outer surface uses the glass three.** `.my-widget { background: var(--ican-card-bg); backdrop-filter: var(--ican-backdrop-filter); -webkit-backdrop-filter: var(--ican-backdrop-filter); border: 1px solid var(--ican-border); }` — these three vars are what makes glass themes work.
717
+ - [ ] **Every `<input>` has a class** AND the override rule with `!important` on `background`, `color`, `border`, `padding`.
718
+ - [ ] **Every `<button>` has a class** even if you don't style it (gives you an escape hatch from the portal's reset).
719
+ - [ ] **Tested on all 4 themes.** Switch theme in the portal's Appearance page (or in dev: change the `data-theme` attribute on `<html>`) and visually confirm. Glass-dark and glass-light are the strict tests.
720
+ - [ ] **Chart colors derived in JavaScript** from `icanContext.themeType`, not from CSS variables (SVG can't read CSS vars). See [`ICan-Widget-Theming-Guide.md`](./ICan-Widget-Theming-Guide.md) section "Reading the Current Theme in JavaScript".
721
+
722
+ ---
723
+
724
+ ## 9. Where to find more
725
+
726
+ - **[`ICan-Widget-Theming-Guide.md`](./ICan-Widget-Theming-Guide.md)** — exhaustive `--ican-*` variable reference, every variable's value across all 4 themes, glass frosting deep dive.
727
+ - **[`ICan-Customizing-Components.md`](./ICan-Customizing-Components.md)** — how to customize ICan's bundled component library.
728
+ - **In-portal `/docs` page** — live component playground with prop tables, live demos, computed CSS variable values for the active theme.
729
+ - **`README.md`** in your scaffolded widget project — bundle.json, build, upload flow.
730
+
731
+ ---
732
+
733
+ ## 10. Class name collisions
734
+
735
+ ### The problem
736
+
737
+ Because ICan widgets render in the **plain DOM** (no Shadow DOM), every widget's CSS rules land in the same global stylesheet. If Widget A and Widget B both define `.widget-root`, both rules exist simultaneously. The browser applies them by cascade order — whichever bundle loaded **last** wins — and **both** widgets end up with the same styles.
738
+
739
+ | Widget A `styles.scss` | Widget B `styles.scss` |
740
+ |---|---|
741
+ | `.widget-root { background: #1a1a2e; }` | `.widget-root { background: #ffffff; }` |
742
+
743
+ On a dashboard showing both, both cells get `background: #ffffff`. Widget A looks broken. Neither developer notices in their own dev environment because they only ever test their widget alone.
744
+
745
+ ### The silent failure
746
+
747
+ This bug has three properties that make it especially hard to catch:
748
+
749
+ 1. **Invisible in isolation.** Each widget looks correct in `npm run dev` because only one widget's CSS is in the page.
750
+ 2. **Load-order dependent.** The broken widget changes depending on which script the browser happens to fetch first — it can look correct on one page load and wrong on the next.
751
+ 3. **Scroll-triggered.** The portal lazy-loads widget scripts. A dashboard can look fine until the user scrolls to the second widget, which injects its script and immediately overwrites the first widget's styles.
752
+
753
+ ### When it happens
754
+
755
+ - Both widgets use the scaffold's default class names (`.widget-root`, `.widget-loading`, `.widget-spinner`, `.widget-error`, `.widget-error-message`)
756
+ - Two developers chose the same descriptive name independently (`.card`, `.title`, `.button`, `.header`)
757
+ - A developer copies code from another widget without renaming classes
758
+
759
+ ### The fix: scope all CSS under a unique outer class
760
+
761
+ Wrap every rule in your `styles.scss` under your widget ID as the outermost class:
762
+
763
+ ```scss
764
+ /* ✅ correct — all rules scoped under the widget ID */
765
+ .my-sales-widget {
766
+ padding: 20px;
767
+ color: var(--ican-primary-text);
768
+
769
+ &__title {
770
+ font-size: 15px;
771
+ font-family: var(--ican-font-brand);
772
+ }
773
+
774
+ &__spinner {
775
+ animation: my-sales-widget-spin 0.8s linear infinite;
776
+ }
777
+ }
778
+
779
+ @keyframes my-sales-widget-spin {
780
+ to { transform: rotate(360deg); }
781
+ }
782
+ ```
783
+
784
+ And in your TSX root element:
785
+
786
+ ```tsx
787
+ <div className="my-sales-widget">
788
+ <h3 className="my-sales-widget__title">...</h3>
789
+ </div>
790
+ ```
791
+
792
+ Now `.my-sales-widget__title` only matches elements inside a `.my-sales-widget` root. Any other widget using `.title` is completely unaffected.
793
+
794
+ ### @keyframes need prefixing too
795
+
796
+ Animation names are global — two widgets both defining `@keyframes spin` will overwrite each other. Always prefix animation names with your widget ID:
797
+
798
+ ```scss
799
+ /* ❌ global — will be overwritten by any other widget named 'spin' */
800
+ @keyframes spin { to { transform: rotate(360deg); } }
801
+
802
+ /* ✅ unique — scoped to your widget */
803
+ @keyframes my-sales-widget-spin { to { transform: rotate(360deg); } }
804
+ ```
805
+
806
+ ### Risk by approach
807
+
808
+ | What you write | Collision risk |
809
+ |---|---|
810
+ | `.button { }` | **High** — any other widget can have `.button` |
811
+ | `.my-sales-widget__button { }` | **None** — scoped to your widget ID |
812
+ | `@keyframes spin { }` | **High** — last loaded wins |
813
+ | `@keyframes my-sales-widget-spin { }` | **None** — unique name |
814
+ | ICan components only (`<Button>`, `<Card>`) | **None** — ICan's internal class names are portal-managed |
815
+ | `var(--ican-*)` inside your scoped class | **None** — CSS variables are not class names |
816
+
817
+ ### What the scaffold generates
818
+
819
+ The `infaira-canvas init` scaffold uses the widget ID you provide as the outer SCSS wrapper in the generated `styles.scss`. Keep this pattern — do not flatten it to generic names like `.root` or `.container`.
820
+
821
+ ---
822
+
823
+ ## 11. AI-agent quick context block
824
+
825
+ > Read this section before generating any widget code.
826
+
827
+ **Architectural facts:**
828
+ 1. Widgets render in **plain DOM** inside `[data-module="ican"]`, NOT in Shadow DOM. (`ShadowContainer.tsx` exists but is unused.)
829
+ 2. CSS variables (`--ican-*`) are scoped to `[data-module="ican"]`. They cascade down to your widget. They are **inert lookup tokens** — they don't override anything by themselves.
830
+ 3. The portal applies element-level styles to `button`, `input, select, textarea, option`, `h1`–`h6`, `a`, `*` inside `[data-module="ican"]`. Your widget's elements *are* affected.
831
+ 4. The `input` rule uses `!important` on `background-color` and `color`. To override: use `!important` on your side.
832
+ 5. Every `--ican-*` variable name is identical between the dev harness (`infaira-canvas init` scaffold's `index.html` declares them on `:root`) and production (`globals.scss` declares them on `[data-module="ican"]`). Same name + same hex value = same color in both environments.
833
+ 6. Widgets render in a shared global DOM — **all class names across all loaded widgets are in the same namespace.** Scope every SCSS rule under the widget ID as the outermost class to prevent collisions. Prefix `@keyframes` names with the widget ID too.
834
+
835
+ **When generating widget code:**
836
+ - Default to **Scenario 1** unless the user wants a custom look or non-standard layout.
837
+ - For custom layouts that should still respect theme: **Scenario 4**.
838
+ - For deliberately fixed-look widgets: **Scenario 3** with a one-line note acknowledging glass themes will look displaced.
839
+ - Always put a class on `<input>` and `<button>` elements.
840
+ - For `<input>`, always add the override rule with `!important` on `background`, `color`, `border`, `padding`.
841
+ - Use `var(--ican-card-bg)` + `var(--ican-backdrop-filter)` on the outermost card surface to ensure glass themes work — this is the single highest-leverage pair of variables for theme correctness.
842
+ - **Always** wrap all SCSS under the widget ID as the outermost class. Never use generic names like `.root`, `.container`, `.button`, `.title` at top level.
843
+
844
+ **Do not:**
845
+ - Don't import from `'ican/components'` if the user explicitly says "no ICan components."
846
+ - Don't hardcode hex values without first checking if the user wants theme adaptation.
847
+ - Don't skip `!important` on input overrides — it will silently fail on the portal.
848
+ - Don't add `data-module="ican"` to a portal you create yourself (e.g., a modal). The portal's wrapper is the only one that should have that attribute. Adding it elsewhere will pull in theme rules (radial gradients, `position: relative`, `::before` overlays) that break your layout.
849
+ - Don't use generic top-level class names — always scope under the widget ID.
850
+
851
+ **Default widget skeleton (Scenario 1, copy-paste-ready):**
852
+
853
+ ```tsx
854
+ import * as React from 'react';
855
+ import { Card } from 'ican/components';
856
+ import { registerWidget } from './ican';
857
+ import type { IContextProvider } from './ican';
858
+ import './styles.scss';
859
+
860
+ interface IProps { icanContext?: IContextProvider }
861
+
862
+ const MyWidget: React.FC<IProps> = ({ icanContext }) => {
863
+ return (
864
+ <Card>
865
+ <div className="my-widget">
866
+ <h3 className="my-widget__title">Title</h3>
867
+ {/* widget body */}
868
+ </div>
869
+ </Card>
870
+ );
871
+ };
872
+
873
+ registerWidget({ id: 'my-widget', widget: MyWidget });
874
+ ```
875
+
876
+ ```scss
877
+ .my-widget {
878
+ font-family: var(--ican-font-body);
879
+ color: var(--ican-primary-text);
880
+
881
+ &__title {
882
+ margin: 0 0 12px;
883
+ font-family: var(--ican-font-brand);
884
+ font-size: 15px;
885
+ font-weight: 600;
886
+ }
887
+ }
888
+ ```
889
+
890
+ This is the safest starting point for any widget. Customize from here.