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.
- package/README.md +264 -0
- package/dist/commands/init.d.ts +17 -0
- package/dist/commands/init.js +647 -0
- package/dist/commands/upload.d.ts +8 -0
- package/dist/commands/upload.js +164 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +123 -0
- package/package.json +44 -0
- package/templates/ICan-Customizing-Components.md +195 -0
- package/templates/ICan-Widget-Development-Guide.md +500 -0
- package/templates/ICan-Widget-Styling-Patterns.md +890 -0
- package/templates/ICan-Widget-Theming-Guide.md +633 -0
- package/templates/README.md +127 -0
- package/templates/designer.d.ts +468 -0
- package/templates/ican.d.ts +763 -0
- package/templates/index.html +2225 -0
- package/templates/resources/favicon.ico +2 -0
- package/templates/resources/ican-components.js +1734 -0
- package/templates/resources/infaira-icon.png +0 -0
- package/templates/resources/infaira-logo.png +0 -0
- package/templates/site.webmanifest +17 -0
- package/templates/ui.html +1670 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import https from 'https';
|
|
4
|
+
import http from 'http';
|
|
5
|
+
import { URL } from 'url';
|
|
6
|
+
import { validateBundleJson } from './init.js';
|
|
7
|
+
// ─── .env reader (no external deps) ──────────────────────────────────────────
|
|
8
|
+
function readDotEnv() {
|
|
9
|
+
const envPath = path.resolve(process.cwd(), '.env');
|
|
10
|
+
if (!fs.existsSync(envPath))
|
|
11
|
+
return {};
|
|
12
|
+
const lines = fs.readFileSync(envPath, 'utf-8').split('\n');
|
|
13
|
+
const result = {};
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
const trimmed = line.trim();
|
|
16
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
17
|
+
continue;
|
|
18
|
+
const eqIdx = trimmed.indexOf('=');
|
|
19
|
+
if (eqIdx < 1)
|
|
20
|
+
continue;
|
|
21
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
22
|
+
const val = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, '');
|
|
23
|
+
if (key)
|
|
24
|
+
result[key] = val;
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
// ─── Multipart form builder ───────────────────────────────────────────────────
|
|
29
|
+
function buildMultipartBody(boundary, fields) {
|
|
30
|
+
const parts = [];
|
|
31
|
+
for (const field of fields) {
|
|
32
|
+
const header = `--${boundary}\r\n` +
|
|
33
|
+
`Content-Disposition: form-data; name="${field.name}"; filename="${field.filename}"\r\n` +
|
|
34
|
+
`Content-Type: ${field.contentType}\r\n\r\n`;
|
|
35
|
+
parts.push(Buffer.from(header, 'utf-8'));
|
|
36
|
+
parts.push(field.data);
|
|
37
|
+
parts.push(Buffer.from('\r\n', 'utf-8'));
|
|
38
|
+
}
|
|
39
|
+
parts.push(Buffer.from(`--${boundary}--\r\n`, 'utf-8'));
|
|
40
|
+
return Buffer.concat(parts);
|
|
41
|
+
}
|
|
42
|
+
// ─── HTTP request helper ──────────────────────────────────────────────────────
|
|
43
|
+
function postMultipart(targetUrl, token, body, boundary) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const parsed = new URL(targetUrl);
|
|
46
|
+
const isHttps = parsed.protocol === 'https:';
|
|
47
|
+
const lib = isHttps ? https : http;
|
|
48
|
+
const options = {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
hostname: parsed.hostname,
|
|
51
|
+
port: parsed.port || (isHttps ? 443 : 80),
|
|
52
|
+
path: parsed.pathname + parsed.search,
|
|
53
|
+
headers: {
|
|
54
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
55
|
+
'Content-Length': body.length,
|
|
56
|
+
Authorization: `Bearer ${token}`,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
const req = lib.request(options, (res) => {
|
|
60
|
+
const chunks = [];
|
|
61
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
62
|
+
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
63
|
+
});
|
|
64
|
+
req.on('error', reject);
|
|
65
|
+
req.write(body);
|
|
66
|
+
req.end();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// ─── Main export ──────────────────────────────────────────────────────────────
|
|
70
|
+
export async function handleUpload(opts) {
|
|
71
|
+
const env = readDotEnv();
|
|
72
|
+
// Resolve url and token: flags take precedence, then .env, then ICAN_* env vars
|
|
73
|
+
const url = opts.url || env['ICAN_URL'] || process.env['ICAN_URL'] || '';
|
|
74
|
+
const token = opts.token || env['ICAN_TOKEN'] || process.env['ICAN_TOKEN'] || '';
|
|
75
|
+
if (!url) {
|
|
76
|
+
console.error('Error: portal URL is required.\n Pass --url <url> or set ICAN_URL in .env');
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
if (!token) {
|
|
80
|
+
console.error('Error: auth token is required.\n Pass --token <token> or set ICAN_TOKEN in .env');
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
const { bundlePath, scriptPath } = opts;
|
|
84
|
+
const resolvedBundle = path.resolve(bundlePath);
|
|
85
|
+
const resolvedScript = path.resolve(scriptPath);
|
|
86
|
+
// ── Check files exist ──
|
|
87
|
+
if (!fs.existsSync(resolvedBundle)) {
|
|
88
|
+
console.error(`Error: bundle.json not found: ${resolvedBundle}`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
if (!fs.existsSync(resolvedScript)) {
|
|
92
|
+
const isDefaultDist = resolvedScript.includes('dist/main.js') || resolvedScript.includes('dist\\main.js');
|
|
93
|
+
if (isDefaultDist) {
|
|
94
|
+
console.error(`Error: dist/main.js not found. Run "npm run build" first.`);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
console.error(`Error: script file not found: ${resolvedScript}`);
|
|
98
|
+
}
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
const bundleData = fs.readFileSync(resolvedBundle);
|
|
102
|
+
const scriptData = fs.readFileSync(resolvedScript);
|
|
103
|
+
// ── Validate bundle.json structure ──
|
|
104
|
+
const validation = validateBundleJson(bundleData.toString('utf-8'));
|
|
105
|
+
if (!validation.valid) {
|
|
106
|
+
console.error(`Error: invalid bundle.json — ${validation.error}`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
const bundleName = validation.data.name ?? validation.data.id;
|
|
110
|
+
const widgetCount = validation.data.widgets.length;
|
|
111
|
+
// ── Upload ──
|
|
112
|
+
const boundary = `----ICanFormBoundary${Date.now().toString(16)}`;
|
|
113
|
+
const body = buildMultipartBody(boundary, [
|
|
114
|
+
{ name: 'bundleJson', filename: 'bundle.json', contentType: 'application/json', data: bundleData },
|
|
115
|
+
{ name: 'scriptFile', filename: 'main.js', contentType: 'application/javascript', data: scriptData },
|
|
116
|
+
]);
|
|
117
|
+
const uploadUrl = url.replace(/\/$/, '') + '/api/bundles/upload';
|
|
118
|
+
const scriptKb = (scriptData.length / 1024).toFixed(1);
|
|
119
|
+
console.log('');
|
|
120
|
+
console.log(` Bundle : ${bundleName}`);
|
|
121
|
+
console.log(` Widgets : ${widgetCount}`);
|
|
122
|
+
console.log(` Size : ${scriptKb} KB`);
|
|
123
|
+
console.log(` Endpoint : ${uploadUrl}`);
|
|
124
|
+
console.log('');
|
|
125
|
+
process.stdout.write(' Uploading ...');
|
|
126
|
+
let responseText;
|
|
127
|
+
try {
|
|
128
|
+
responseText = await postMultipart(uploadUrl, token, body, boundary);
|
|
129
|
+
process.stdout.write(' done\n\n');
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
process.stdout.write('\n');
|
|
133
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
134
|
+
console.error(`Error: upload failed — ${message}`);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
let result;
|
|
138
|
+
try {
|
|
139
|
+
result = JSON.parse(responseText);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
console.error('Error: unexpected response from server (not JSON):');
|
|
143
|
+
console.error(responseText.slice(0, 500));
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
if (!result.success) {
|
|
147
|
+
const errCode = result.error?.code ?? 'UNKNOWN';
|
|
148
|
+
const errMsg = result.error?.message ?? 'Unknown error';
|
|
149
|
+
console.error(`\u2716 Upload failed [${errCode}]: ${errMsg}`);
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
const data = result.data ?? {};
|
|
153
|
+
console.log('\u2714 Bundle uploaded successfully!');
|
|
154
|
+
if (data.id)
|
|
155
|
+
console.log(` Bundle ID : ${data.id}`);
|
|
156
|
+
if (data.name)
|
|
157
|
+
console.log(` Name : ${data.name}`);
|
|
158
|
+
if (data.version)
|
|
159
|
+
console.log(` Version : ${data.version}`);
|
|
160
|
+
if (data.widgets && data.widgets.length > 0) {
|
|
161
|
+
console.log(` Widgets : ${data.widgets.map((w) => w.name).join(', ')}`);
|
|
162
|
+
}
|
|
163
|
+
console.log('');
|
|
164
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { handleInit, validateBundleJson } from './commands/init.js';
|
|
3
|
+
import { handleUpload } from './commands/upload.js';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
// ─── Help text ────────────────────────────────────────────────────────────────
|
|
7
|
+
function printHelp() {
|
|
8
|
+
console.log(`
|
|
9
|
+
infaira-canvas — InfAIra Canvas Widget CLI
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
infaira-canvas init <name> Scaffold a new widget project
|
|
13
|
+
infaira-canvas upload [options] Upload a built widget bundle to the portal
|
|
14
|
+
infaira-canvas validate Validate bundle.json in the current directory
|
|
15
|
+
infaira-canvas help Show this help message
|
|
16
|
+
|
|
17
|
+
Examples:
|
|
18
|
+
infaira-canvas init my-sales-widget
|
|
19
|
+
|
|
20
|
+
infaira-canvas validate
|
|
21
|
+
|
|
22
|
+
infaira-canvas upload \\
|
|
23
|
+
--bundle ./bundle.json \\
|
|
24
|
+
--script ./dist/main.js
|
|
25
|
+
(reads ICAN_URL and ICAN_TOKEN from .env automatically)
|
|
26
|
+
|
|
27
|
+
infaira-canvas upload \\
|
|
28
|
+
--url http://localhost:4000 \\
|
|
29
|
+
--token eyJhbGci... \\
|
|
30
|
+
--bundle ./bundle.json \\
|
|
31
|
+
--script ./dist/main.js
|
|
32
|
+
|
|
33
|
+
Upload options:
|
|
34
|
+
--url <url> Portal base URL (overrides ICAN_URL in .env)
|
|
35
|
+
--token <token> Auth token JWT (overrides ICAN_TOKEN in .env)
|
|
36
|
+
--bundle <path> Path to bundle.json (default: ./bundle.json)
|
|
37
|
+
--script <path> Path to main.js (default: ./dist/main.js)
|
|
38
|
+
|
|
39
|
+
Environment variables (set in .env or shell):
|
|
40
|
+
ICAN_URL Portal base URL
|
|
41
|
+
ICAN_TOKEN Auth token JWT
|
|
42
|
+
`);
|
|
43
|
+
}
|
|
44
|
+
// ─── Argument parser ──────────────────────────────────────────────────────────
|
|
45
|
+
function parseFlags(args) {
|
|
46
|
+
const flags = {};
|
|
47
|
+
for (let i = 0; i < args.length; i++) {
|
|
48
|
+
const arg = args[i];
|
|
49
|
+
if (arg.startsWith('--') && i + 1 < args.length) {
|
|
50
|
+
const key = arg.slice(2);
|
|
51
|
+
const next = args[i + 1];
|
|
52
|
+
if (!next.startsWith('--')) {
|
|
53
|
+
flags[key] = next;
|
|
54
|
+
i++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return flags;
|
|
59
|
+
}
|
|
60
|
+
// ─── Entry point ──────────────────────────────────────────────────────────────
|
|
61
|
+
async function main() {
|
|
62
|
+
const args = process.argv.slice(2);
|
|
63
|
+
if (args.length === 0 || args[0] === 'help' || args[0] === '--help' || args[0] === '-h') {
|
|
64
|
+
printHelp();
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
const command = args[0];
|
|
68
|
+
switch (command) {
|
|
69
|
+
case 'init': {
|
|
70
|
+
const name = args[1];
|
|
71
|
+
if (!name) {
|
|
72
|
+
console.error('Error: missing widget name.\n Usage: infaira-canvas init <name>');
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
await handleInit(name);
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
case 'validate': {
|
|
79
|
+
const bundlePath = path.resolve(process.cwd(), 'bundle.json');
|
|
80
|
+
if (!fs.existsSync(bundlePath)) {
|
|
81
|
+
console.error('Error: bundle.json not found in current directory.');
|
|
82
|
+
console.error(' Run this command from inside your widget project folder.');
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
const raw = fs.readFileSync(bundlePath, 'utf-8');
|
|
86
|
+
const result = validateBundleJson(raw);
|
|
87
|
+
if (!result.valid) {
|
|
88
|
+
console.error(`\u2716 bundle.json is invalid: ${result.error}`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
const { data } = result;
|
|
92
|
+
console.log('\u2714 bundle.json is valid');
|
|
93
|
+
console.log(` Bundle ID : ${data.id}`);
|
|
94
|
+
console.log(` Name : ${data.name ?? '(not set)'}`);
|
|
95
|
+
console.log(` Version : ${data.version ?? '(not set)'}`);
|
|
96
|
+
console.log(` Widgets : ${data.widgets.map(w => w.id).join(', ')}`);
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
case 'upload': {
|
|
100
|
+
const flags = parseFlags(args.slice(1));
|
|
101
|
+
// Apply sensible defaults so you can just run "infaira-canvas upload" from widget folder
|
|
102
|
+
const bundle = flags['bundle'] ?? './bundle.json';
|
|
103
|
+
const script = flags['script'] ?? './dist/main.js';
|
|
104
|
+
await handleUpload({
|
|
105
|
+
url: flags['url'] ?? '',
|
|
106
|
+
token: flags['token'] ?? '',
|
|
107
|
+
bundlePath: bundle,
|
|
108
|
+
scriptPath: script,
|
|
109
|
+
});
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
default: {
|
|
113
|
+
console.error(`Unknown command: "${command}"`);
|
|
114
|
+
printHelp();
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
main().catch((err) => {
|
|
120
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
121
|
+
console.error(`Fatal error: ${message}`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "infaira-canvas",
|
|
3
|
+
"version": "0.1.9",
|
|
4
|
+
"description": "InfAIra Canvas CLI — Widget development toolkit for InfAIra Canvas",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"infaira",
|
|
7
|
+
"ican",
|
|
8
|
+
"widget",
|
|
9
|
+
"cli",
|
|
10
|
+
"scaffold"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://infaira.co",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"bin": {
|
|
16
|
+
"infaira-canvas": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"templates/resources",
|
|
21
|
+
"templates/ican.d.ts",
|
|
22
|
+
"templates/designer.d.ts",
|
|
23
|
+
"templates/index.html",
|
|
24
|
+
"templates/ui.html",
|
|
25
|
+
"templates/site.webmanifest",
|
|
26
|
+
"templates/README.md",
|
|
27
|
+
"templates/ICan-Widget-Theming-Guide.md",
|
|
28
|
+
"templates/ICan-Customizing-Components.md",
|
|
29
|
+
"templates/ICan-Widget-Styling-Patterns.md",
|
|
30
|
+
"templates/ICan-Widget-Development-Guide.md"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc",
|
|
37
|
+
"dev": "tsc --watch",
|
|
38
|
+
"prepublishOnly": "npm run build"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^22.0.0",
|
|
42
|
+
"typescript": "^5.7.0"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Customizing ICan Components — `style` and `className`
|
|
2
|
+
|
|
3
|
+
Every ICan component (`Card`, `Button`, `Badge`, `StatCard`, `Input`, `Tabs`,
|
|
4
|
+
`Modal`, charts, etc.) accepts two universal style hooks:
|
|
5
|
+
|
|
6
|
+
| Prop | Purpose |
|
|
7
|
+
|-------------|--------------------------------------------------------------------------|
|
|
8
|
+
| `style` | Inline overrides merged on top of the component’s default inline styles |
|
|
9
|
+
| `className` | A custom class merged with the component’s built-in class (if any) |
|
|
10
|
+
|
|
11
|
+
Both work identically in `npm run dev` (the local harness) and in the ICan portal
|
|
12
|
+
after upload — the dev harness mirrors the portal’s `widget-bridge.ts` behavior.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## The Three Customization Patterns
|
|
17
|
+
|
|
18
|
+
### 1. ICan component + your `style` prop
|
|
19
|
+
|
|
20
|
+
Use this when you want to keep the component’s structure but tweak its
|
|
21
|
+
appearance with one-off overrides. Always go through CSS variables so themes
|
|
22
|
+
keep working:
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
<Card style={{
|
|
26
|
+
border: '2px solid var(--ican-accent)',
|
|
27
|
+
background: 'var(--ican-accent-dim)',
|
|
28
|
+
borderRadius: 'var(--ican-radius-lg)',
|
|
29
|
+
padding: '24px',
|
|
30
|
+
}}>
|
|
31
|
+
…
|
|
32
|
+
</Card>
|
|
33
|
+
|
|
34
|
+
<Button
|
|
35
|
+
label="View Report"
|
|
36
|
+
variant="primary"
|
|
37
|
+
style={{
|
|
38
|
+
borderRadius: 'var(--ican-radius-md)',
|
|
39
|
+
padding: '8px 20px',
|
|
40
|
+
fontWeight: 600,
|
|
41
|
+
}}
|
|
42
|
+
onClick={onClick}
|
|
43
|
+
/>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### 2. Build your own elements (no ICan component)
|
|
47
|
+
|
|
48
|
+
Skip the component library entirely and style raw `<div>` / `<button>` elements
|
|
49
|
+
in SCSS using ICan CSS variables:
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
<div className="stat-card">
|
|
53
|
+
<span className="stat-card__label">Active Users</span>
|
|
54
|
+
<span className="stat-card__value">12,847</span>
|
|
55
|
+
</div>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```scss
|
|
59
|
+
.stat-card {
|
|
60
|
+
background: var(--ican-card-bg);
|
|
61
|
+
border: 1px solid var(--ican-glass-border);
|
|
62
|
+
border-radius: var(--ican-radius-lg);
|
|
63
|
+
padding: 16px;
|
|
64
|
+
backdrop-filter: var(--ican-backdrop-filter);
|
|
65
|
+
}
|
|
66
|
+
.stat-card__label { color: var(--ican-secondary-text); font-size: 11px; }
|
|
67
|
+
.stat-card__value { color: var(--ican-primary-text); font-size: 1.5rem; }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 3. ICan component + `className` AND `style`
|
|
71
|
+
|
|
72
|
+
Use `className` for layout / structural rules (in your SCSS) and `style` for
|
|
73
|
+
small contextual overrides. This keeps SCSS the source of truth for theming
|
|
74
|
+
while inline `style` handles per-instance tweaks:
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
<Card className="order-card" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
78
|
+
<Badge className="live-badge" label="LIVE" variant="success" />
|
|
79
|
+
<Button className="go-btn" label="Go to Orders →" style={{ width: '100%' }} onClick={onClick} />
|
|
80
|
+
</Card>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```scss
|
|
84
|
+
.order-card { padding: 20px !important; border-radius: var(--ican-radius-lg) !important; }
|
|
85
|
+
.live-badge { padding: 3px 8px !important; }
|
|
86
|
+
.go-btn { margin-top: auto; border-radius: var(--ican-radius-md) !important; }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
> Why `!important`? ICan components ship with inline `style`. SCSS class rules
|
|
90
|
+
> can’t override inline styles without `!important`. If you’d rather avoid it,
|
|
91
|
+
> put the value in the `style` prop instead.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Style Merge Order (most → least specific)
|
|
96
|
+
|
|
97
|
+
When all three are present, the final rendered style is computed as:
|
|
98
|
+
|
|
99
|
+
1. Component’s built-in inline `style` *(lowest priority)*
|
|
100
|
+
2. Your `style` prop *(overrides #1)*
|
|
101
|
+
3. Your `className` rule with `!important` in SCSS *(overrides everything)*
|
|
102
|
+
4. Your `className` rule **without** `!important` *(only overrides #1)*
|
|
103
|
+
|
|
104
|
+
Pick the layer that matches the override’s scope:
|
|
105
|
+
|
|
106
|
+
| Override type | Best layer |
|
|
107
|
+
|--------------------------------------------|-------------------------------------|
|
|
108
|
+
| One-off inline tweak | `style` prop |
|
|
109
|
+
| Reusable structural rule | `className` + SCSS |
|
|
110
|
+
| Replace a built-in inline default | `className` + SCSS + `!important` |
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Always Use ICan CSS Variables Inside Overrides
|
|
115
|
+
|
|
116
|
+
Whether you go via `style` or `className`, your overrides must still resolve
|
|
117
|
+
through CSS variables so the four themes (Dark / Light / Glass-Dark /
|
|
118
|
+
Glass-Light) keep working:
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
// ✅ Theme-aware
|
|
122
|
+
style={{ background: 'var(--ican-accent-dim)', color: 'var(--ican-primary-text)' }}
|
|
123
|
+
|
|
124
|
+
// ❌ Hardcoded — breaks on 3 of 4 themes
|
|
125
|
+
style={{ background: '#7c3aed22', color: '#fafafa' }}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
See [ICan-Widget-Theming-Guide.md](./ICan-Widget-Theming-Guide.md) for the full
|
|
129
|
+
list of CSS variables and the four-theme matrix.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Dev Harness vs. Portal Parity
|
|
134
|
+
|
|
135
|
+
The dev harness mock (`resources/ican-components.js`) and the portal’s
|
|
136
|
+
`widget-bridge.ts` both apply your `className` and merge your `style` over the
|
|
137
|
+
component’s defaults. So if a customization works in `npm run dev`, it works in
|
|
138
|
+
the portal — and vice versa.
|
|
139
|
+
|
|
140
|
+
If you ever notice a mismatch, it’s a harness bug — please file an issue and
|
|
141
|
+
we’ll sync the harness back to the portal’s behavior.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## v0.1.9 — New Component Features
|
|
146
|
+
|
|
147
|
+
The 0.1.9 release added several developer-friendly props to existing
|
|
148
|
+
components. Highlights worth knowing:
|
|
149
|
+
|
|
150
|
+
| Component | What’s new |
|
|
151
|
+
|-----------|-----------|
|
|
152
|
+
| `Button` | `icon` prop — render an icon (string or emoji) before the label. |
|
|
153
|
+
| `Card` | `header` and `footer` slots — pass any node; rendered with built-in dividers. |
|
|
154
|
+
| `StatCard` | `description` prop — small subtext under the value (e.g. “vs last month”). |
|
|
155
|
+
| `NotificationBlock` | `title` prop — bold heading above `message`. |
|
|
156
|
+
| `Modal` | Closes on **Escape** by default. Pass `closable={false}` to disable Escape, X-button, and backdrop click for forced flows. |
|
|
157
|
+
| `Tabs` | Arrow-key navigation between tabs (`role="tablist"` for a11y). |
|
|
158
|
+
| `Tooltip` | `delay` prop (default 200ms) prevents flicker on quick mouse-overs. |
|
|
159
|
+
| `Pagination` | Shows `Page X / Y` and a jump-to-page input when there are >5 pages. |
|
|
160
|
+
| `DataTable` | Set `sortable: true` per column → header becomes clickable; cycles `asc → desc → off`. Row-hover background uses `--ican-hover`. |
|
|
161
|
+
| `ItemCard` | When `onClick` is provided, the card lifts on hover (`translateY(-2px)` + shadow). |
|
|
162
|
+
| `RadialGauge` | Hover anywhere on the gauge → tooltip showing exact value and percentage. |
|
|
163
|
+
| `LineChart` / `BarChart` | Render a clean *“No data”* state when given empty input (no more crashes). |
|
|
164
|
+
| `Input`, `TextArea`, `Select` | Themed focus ring using `--ican-accent` / `--ican-accent-dim`. |
|
|
165
|
+
|
|
166
|
+
### Quick examples
|
|
167
|
+
|
|
168
|
+
```tsx
|
|
169
|
+
<Button icon="↻" label="Refresh" onClick={refresh} />
|
|
170
|
+
|
|
171
|
+
<Card header="Active Sessions" footer={<Button label="View all" onClick={…} />}>
|
|
172
|
+
…
|
|
173
|
+
</Card>
|
|
174
|
+
|
|
175
|
+
<StatCard label="Revenue" value="$248K" trend={12.4} description="vs last month" />
|
|
176
|
+
|
|
177
|
+
<NotificationBlock type="warning" title="High load" message="Approaching rate limit." />
|
|
178
|
+
|
|
179
|
+
<Modal open={open} onClose={…} title="Required" closable={false}>
|
|
180
|
+
Accept the terms to continue.
|
|
181
|
+
</Modal>
|
|
182
|
+
|
|
183
|
+
<DataTable
|
|
184
|
+
rows={rows}
|
|
185
|
+
columns={[
|
|
186
|
+
{ key: 'name', label: 'Name', sortable: true },
|
|
187
|
+
{ key: 'status', label: 'Status', sortable: true },
|
|
188
|
+
{ key: 'load', label: 'Load' },
|
|
189
|
+
]}
|
|
190
|
+
/>
|
|
191
|
+
|
|
192
|
+
<Tooltip content="Refresh data" delay={400}>
|
|
193
|
+
<IconButton type="refresh" onClick={refresh} />
|
|
194
|
+
</Tooltip>
|
|
195
|
+
```
|