pi-cost 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
Binary file
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
2
+ <rect width="512" height="512" rx="96" fill="#1a1a1a"/>
3
+ <g fill="none" stroke="#e86f33" stroke-width="44" stroke-linecap="round" stroke-linejoin="round" transform="translate(40 24) scale(0.84)">
4
+ <line x1="256" y1="64" x2="256" y2="448"/>
5
+ <path d="M352 152H208a72 72 0 000 144h96a72 72 0 010 144H144"/>
6
+ </g>
7
+ </svg>
@@ -0,0 +1,109 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>pi-cost</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Playfair+Display:wght@400;500;600&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="style.css">
11
+ <script defer src="https://cdn.jsdelivr.net/npm/chart.js"></script>
12
+ <link rel="manifest" href="/manifest.json">
13
+ <meta name="theme-color" content="#101114" media="(prefers-color-scheme: dark)">
14
+ <meta name="theme-color" content="#e8e6e3" media="(prefers-color-scheme: light)">
15
+ <script defer src="/app.js"></script>
16
+ </head>
17
+ <body>
18
+ <div class="topbar">
19
+ <div class="topbar-logo" onclick="navigate('overview')" style="cursor:pointer" title="Go to Overview">
20
+ <div class="logo-mark">
21
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
22
+ <line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/>
23
+ </svg>
24
+ </div>
25
+ <span class="logo-text">pi-cost</span>
26
+ </div>
27
+ <div class="spacer"></div>
28
+ <div class="topbar-nav" id="viewNav">
29
+ <button class="topbar-nav-btn active" data-view="overview" onclick="navigate('overview')">Overview</button>
30
+ <button class="topbar-nav-btn" data-view="projects" onclick="navigate('projects')">Projects</button>
31
+ </div>
32
+ <select class="topbar-select" id="rangeSelect" onchange="onRangeChange(this.value)">
33
+ <option value="today">Today</option>
34
+ <option value="1">1 day</option>
35
+ <option value="3" selected>3 days</option>
36
+ <option value="7">7 days</option>
37
+ <option value="30">30 days</option>
38
+ <option value="90">90 days</option>
39
+ <option value="365">1 year</option>
40
+ </select>
41
+ <button class="topbar-btn" id="refreshBtn" title="Refresh data" onclick="refreshData()">
42
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
43
+ Refresh
44
+ </button>
45
+ <button class="topbar-btn" id="themeBtn" title="Toggle theme" onclick="toggleTheme()">
46
+ <svg id="theme-icon-dark" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg>
47
+ <svg id="theme-icon-light" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:none"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
48
+ </button>
49
+ <button class="topbar-btn" id="infoBtn" title="Help (?)" onclick="toggleHelpModal()">
50
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><circle cx="12" cy="17" r="0.5" fill="currentColor"/></svg>
51
+ </button>
52
+ </div>
53
+
54
+ <div class="modal-overlay" id="helpModal" onclick="if(event.target===this)toggleHelpModal()">
55
+ <div class="modal-box">
56
+ <div class="modal-header"><div class="modal-title">pi-cost</div></div>
57
+ <div class="modal-body">
58
+ <div class="modal-section">
59
+ <p>Cost dashboard for the <a href="https://pi.dev" target="_blank" rel="noopener">pi coding agent</a>. Reads JSONL session files from <code>~/.pi/agent/sessions</code>.</p>
60
+ <p>Costs come from two sources:</p>
61
+ <p>
62
+ <span class="src-badge src-jsonl">actual</span> &mdash; pi recorded the cost in the JSONL.<br>
63
+ <span class="src-badge src-pricing">estimated</span> &mdash; computed from token counts and <a href="https://github.com/BerriAI/litellm" target="_blank" rel="noopener">LiteLLM</a> pricing (refreshed every 6h).
64
+ </p>
65
+ </div>
66
+ <hr class="modal-divider">
67
+ <h4 class="shortcut-section">Keyboard Shortcuts</h4>
68
+ <div class="shortcut-grid">
69
+ <div>
70
+ <h4 class="shortcut-section">Navigation</h4>
71
+ <table class="shortcut-table">
72
+ <tr><td><kbd>1</kbd></td><td>Overview</td></tr>
73
+ <tr><td><kbd>2</kbd></td><td>Projects</td></tr>
74
+ <tr><td><kbd>Backspace</kbd></td><td>Go back</td></tr>
75
+ <tr><td><kbd>Esc</kbd></td><td>Close / back</td></tr>
76
+ </table>
77
+ </div>
78
+ <div>
79
+ <h4 class="shortcut-section">Table</h4>
80
+ <table class="shortcut-table">
81
+ <tr><td><kbd>J</kbd> / <kbd>&darr;</kbd></td><td>Next row</td></tr>
82
+ <tr><td><kbd>K</kbd> / <kbd>&uarr;</kbd></td><td>Previous row</td></tr>
83
+ <tr><td><kbd>Enter</kbd></td><td>Open</td></tr>
84
+ </table>
85
+ </div>
86
+ <div>
87
+ <h4 class="shortcut-section">Actions</h4>
88
+ <table class="shortcut-table">
89
+ <tr><td><kbd>R</kbd></td><td>Refresh</td></tr>
90
+ <tr><td><kbd>T</kbd></td><td>Toggle theme</td></tr>
91
+ <tr><td><kbd>?</kbd></td><td>This help</td></tr>
92
+ </table>
93
+ </div>
94
+ </div>
95
+ </div>
96
+ <button class="modal-close" onclick="toggleHelpModal()">Close</button>
97
+ </div>
98
+ </div>
99
+
100
+ <div class="dashboard" id="app">
101
+ <div class="loading-state" id="loadingState">
102
+ <div class="loading-spinner"></div>
103
+ <span>Loading cost data...</span>
104
+ </div>
105
+ </div>
106
+
107
+ <div class="toast-container" id="toast"></div>
108
+ </body>
109
+ </html>
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "pi-cost",
3
+ "short_name": "pi-cost",
4
+ "description": "Cost dashboard for the pi coding agent",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "background_color": "#101114",
8
+ "theme_color": "#e86f33",
9
+ "icons": [
10
+ {
11
+ "src": "/icons/icon-192.png",
12
+ "sizes": "192x192",
13
+ "type": "image/png"
14
+ },
15
+ {
16
+ "src": "/icons/icon-512.png",
17
+ "sizes": "512x512",
18
+ "type": "image/png"
19
+ },
20
+ {
21
+ "src": "/icons/icon-svg.svg",
22
+ "sizes": "any",
23
+ "type": "image/svg+xml"
24
+ }
25
+ ]
26
+ }
@@ -0,0 +1,420 @@
1
+ /* #region VARIABLES */
2
+ :root {
3
+ /* pi-dark defaults (matches the default dark theme so first paint has no flash). */
4
+ --bg-deep: #0d1116;
5
+ --bg-surface: #161d27;
6
+ --bg-elevated: #212730;
7
+ --bg-hover: #252f3d;
8
+ --border: #495059;
9
+ --text-primary: #ebe7e4;
10
+ --text-secondary: #d5d8db;
11
+ --text-tertiary: #9fa4ab;
12
+ --text-muted: #757d89;
13
+ --accent: #6a9fcc;
14
+ --accent-text: #8fb6d8;
15
+ --accent-dim: rgba(106, 159, 204, 0.22);
16
+ --accent-glow: rgba(106, 159, 204, 0.55);
17
+ --accent-faint: rgba(106, 159, 204, 0.05);
18
+ --accent-soft: rgba(106, 159, 204, 0.12);
19
+ --accent-tag: rgba(106, 159, 204, 0.15);
20
+ --chart-fill: rgba(106, 159, 204, 0.6);
21
+ --success: #4ade80;
22
+ --success-dim: rgba(74, 222, 128, 0.18);
23
+ --warning: #fbbf24;
24
+ --warning-dim: rgba(251, 191, 36, 0.18);
25
+ --error: #ef4444;
26
+ --error-dim: rgba(239, 68, 68, 0.18);
27
+ --muted-dim: rgba(117, 125, 137, 0.18);
28
+ --mono: 'IBM Plex Mono', monospace;
29
+ --serif: 'Playfair Display', serif;
30
+ --chart-1: #e86f33;
31
+ --chart-2: #4a9eda;
32
+ --chart-3: #3ecf8e;
33
+ --chart-4: #f0b429;
34
+ }
35
+ /* #endregion */
36
+
37
+ /* #region RESET */
38
+ * { box-sizing: border-box; margin: 0; padding: 0; }
39
+ body {
40
+ font-family: var(--mono);
41
+ font-size: 13px;
42
+ background: var(--bg-deep);
43
+ color: var(--text-primary);
44
+ line-height: 1.5;
45
+ height: 100vh;
46
+ display: flex;
47
+ flex-direction: column;
48
+ overflow: hidden;
49
+ -webkit-font-smoothing: antialiased;
50
+ }
51
+ code { font-family: var(--mono); background: var(--bg-hover); padding: 1px 4px; border-radius: 3px; font-size: 11px; }
52
+ a { color: var(--accent-text); }
53
+ /* #endregion */
54
+
55
+ /* #region SCROLLBAR */
56
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
57
+ ::-webkit-scrollbar-track { background: transparent; }
58
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
59
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
60
+ /* #endregion */
61
+
62
+ /* #region TOPBAR */
63
+ .topbar {
64
+ display: flex; align-items: center; gap: 12px;
65
+ padding: 10px 16px;
66
+ background: var(--bg-surface);
67
+ border-bottom: 1px solid var(--border);
68
+ flex-shrink: 0; z-index: 10;
69
+ }
70
+ .topbar-logo { display: flex; align-items: center; gap: 10px; }
71
+ .topbar-logo .logo-mark {
72
+ width: 24px; height: 24px; background: var(--accent);
73
+ border-radius: 6px; display: flex; align-items: center; justify-content: center;
74
+ }
75
+ .topbar-logo .logo-mark svg { width: 14px; height: 14px; color: white; }
76
+ .topbar-logo .logo-text {
77
+ font-family: var(--serif); font-size: 17px; font-weight: 500; letter-spacing: -0.02em;
78
+ }
79
+ .spacer { flex: 1; }
80
+ .topbar-nav {
81
+ display: flex; gap: 2px;
82
+ background: var(--bg-elevated); border: 1px solid var(--border);
83
+ border-radius: 6px; padding: 2px;
84
+ }
85
+ .topbar-nav-btn {
86
+ padding: 4px 12px; border-radius: 4px;
87
+ font-size: 11px; font-weight: 500; cursor: pointer; border: none;
88
+ background: transparent; color: var(--text-tertiary);
89
+ font-family: var(--mono);
90
+ transition: all 0.15s ease;
91
+ text-transform: uppercase; letter-spacing: 0.03em;
92
+ }
93
+ .topbar-nav-btn:hover { color: var(--text-secondary); }
94
+ .topbar-nav-btn.active { background: var(--accent-dim); color: var(--accent-text); }
95
+ .topbar-select {
96
+ background: var(--bg-elevated); border: 1px solid var(--border);
97
+ border-radius: 6px; color: var(--text-secondary);
98
+ font-family: var(--mono); font-size: 11px; padding: 4px 8px;
99
+ outline: none; cursor: pointer;
100
+ }
101
+ .topbar-select:focus { border-color: var(--accent); }
102
+ .topbar-btn {
103
+ display: flex; align-items: center; gap: 5px;
104
+ padding: 6px 10px; border-radius: 6px;
105
+ font-size: 11px; font-weight: 500; cursor: pointer;
106
+ border: 1px solid var(--border); background: var(--bg-elevated);
107
+ color: var(--text-tertiary); font-family: var(--mono);
108
+ transition: all 0.15s ease;
109
+ white-space: nowrap; text-transform: uppercase; letter-spacing: 0.03em;
110
+ }
111
+ .topbar-btn:hover { border-color: var(--accent); color: var(--text-secondary); }
112
+ .topbar-btn svg { color: var(--text-muted); }
113
+ .topbar-btn.loading svg { animation: spin 1s linear infinite; }
114
+ @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
115
+ /* #endregion */
116
+
117
+ /* #region LAYOUT */
118
+ .dashboard { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 20px 24px; }
119
+ .dashboard-content { max-width: 1400px; margin: 0 auto; }
120
+ .view { display: none; }
121
+ .view.active { display: block; }
122
+ /* #endregion */
123
+
124
+ /* #region CARDS */
125
+ .cards-row {
126
+ display: grid;
127
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
128
+ gap: 12px; margin-bottom: 20px;
129
+ }
130
+ .stat-card {
131
+ background: var(--bg-elevated); border: 1px solid var(--border);
132
+ border-radius: 8px; padding: 16px;
133
+ transition: border-color 0.15s ease;
134
+ }
135
+ .stat-card:hover { border-color: var(--accent-dim); }
136
+ .stat-card .card-label {
137
+ font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em;
138
+ color: var(--text-muted); margin-bottom: 6px;
139
+ }
140
+ .stat-card .card-value {
141
+ font-size: 22px; font-weight: 600;
142
+ color: var(--text-primary); font-family: var(--mono);
143
+ }
144
+ .stat-card .card-value.cost { color: var(--accent-text); }
145
+ .stat-card .card-sub { font-size: 10px; color: var(--text-muted); margin-top: 4px; }
146
+ /* #endregion */
147
+
148
+ /* #region CHARTS */
149
+ .charts-row {
150
+ display: grid; grid-template-columns: 3fr 2fr;
151
+ gap: 16px; margin-bottom: 20px;
152
+ }
153
+ .chart-box {
154
+ background: var(--bg-elevated); border: 1px solid var(--border);
155
+ border-radius: 8px; padding: 16px;
156
+ overflow: hidden; min-width: 0;
157
+ }
158
+ .chart-box .chart-title {
159
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em;
160
+ color: var(--text-muted); margin-bottom: 12px;
161
+ }
162
+ .chart-box canvas { max-width: 100%; max-height: 180px; }
163
+ /* #endregion */
164
+
165
+ /* #region TABLE */
166
+ .section-title {
167
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em;
168
+ color: var(--text-muted); margin-bottom: 12px;
169
+ }
170
+ .data-table { width: 100%; border-collapse: collapse; font-size: 12px; }
171
+ .data-table th {
172
+ text-align: left; padding: 8px 12px;
173
+ font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em;
174
+ color: var(--text-muted); border-bottom: 1px solid var(--border);
175
+ cursor: pointer; user-select: none; white-space: nowrap;
176
+ }
177
+ .data-table th:hover { color: var(--accent-text); }
178
+ .data-table th.sorted { color: var(--accent-text); }
179
+ .data-table th .sort-arrow { margin-left: 4px; font-size: 9px; }
180
+ .data-table td {
181
+ padding: 10px 12px; border-bottom: 1px solid var(--border);
182
+ color: var(--text-secondary); vertical-align: middle;
183
+ }
184
+ .data-table tbody tr:hover { background: var(--bg-hover); cursor: pointer; }
185
+ .data-table tbody tr.kb-selected {
186
+ background: var(--accent-dim);
187
+ outline: 1px solid var(--accent); outline-offset: -1px;
188
+ }
189
+ .data-table .cost-cell { color: var(--accent-text); font-weight: 500; }
190
+ .data-table .muted { color: var(--text-muted); }
191
+ .data-table .truncate { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
192
+ .model-badge {
193
+ display: inline-block; padding: 2px 6px;
194
+ border-radius: 4px; font-size: 10px;
195
+ background: var(--bg-hover); color: var(--text-tertiary);
196
+ border: 1px solid var(--border);
197
+ }
198
+ .provider-badge {
199
+ display: inline-block; padding: 1px 6px; margin-right: 4px;
200
+ border-radius: 3px; font-size: 9px;
201
+ background: var(--accent-dim); color: var(--accent-text);
202
+ text-transform: uppercase; letter-spacing: 0.04em;
203
+ }
204
+ /* #endregion */
205
+
206
+ /* #region COST_SOURCE */
207
+ .src-badge {
208
+ display: inline-block; padding: 1px 5px;
209
+ border-radius: 3px; font-size: 9px;
210
+ text-transform: uppercase; letter-spacing: 0.03em;
211
+ margin-left: 4px; vertical-align: middle;
212
+ }
213
+ .src-jsonl { background: var(--success-dim); color: var(--success); }
214
+ .src-pricing { background: var(--warning-dim); color: var(--warning); }
215
+ .src-none { background: var(--muted-dim); color: var(--text-muted); }
216
+
217
+ .detail-sub {
218
+ font-size: 11px;
219
+ color: var(--text-tertiary);
220
+ margin-top: 4px;
221
+ }
222
+
223
+ .session-id {
224
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
225
+ font-size: 0.92em;
226
+ color: var(--text-primary);
227
+ }
228
+
229
+ .subagent-row { background: var(--accent-faint); }
230
+ .subagent-row.clickable { cursor: pointer; }
231
+ .subagent-row.clickable:hover { background: var(--accent-soft); }
232
+ .subagent-row td { border-left: 2px solid var(--accent); }
233
+ .subagent-row td:not(:first-child) { border-left: none; }
234
+ .subagent-tag {
235
+ display: inline-block; padding: 1px 6px; border-radius: 3px;
236
+ background: var(--accent-tag); color: var(--accent);
237
+ font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em;
238
+ margin-right: 4px;
239
+ }
240
+ /* #endregion */
241
+
242
+ /* #region BREADCRUMB */
243
+ .breadcrumb {
244
+ display: flex; align-items: center; gap: 8px;
245
+ margin-bottom: 16px; font-size: 12px;
246
+ }
247
+ .breadcrumb a { color: var(--accent-text); text-decoration: none; cursor: pointer; }
248
+ .breadcrumb a:hover { text-decoration: underline; }
249
+ .breadcrumb .sep { color: var(--text-muted); }
250
+ .breadcrumb .current { color: var(--text-secondary); }
251
+ /* #endregion */
252
+
253
+ /* #region DETAIL */
254
+ .detail-header { display: flex; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
255
+ .detail-stat { display: flex; flex-direction: column; gap: 2px; }
256
+ .detail-stat .detail-label {
257
+ font-size: 10px; color: var(--text-muted);
258
+ text-transform: uppercase; letter-spacing: 0.03em;
259
+ }
260
+ .detail-stat .detail-value { font-size: 16px; font-weight: 500; color: var(--text-secondary); }
261
+ .detail-stat .detail-value.cost { color: var(--accent-text); }
262
+
263
+ .messages-table { width: 100%; border-collapse: collapse; font-size: 11px; }
264
+ .messages-table th {
265
+ text-align: left; padding: 6px 10px;
266
+ font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em;
267
+ color: var(--text-muted); border-bottom: 1px solid var(--border);
268
+ white-space: nowrap;
269
+ }
270
+ .messages-table td {
271
+ padding: 6px 10px; border-bottom: 1px solid var(--border);
272
+ color: var(--text-secondary);
273
+ }
274
+ .messages-table .cost-cell { color: var(--accent-text); font-weight: 500; }
275
+ .messages-table .cumulative { color: var(--text-muted); font-size: 10px; }
276
+ /* #endregion */
277
+
278
+ /* #region LOADING */
279
+ .loading-state {
280
+ display: flex; align-items: center; justify-content: center;
281
+ gap: 10px; height: 200px;
282
+ color: var(--text-muted); font-size: 12px;
283
+ }
284
+ .loading-spinner {
285
+ width: 16px; height: 16px;
286
+ border: 2px solid var(--border); border-top-color: var(--accent);
287
+ border-radius: 50%;
288
+ animation: spin 0.8s linear infinite;
289
+ }
290
+ .empty-state {
291
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
292
+ gap: 8px; padding: 60px 20px;
293
+ color: var(--text-muted); font-size: 12px; text-align: center;
294
+ }
295
+ .empty-state .empty-icon { font-size: 32px; opacity: 0.5; margin-bottom: 8px; }
296
+ /* #endregion */
297
+
298
+ /* #region MODAL */
299
+ .modal-overlay {
300
+ position: fixed; inset: 0; background: rgba(0,0,0,0.5);
301
+ display: none; align-items: center; justify-content: center; z-index: 10000;
302
+ }
303
+ .modal-overlay.visible { display: flex; }
304
+ .modal-box {
305
+ background: var(--bg-surface); border: 1px solid var(--border);
306
+ border-radius: 12px; width: 90%; max-width: 540px; max-height: 90vh;
307
+ padding: 24px; box-shadow: 0 20px 60px rgba(0,0,0,0.5); overflow-y: auto;
308
+ }
309
+ .modal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
310
+ .modal-title { font-size: 18px; font-weight: 600; color: var(--text-primary); }
311
+ .modal-body { color: var(--text-secondary); line-height: 1.6; }
312
+ .modal-body p { margin: 0 0 12px; font-size: 12px; }
313
+ .modal-close {
314
+ margin-top: 20px; width: 100%; padding: 8px;
315
+ background: var(--bg-elevated); border: 1px solid var(--border);
316
+ border-radius: 6px; color: var(--text-secondary);
317
+ cursor: pointer; font-family: var(--mono); font-size: 12px; text-transform: uppercase;
318
+ }
319
+ .modal-close:hover { background: var(--bg-hover); color: var(--text-primary); }
320
+ .modal-divider { border: none; border-top: 1px solid var(--border); margin: 16px 0; }
321
+ .shortcut-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px; }
322
+ .shortcut-section { margin: 0 0 8px 0; color: var(--text-primary); font-size: 13px; font-weight: 600; }
323
+ .shortcut-table { width: 100%; font-size: 12px; }
324
+ .shortcut-table td { padding: 3px 0; color: var(--text-secondary); }
325
+ .shortcut-table kbd {
326
+ background: var(--bg-hover); padding: 1px 6px;
327
+ border-radius: 4px; font-family: var(--mono); font-size: 11px;
328
+ border: 1px solid var(--border);
329
+ }
330
+ /* #endregion */
331
+
332
+ /* #region TOAST */
333
+ .toast-container { position: fixed; bottom: 16px; right: 16px; z-index: 100; }
334
+ .toast {
335
+ background: var(--bg-elevated); border: 1px solid var(--border);
336
+ border-radius: 6px; padding: 8px 14px;
337
+ font-size: 11px; color: var(--text-secondary);
338
+ animation: slideIn 0.2s ease;
339
+ margin-top: 6px;
340
+ }
341
+ .toast.toast-success { border-color: var(--success); color: var(--success); }
342
+ @keyframes slideIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
343
+ /* #endregion */
344
+
345
+ /* #region LIGHT_THEME */
346
+ /* Shared light-mode tokens — applied via @media (prefers-color-scheme: light)
347
+ when the user has no saved preference, OR via body.light when they explicitly
348
+ choose light. body.dark-forced wins back to dark over the media query. */
349
+ /* pi-light defaults (matches the default light theme so first paint has no flash). */
350
+ body.light {
351
+ --bg-deep: #ebe7e4;
352
+ --bg-surface: #f4f1ee;
353
+ --bg-elevated: #e0dcd8;
354
+ --bg-hover: #d5d1cd;
355
+ --border: #b5b0aa;
356
+ --text-primary: #0d1116;
357
+ --text-secondary: #212730;
358
+ --text-tertiary: #495059;
359
+ --text-muted: #757d89;
360
+ --accent: #2f5f8a;
361
+ --accent-text: #1d4870;
362
+ --accent-dim: rgba(47, 95, 138, 0.18);
363
+ --accent-glow: rgba(47, 95, 138, 0.5);
364
+ --accent-faint: rgba(47, 95, 138, 0.06);
365
+ --accent-soft: rgba(47, 95, 138, 0.12);
366
+ --accent-tag: rgba(47, 95, 138, 0.15);
367
+ --chart-fill: rgba(47, 95, 138, 0.5);
368
+ --success: #15803d;
369
+ --success-dim: rgba(21, 128, 61, 0.15);
370
+ --warning: #b45309;
371
+ --warning-dim: rgba(180, 83, 9, 0.15);
372
+ --error: #c53030;
373
+ --error-dim: rgba(197, 48, 48, 0.15);
374
+ --muted-dim: rgba(117, 125, 137, 0.15);
375
+ }
376
+
377
+ @media (prefers-color-scheme: light) {
378
+ body:not(.dark-forced) {
379
+ --bg-deep: #ebe7e4;
380
+ --bg-surface: #f4f1ee;
381
+ --bg-elevated: #e0dcd8;
382
+ --bg-hover: #d5d1cd;
383
+ --border: #b5b0aa;
384
+ --text-primary: #0d1116;
385
+ --text-secondary: #212730;
386
+ --text-tertiary: #495059;
387
+ --text-muted: #757d89;
388
+ --accent: #2f5f8a;
389
+ --accent-text: #1d4870;
390
+ --accent-dim: rgba(47, 95, 138, 0.18);
391
+ --accent-glow: rgba(47, 95, 138, 0.5);
392
+ --accent-faint: rgba(47, 95, 138, 0.06);
393
+ --accent-soft: rgba(47, 95, 138, 0.12);
394
+ --accent-tag: rgba(47, 95, 138, 0.15);
395
+ --chart-fill: rgba(47, 95, 138, 0.5);
396
+ --success: #15803d;
397
+ --success-dim: rgba(21, 128, 61, 0.15);
398
+ --warning: #b45309;
399
+ --warning-dim: rgba(180, 83, 9, 0.15);
400
+ --error: #c53030;
401
+ --error-dim: rgba(197, 48, 48, 0.15);
402
+ --muted-dim: rgba(117, 125, 137, 0.15);
403
+ }
404
+ }
405
+ /* #endregion */
406
+
407
+ /* #region RESPONSIVE */
408
+ @media (max-width: 900px) {
409
+ .charts-row { grid-template-columns: 1fr; }
410
+ .cards-row { grid-template-columns: repeat(2, 1fr); }
411
+ .shortcut-grid { grid-template-columns: 1fr; }
412
+ }
413
+ @media (max-width: 600px) {
414
+ .topbar { flex-wrap: wrap; gap: 8px; padding: 8px 12px; }
415
+ .topbar-nav { order: 10; width: 100%; }
416
+ .topbar-nav-btn { flex: 1; text-align: center; }
417
+ .cards-row { grid-template-columns: 1fr; }
418
+ .dashboard { padding: 12px; }
419
+ }
420
+ /* #endregion */
package/public/sw.js ADDED
@@ -0,0 +1,60 @@
1
+ const CACHE_NAME = 'pi-cost-v3';
2
+ const PRECACHE_URLS = ['/', '/style.css', '/app.js'];
3
+
4
+ let cachePromise = null;
5
+ function getCache() {
6
+ if (!cachePromise) cachePromise = caches.open(CACHE_NAME);
7
+ return cachePromise;
8
+ }
9
+
10
+ function cacheResponse(request, response) {
11
+ if (response.ok) {
12
+ const clone = response.clone();
13
+ getCache().then(cache => cache.put(request, clone));
14
+ }
15
+ return response;
16
+ }
17
+
18
+ self.addEventListener('install', (event) => {
19
+ event.waitUntil(
20
+ getCache()
21
+ .then(cache => cache.addAll(PRECACHE_URLS))
22
+ .then(() => self.skipWaiting())
23
+ );
24
+ });
25
+
26
+ self.addEventListener('activate', (event) => {
27
+ cachePromise = null;
28
+ event.waitUntil(
29
+ caches.keys()
30
+ .then(keys => Promise.all(
31
+ keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))
32
+ ))
33
+ .then(() => self.clients.claim())
34
+ );
35
+ });
36
+
37
+ self.addEventListener('fetch', (event) => {
38
+ const url = new URL(event.request.url);
39
+
40
+ // Never cache API calls
41
+ if (url.pathname.startsWith('/api')) return;
42
+
43
+ // Network-first for same-origin, fallback to cache
44
+ if (url.origin === self.location.origin) {
45
+ event.respondWith(
46
+ fetch(event.request)
47
+ .then(r => cacheResponse(event.request, r))
48
+ .catch(() => caches.match(event.request))
49
+ );
50
+ return;
51
+ }
52
+
53
+ // CDN resources: cache-first
54
+ event.respondWith(
55
+ caches.match(event.request).then(cached => {
56
+ if (cached) return cached;
57
+ return fetch(event.request).then(r => cacheResponse(event.request, r));
58
+ })
59
+ );
60
+ });