@entrinsik/vite-plugin-informer 1.0.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/bin/deploy.js +57 -0
- package/bin/init.js +434 -0
- package/package.json +30 -0
- package/src/client.js +114 -0
- package/src/deploy.js +156 -0
- package/src/index.js +88 -0
- package/templates/skill/SKILL.md +388 -0
- package/templates/skill/references/api-reference.md +242 -0
- package/templates/skill/references/report-templates.md +299 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# API Quick Reference
|
|
2
|
+
|
|
3
|
+
All endpoints are relative to `/api`. The Vite plugin handles authentication in dev mode.
|
|
4
|
+
|
|
5
|
+
## Datasets
|
|
6
|
+
|
|
7
|
+
### GET /api/datasets-list
|
|
8
|
+
List all datasets the user can access.
|
|
9
|
+
|
|
10
|
+
```javascript
|
|
11
|
+
const datasets = await fetch('/api/datasets-list').then(r => r.json());
|
|
12
|
+
// [{ id, name, description, records, size, tags, sharing }, ...]
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### POST /api/datasets/{id}/_search
|
|
16
|
+
Query dataset data using Elasticsearch DSL.
|
|
17
|
+
|
|
18
|
+
```javascript
|
|
19
|
+
const result = await fetch(`/api/datasets/${id}/_search`, {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: { 'Content-Type': 'application/json' },
|
|
22
|
+
body: JSON.stringify({
|
|
23
|
+
query: { match_all: {} },
|
|
24
|
+
size: 100,
|
|
25
|
+
from: 0,
|
|
26
|
+
_source: ['field1', 'field2'],
|
|
27
|
+
sort: [{ field1: 'desc' }],
|
|
28
|
+
aggs: { total: { sum: { field: 'amount' } } }
|
|
29
|
+
})
|
|
30
|
+
}).then(r => r.json());
|
|
31
|
+
|
|
32
|
+
// result.hits.total - count
|
|
33
|
+
// result.hits.hits - [{ _source: { ... } }, ...]
|
|
34
|
+
// result.aggregations - { total: { value: 12345 } }
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**Query patterns:**
|
|
38
|
+
```javascript
|
|
39
|
+
// All records
|
|
40
|
+
{ query: { match_all: {} } }
|
|
41
|
+
|
|
42
|
+
// Exact match
|
|
43
|
+
{ query: { bool: { filter: [{ term: { status: 'active' } }] } } }
|
|
44
|
+
|
|
45
|
+
// Range
|
|
46
|
+
{ query: { bool: { filter: [{ range: { amount: { gte: 1000 } } }] } } }
|
|
47
|
+
|
|
48
|
+
// Date range
|
|
49
|
+
{ query: { bool: { filter: [{ range: { date: { gte: '2024-01-01', lte: '2024-12-31' } } }] } } }
|
|
50
|
+
|
|
51
|
+
// Multiple filters
|
|
52
|
+
{ query: { bool: { filter: [
|
|
53
|
+
{ term: { region: 'North' } },
|
|
54
|
+
{ range: { amount: { gte: 1000 } } }
|
|
55
|
+
] } } }
|
|
56
|
+
|
|
57
|
+
// Text search
|
|
58
|
+
{ query: { bool: { must: [{ match: { name: 'search text' } }] } } }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Aggregation patterns:**
|
|
62
|
+
```javascript
|
|
63
|
+
// Metrics
|
|
64
|
+
{ aggs: { total: { sum: { field: 'amount' } } } }
|
|
65
|
+
{ aggs: { average: { avg: { field: 'amount' } } } }
|
|
66
|
+
{ aggs: { minimum: { min: { field: 'amount' } } } }
|
|
67
|
+
{ aggs: { maximum: { max: { field: 'amount' } } } }
|
|
68
|
+
{ aggs: { count: { value_count: { field: 'id' } } } }
|
|
69
|
+
{ aggs: { unique: { cardinality: { field: 'customer' } } } }
|
|
70
|
+
|
|
71
|
+
// Group by
|
|
72
|
+
{ aggs: { by_status: { terms: { field: 'status', size: 50 } } } }
|
|
73
|
+
|
|
74
|
+
// Group with metric
|
|
75
|
+
{ aggs: {
|
|
76
|
+
by_region: {
|
|
77
|
+
terms: { field: 'region', size: 50 },
|
|
78
|
+
aggs: { total: { sum: { field: 'amount' } } }
|
|
79
|
+
}
|
|
80
|
+
} }
|
|
81
|
+
|
|
82
|
+
// Date histogram
|
|
83
|
+
{ aggs: {
|
|
84
|
+
by_month: {
|
|
85
|
+
date_histogram: { field: 'date', calendar_interval: 'month' },
|
|
86
|
+
aggs: { total: { sum: { field: 'amount' } } }
|
|
87
|
+
}
|
|
88
|
+
} }
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Queries
|
|
92
|
+
|
|
93
|
+
### GET /api/queries-list
|
|
94
|
+
List saved queries.
|
|
95
|
+
|
|
96
|
+
```javascript
|
|
97
|
+
const queries = await fetch('/api/queries-list').then(r => r.json());
|
|
98
|
+
// [{ id, name, description, tags, sharing }, ...]
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### POST /api/queries/{id}/_execute
|
|
102
|
+
Execute a saved query.
|
|
103
|
+
|
|
104
|
+
```javascript
|
|
105
|
+
const result = await fetch(`/api/queries/${id}/_execute`, {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: { 'Content-Type': 'application/json' },
|
|
108
|
+
body: JSON.stringify({
|
|
109
|
+
parameters: { startDate: '2024-01-01' }
|
|
110
|
+
})
|
|
111
|
+
}).then(r => r.json());
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Integrations
|
|
115
|
+
|
|
116
|
+
### GET /api/integrations
|
|
117
|
+
List configured integrations.
|
|
118
|
+
|
|
119
|
+
```javascript
|
|
120
|
+
const result = await fetch('/api/integrations').then(r => r.json());
|
|
121
|
+
// result.items = [{ id, name, slug, type, description }, ...]
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### POST /api/integrations/{id}/request
|
|
125
|
+
Make an authenticated request through an integration.
|
|
126
|
+
|
|
127
|
+
```javascript
|
|
128
|
+
const result = await fetch(`/api/integrations/${slugOrId}/request`, {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: { 'Content-Type': 'application/json' },
|
|
131
|
+
body: JSON.stringify({
|
|
132
|
+
url: '/path/to/endpoint',
|
|
133
|
+
method: 'GET', // GET, POST, PUT, PATCH, DELETE
|
|
134
|
+
params: { key: 'value' }, // Query parameters
|
|
135
|
+
data: { key: 'value' }, // Request body (POST/PUT/PATCH)
|
|
136
|
+
headers: {} // Additional headers
|
|
137
|
+
})
|
|
138
|
+
}).then(r => r.json());
|
|
139
|
+
|
|
140
|
+
// result.status - HTTP status
|
|
141
|
+
// result.data - Response body
|
|
142
|
+
// result.error - true if error status
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**Salesforce examples:**
|
|
146
|
+
|
|
147
|
+
```javascript
|
|
148
|
+
// SOQL query
|
|
149
|
+
await fetch('/api/integrations/salesforce/request', {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: { 'Content-Type': 'application/json' },
|
|
152
|
+
body: JSON.stringify({
|
|
153
|
+
url: '/data/v59.0/query',
|
|
154
|
+
method: 'GET',
|
|
155
|
+
params: { q: "SELECT Id, Name FROM Account LIMIT 10" }
|
|
156
|
+
})
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Get record
|
|
160
|
+
await fetch('/api/integrations/salesforce/request', {
|
|
161
|
+
method: 'POST',
|
|
162
|
+
headers: { 'Content-Type': 'application/json' },
|
|
163
|
+
body: JSON.stringify({
|
|
164
|
+
url: '/data/v59.0/sobjects/Account/001xxxxxxxxxxxx',
|
|
165
|
+
method: 'GET'
|
|
166
|
+
})
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Create record
|
|
170
|
+
await fetch('/api/integrations/salesforce/request', {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: { 'Content-Type': 'application/json' },
|
|
173
|
+
body: JSON.stringify({
|
|
174
|
+
url: '/data/v59.0/sobjects/Contact',
|
|
175
|
+
method: 'POST',
|
|
176
|
+
data: { FirstName: 'John', LastName: 'Doe', Email: 'john@example.com' }
|
|
177
|
+
})
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Data Access Configuration
|
|
182
|
+
|
|
183
|
+
Create `data-access.yaml` in your project root to declare which APIs your report needs. Without this file, all API access is blocked when published.
|
|
184
|
+
|
|
185
|
+
```yaml
|
|
186
|
+
# data-access.yaml
|
|
187
|
+
|
|
188
|
+
datasets:
|
|
189
|
+
- admin:sales-data
|
|
190
|
+
|
|
191
|
+
queries:
|
|
192
|
+
- admin:summary
|
|
193
|
+
|
|
194
|
+
integrations:
|
|
195
|
+
- salesforce
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Row-Level Security
|
|
199
|
+
|
|
200
|
+
```yaml
|
|
201
|
+
datasets:
|
|
202
|
+
- id: admin:orders
|
|
203
|
+
filter:
|
|
204
|
+
region: $user.custom.region
|
|
205
|
+
owner: $user.username
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Integration Credentials
|
|
209
|
+
|
|
210
|
+
```yaml
|
|
211
|
+
integrations:
|
|
212
|
+
- id: partner-api
|
|
213
|
+
headers:
|
|
214
|
+
Authorization: Bearer $user.custom.apiToken
|
|
215
|
+
params:
|
|
216
|
+
tenant: $tenant.id
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Variables
|
|
220
|
+
|
|
221
|
+
- `$user.username`, `$user.email`, `$user.displayName`
|
|
222
|
+
- `$user.custom.xxx` - Custom user fields
|
|
223
|
+
- `$tenant.id`
|
|
224
|
+
- `$report.id`, `$report.name`
|
|
225
|
+
|
|
226
|
+
## Error Handling
|
|
227
|
+
|
|
228
|
+
API errors return:
|
|
229
|
+
```javascript
|
|
230
|
+
{
|
|
231
|
+
statusCode: 400,
|
|
232
|
+
error: 'Bad Request',
|
|
233
|
+
message: 'Description of error'
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Common status codes:
|
|
238
|
+
- `400` - Bad request / validation error
|
|
239
|
+
- `401` - Not authenticated
|
|
240
|
+
- `403` - Not authorized
|
|
241
|
+
- `404` - Not found
|
|
242
|
+
- `502` - Upstream error (integration request failed)
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# Report Templates
|
|
2
|
+
|
|
3
|
+
## Minimal Starter
|
|
4
|
+
|
|
5
|
+
### index.html
|
|
6
|
+
```html
|
|
7
|
+
<!DOCTYPE html>
|
|
8
|
+
<html lang="en">
|
|
9
|
+
<head>
|
|
10
|
+
<meta charset="UTF-8">
|
|
11
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
12
|
+
<title>Report</title>
|
|
13
|
+
<link rel="stylesheet" href="styles.css">
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<div id="app">
|
|
17
|
+
<h1>Report Title</h1>
|
|
18
|
+
<div id="content"></div>
|
|
19
|
+
</div>
|
|
20
|
+
<script type="module" src="main.js"></script>
|
|
21
|
+
</body>
|
|
22
|
+
</html>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### styles.css
|
|
26
|
+
```css
|
|
27
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
28
|
+
|
|
29
|
+
body {
|
|
30
|
+
font-family: system-ui, sans-serif;
|
|
31
|
+
background: #0f172a;
|
|
32
|
+
color: #f1f5f9;
|
|
33
|
+
padding: 24px;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#app {
|
|
37
|
+
max-width: 1200px;
|
|
38
|
+
margin: 0 auto;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
h1 {
|
|
42
|
+
font-size: 24px;
|
|
43
|
+
margin-bottom: 24px;
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### main.js
|
|
48
|
+
```javascript
|
|
49
|
+
window.informerReady = false;
|
|
50
|
+
|
|
51
|
+
async function init() {
|
|
52
|
+
// List available datasets
|
|
53
|
+
const datasets = await fetch('/api/datasets-list').then(r => r.json());
|
|
54
|
+
console.log('Available datasets:', datasets);
|
|
55
|
+
|
|
56
|
+
// Query a dataset
|
|
57
|
+
if (datasets.length > 0) {
|
|
58
|
+
const result = await fetch(`/api/datasets/${datasets[0].id}/_search`, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { 'Content-Type': 'application/json' },
|
|
61
|
+
body: JSON.stringify({ query: { match_all: {} }, size: 10 })
|
|
62
|
+
}).then(r => r.json());
|
|
63
|
+
|
|
64
|
+
const records = result.hits.hits.map(h => h._source);
|
|
65
|
+
console.log('Records:', records);
|
|
66
|
+
|
|
67
|
+
// Render your content here
|
|
68
|
+
document.getElementById('content').innerHTML = `
|
|
69
|
+
<p>Found ${result.hits.total} records</p>
|
|
70
|
+
<pre>${JSON.stringify(records, null, 2)}</pre>
|
|
71
|
+
`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
window.informerReady = true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
init();
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Dashboard Layout
|
|
81
|
+
|
|
82
|
+
### index.html
|
|
83
|
+
```html
|
|
84
|
+
<!DOCTYPE html>
|
|
85
|
+
<html lang="en">
|
|
86
|
+
<head>
|
|
87
|
+
<meta charset="UTF-8">
|
|
88
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
89
|
+
<title>Dashboard</title>
|
|
90
|
+
<link rel="stylesheet" href="styles.css">
|
|
91
|
+
</head>
|
|
92
|
+
<body>
|
|
93
|
+
<div class="dashboard">
|
|
94
|
+
<header>
|
|
95
|
+
<h1>Dashboard Title</h1>
|
|
96
|
+
<p class="subtitle">Description of this dashboard</p>
|
|
97
|
+
</header>
|
|
98
|
+
|
|
99
|
+
<div class="metrics">
|
|
100
|
+
<div class="metric-card">
|
|
101
|
+
<span class="metric-value" id="metric1">--</span>
|
|
102
|
+
<span class="metric-label">Metric 1</span>
|
|
103
|
+
</div>
|
|
104
|
+
<div class="metric-card">
|
|
105
|
+
<span class="metric-value" id="metric2">--</span>
|
|
106
|
+
<span class="metric-label">Metric 2</span>
|
|
107
|
+
</div>
|
|
108
|
+
<div class="metric-card">
|
|
109
|
+
<span class="metric-value" id="metric3">--</span>
|
|
110
|
+
<span class="metric-label">Metric 3</span>
|
|
111
|
+
</div>
|
|
112
|
+
<div class="metric-card">
|
|
113
|
+
<span class="metric-value" id="metric4">--</span>
|
|
114
|
+
<span class="metric-label">Metric 4</span>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div class="content">
|
|
119
|
+
<!-- Add your charts, tables, or other visualizations here -->
|
|
120
|
+
<div id="visualization"></div>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<script type="module" src="main.js"></script>
|
|
125
|
+
</body>
|
|
126
|
+
</html>
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### styles.css
|
|
130
|
+
```css
|
|
131
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
132
|
+
|
|
133
|
+
:root {
|
|
134
|
+
--bg: #0f172a;
|
|
135
|
+
--bg-card: #1e293b;
|
|
136
|
+
--border: #334155;
|
|
137
|
+
--text: #f1f5f9;
|
|
138
|
+
--text-muted: #94a3b8;
|
|
139
|
+
--primary: #6366f1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
body {
|
|
143
|
+
font-family: system-ui, sans-serif;
|
|
144
|
+
background: var(--bg);
|
|
145
|
+
color: var(--text);
|
|
146
|
+
line-height: 1.5;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.dashboard {
|
|
150
|
+
max-width: 1400px;
|
|
151
|
+
margin: 0 auto;
|
|
152
|
+
padding: 32px 24px;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
header {
|
|
156
|
+
margin-bottom: 32px;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
header h1 {
|
|
160
|
+
font-size: 28px;
|
|
161
|
+
font-weight: 700;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.subtitle {
|
|
165
|
+
color: var(--text-muted);
|
|
166
|
+
margin-top: 4px;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.metrics {
|
|
170
|
+
display: grid;
|
|
171
|
+
grid-template-columns: repeat(4, 1fr);
|
|
172
|
+
gap: 16px;
|
|
173
|
+
margin-bottom: 24px;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.metric-card {
|
|
177
|
+
background: var(--bg-card);
|
|
178
|
+
border: 1px solid var(--border);
|
|
179
|
+
border-radius: 12px;
|
|
180
|
+
padding: 20px;
|
|
181
|
+
text-align: center;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.metric-value {
|
|
185
|
+
display: block;
|
|
186
|
+
font-size: 32px;
|
|
187
|
+
font-weight: 700;
|
|
188
|
+
color: var(--primary);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.metric-label {
|
|
192
|
+
display: block;
|
|
193
|
+
font-size: 13px;
|
|
194
|
+
color: var(--text-muted);
|
|
195
|
+
margin-top: 4px;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.content {
|
|
199
|
+
background: var(--bg-card);
|
|
200
|
+
border: 1px solid var(--border);
|
|
201
|
+
border-radius: 12px;
|
|
202
|
+
padding: 24px;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
@media (max-width: 768px) {
|
|
206
|
+
.metrics { grid-template-columns: repeat(2, 1fr); }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
@media (max-width: 480px) {
|
|
210
|
+
.metrics { grid-template-columns: 1fr; }
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### main.js
|
|
215
|
+
```javascript
|
|
216
|
+
window.informerReady = false;
|
|
217
|
+
|
|
218
|
+
async function init() {
|
|
219
|
+
try {
|
|
220
|
+
await loadData();
|
|
221
|
+
window.informerReady = true;
|
|
222
|
+
} catch (err) {
|
|
223
|
+
console.error('Failed to load:', err);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function loadData() {
|
|
228
|
+
// Replace with your dataset ID
|
|
229
|
+
const datasetId = 'admin:your-dataset';
|
|
230
|
+
|
|
231
|
+
const result = await fetch(`/api/datasets/${datasetId}/_search`, {
|
|
232
|
+
method: 'POST',
|
|
233
|
+
headers: { 'Content-Type': 'application/json' },
|
|
234
|
+
body: JSON.stringify({
|
|
235
|
+
query: { match_all: {} },
|
|
236
|
+
size: 0,
|
|
237
|
+
aggs: {
|
|
238
|
+
total: { sum: { field: 'amount' } },
|
|
239
|
+
count: { value_count: { field: 'id' } },
|
|
240
|
+
avg: { avg: { field: 'amount' } },
|
|
241
|
+
by_category: {
|
|
242
|
+
terms: { field: 'category', size: 10 },
|
|
243
|
+
aggs: { total: { sum: { field: 'amount' } } }
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
}).then(r => r.json());
|
|
248
|
+
|
|
249
|
+
// Update metrics
|
|
250
|
+
const aggs = result.aggregations;
|
|
251
|
+
document.getElementById('metric1').textContent = formatCurrency(aggs.total.value);
|
|
252
|
+
document.getElementById('metric2').textContent = aggs.count.value.toLocaleString();
|
|
253
|
+
document.getElementById('metric3').textContent = formatCurrency(aggs.avg.value);
|
|
254
|
+
document.getElementById('metric4').textContent = result.hits.total.toLocaleString();
|
|
255
|
+
|
|
256
|
+
// Use aggs.by_category.buckets for visualization
|
|
257
|
+
// Each bucket has: { key: 'Category Name', doc_count: 123, total: { value: 456 } }
|
|
258
|
+
console.log('Category data:', aggs.by_category.buckets);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function formatCurrency(val) {
|
|
262
|
+
return new Intl.NumberFormat('en-US', {
|
|
263
|
+
style: 'currency',
|
|
264
|
+
currency: 'USD',
|
|
265
|
+
minimumFractionDigits: 0
|
|
266
|
+
}).format(val);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
init();
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## PDF Export Tips
|
|
273
|
+
|
|
274
|
+
The PDF renderer uses **print media** and adds a `.print` class to `<html>`.
|
|
275
|
+
|
|
276
|
+
```css
|
|
277
|
+
/* Standard @media print works */
|
|
278
|
+
@media print {
|
|
279
|
+
body {
|
|
280
|
+
background: white;
|
|
281
|
+
color: black;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.no-print {
|
|
285
|
+
display: none;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/* Avoid page breaks inside charts/cards */
|
|
289
|
+
.chart-container,
|
|
290
|
+
.metric-card {
|
|
291
|
+
break-inside: avoid;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/* Or use the .print class */
|
|
296
|
+
.print .no-print {
|
|
297
|
+
display: none;
|
|
298
|
+
}
|
|
299
|
+
```
|