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,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
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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
+ ```