codex-work-receipt 0.7.3 → 0.8.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.
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
 
4
4
  import { buildCode128B } from "../core/barcode.mjs";
5
+ import { createReceiptFile } from "../core/file-payload.mjs";
5
6
  import { formatDate, formatDuration, formatNumber, formatTime } from "../lib/time.mjs";
6
7
  import {
7
8
  buildCompensation,
@@ -10,9 +11,17 @@ import {
10
11
  getRollingSummaryNotice,
11
12
  getScopeLabel,
12
13
  } from "../core/presentation.mjs";
14
+ import { getHtmlStarPrompt } from "../core/open-source.mjs";
13
15
 
14
16
  const require = createRequire(import.meta.url);
15
- const DOM_TO_IMAGE_SOURCE = fs.readFileSync(require.resolve("dom-to-image-more"), "utf8");
17
+ const DOM_TO_IMAGE_SOURCE = fs.readFileSync(
18
+ process.env.CODEX_WORK_RECEIPT_DOM_TO_IMAGE || require.resolve("dom-to-image-more"),
19
+ "utf8",
20
+ );
21
+ const MODELFLARE_LOGO_DATA_URL = `data:image/png;base64,${fs.readFileSync(
22
+ new URL("../../docs/images/sponsors/modelflare-logo.png", import.meta.url),
23
+ ).toString("base64")}`;
24
+ const MODELFLARE_URL = "https://modelflare.dev/sign-up?partner=OB9YXNSEEGOL";
16
25
 
17
26
  function inlineScript(value) {
18
27
  return String(value).replaceAll("</script", "<\\/script");
@@ -53,16 +62,11 @@ function formatDateKey(value, locale) {
53
62
  return locale === "en" ? `${match[2]}/${match[3]}/${match[1]}` : `${match[1]}/${match[2]}/${match[3]}`;
54
63
  }
55
64
 
56
- function formatCopy(template, values) {
57
- return Object.entries(values).reduce(
58
- (result, [key, value]) => result.replaceAll(`{${key}}`, String(value)),
59
- String(template),
60
- );
61
- }
62
-
63
- export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null, miniProgramCodeDataUrl = null }) {
65
+ export function renderHtml({ record, dataQrDataUrl = null, miniProgramCodeDataUrl = null, transferFile = null }) {
64
66
  const locale = record.locale || DEFAULT_LOCALE;
65
67
  const copy = getReceiptCopy(locale);
68
+ const githubStarPrompt = getHtmlStarPrompt(locale);
69
+ const changelogUrl = `${githubStarPrompt.url}/blob/main/CHANGELOG.md`;
66
70
  const startAt = new Date(record.period.start_at);
67
71
  const endAt = new Date(record.period.end_at);
68
72
  const timezone = record.period.timezone;
@@ -90,13 +94,25 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
90
94
  minimumFractionDigits: 1,
91
95
  maximumFractionDigits: 1,
92
96
  }).format(record.stats.average_first_token_ms / 1000);
97
+ const fileBase = `codex-work-receipt-${record.source.scope}-${spansMultipleDates ? `${rangeStartDate}-to-${rangeEndDate}` : `${rangeEndDate}-${record.id.slice(4, 12)}`}`;
93
98
  const exportConfig = JSON.stringify({
94
99
  idleLabel: copy.exportImage,
95
100
  busyLabel: copy.exportingImage,
96
101
  successLabel: copy.exportSuccess,
97
102
  errorLabel: copy.exportError,
98
103
  miniProgramLabel: copy.exportMiniProgramLabel,
99
- fileBase: `codex-work-receipt-${record.source.scope}-${spansMultipleDates ? `${rangeStartDate}-to-${rangeEndDate}` : `${rangeEndDate}-${record.id.slice(4, 12)}`}`,
104
+ fileBase,
105
+ }).replaceAll("<", "\\u003c");
106
+ const resolvedTransferFile = transferFile || {
107
+ ...createReceiptFile(record),
108
+ filename: `${fileBase}.cwr.json`,
109
+ };
110
+ const transferConfig = JSON.stringify({
111
+ filename: resolvedTransferFile.filename || `${fileBase}.cwr.json`,
112
+ content: resolvedTransferFile.content,
113
+ mimeType: resolvedTransferFile.mimeType,
114
+ successLabel: copy.downloadSuccess,
115
+ errorLabel: copy.downloadError,
100
116
  }).replaceAll("<", "\\u003c");
101
117
  const rows = [
102
118
  receiptRow(copy.rows.scope, scopeLabel),
@@ -114,61 +130,45 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
114
130
  const miniProgramVisual = miniProgramCodeDataUrl
115
131
  ? `<img src="${miniProgramCodeDataUrl}" alt="${escapeHtml(copy.miniProgramAlt)}">`
116
132
  : `<div class="mini-placeholder" role="img" aria-label="${escapeHtml(copy.placeholderAria)}"><span>${escapeHtml(copy.placeholderLabel)}</span><strong>${escapeHtml(copy.placeholderValue)}</strong></div>`;
117
- const qrUrls = Array.isArray(dataQrDataUrls) && dataQrDataUrls.length
118
- ? dataQrDataUrls
119
- : dataQrDataUrl
120
- ? [dataQrDataUrl]
121
- : [];
122
- const dataQrItems = qrUrls.map((url, index) => `
123
- <div class="qr-item" data-data-qr-index="${index}">
124
- <div class="qr-frame"><img src="${url}" alt="${escapeHtml(copy.dataQrAlt)} ${index + 1}/${qrUrls.length}"></div>
125
- <strong>${escapeHtml(copy.importData)}${qrUrls.length > 1 ? ` ${index + 1}/${qrUrls.length}` : ""}</strong>
126
- <span>${escapeHtml(copy.importDataHint)}</span>
127
- </div>`).join("");
128
- const isMultipart = qrUrls.length > 1;
129
- const multipartSetupSeconds = 10;
130
- const multipartConfig = JSON.stringify({
131
- enabled: isMultipart,
132
- setupSeconds: multipartSetupSeconds,
133
- frameMs: 4000,
134
- blankMs: 240,
135
- setupHint: copy.multipartOpenHint,
136
- partLabel: copy.multipartPartLabel,
137
- }).replaceAll("<", "\\u003c");
138
- const transferVisual = isMultipart
133
+ const fileImportSteps = copy.fileImportSteps
134
+ .map((step) => `<li>${escapeHtml(step)}</li>`)
135
+ .join("");
136
+ const dataQrPanel = dataQrDataUrl
139
137
  ? `
140
- <div class="multipart-live" id="multipart-live">
141
- <div class="multipart-panel multipart-setup" id="multipart-setup">
142
- <div class="qr-item multipart-setup__item">
138
+ <div class="qr-item qr-panel" data-data-qr-panel hidden>
139
+ <div class="qr-frame qr-frame--large"><img src="${dataQrDataUrl}" alt="${escapeHtml(copy.dataQrAlt)}"></div>
140
+ <strong>${escapeHtml(copy.dataQrTitle)}</strong>
141
+ <span>${escapeHtml(copy.dataQrHint)}</span>
142
+ <button class="secondary-button" data-show-mini-program type="button">${escapeHtml(copy.showMiniProgramCode)}</button>
143
+ </div>`
144
+ : "";
145
+ const scanAlternative = dataQrDataUrl
146
+ ? `
147
+ <div class="scan-alternative">
148
+ <strong>${escapeHtml(copy.scanAlternativeTitle)}</strong>
149
+ <span>${escapeHtml(copy.scanAlternativeHint)}</span>
150
+ <button class="secondary-button" data-show-data-qr type="button">${escapeHtml(copy.showDataQr)}</button>
151
+ </div>`
152
+ : "";
153
+ const transferVisual = `
154
+ <div class="transfer-layout">
155
+ <div class="qr-switcher">
156
+ <div class="qr-item qr-panel" data-mini-program-panel>
143
157
  <div class="qr-frame qr-frame--large">${miniProgramVisual}</div>
144
- <strong>${escapeHtml(copy.multipartOpenTitle)}</strong>
145
- <span id="multipart-setup-hint">${escapeHtml(formatCopy(copy.multipartOpenHint, { seconds: multipartSetupSeconds }))}</span>
158
+ <strong data-export-mini-label>${escapeHtml(copy.openMiniProgram)}</strong>
159
+ <span>${escapeHtml(copy.openMiniProgramHint)}</span>
146
160
  </div>
161
+ ${dataQrPanel}
147
162
  </div>
148
- <div class="multipart-panel multipart-stage" id="multipart-stage" hidden>
149
- <div class="qr-frame qr-frame--large"><img id="multipart-active-qr" alt="${escapeHtml(copy.dataQrAlt)}"></div>
150
- <strong class="multipart-stage__title">${escapeHtml(copy.multipartTransferTitle)}</strong>
151
- <span class="multipart-stage__part" id="multipart-part-label">${escapeHtml(formatCopy(copy.multipartPartLabel, { current: 1, total: qrUrls.length }))}</span>
152
- <span class="multipart-stage__hint">${escapeHtml(copy.multipartTransferHint)}</span>
153
- <button class="multipart-secondary" id="multipart-show-mini" type="button">${escapeHtml(copy.multipartRestart)}</button>
154
- </div>
155
- </div>
156
- <div class="qr-grid qr-grid--export-only" hidden>
157
- <div class="qr-item">
158
- <div class="qr-frame">${miniProgramVisual}</div>
159
- <strong data-export-mini-label>${escapeHtml(copy.openMiniProgram)}</strong>
160
- <span>${escapeHtml(copy.openMiniProgramHint)}</span>
161
- </div>
162
- ${dataQrItems}
163
- </div>`
164
- : `
165
- <div class="qr-grid qr-grid--single">
166
- <div class="qr-item">
167
- <div class="qr-frame">${miniProgramVisual}</div>
168
- <strong data-export-mini-label>${escapeHtml(copy.openMiniProgram)}</strong>
169
- <span>${escapeHtml(copy.openMiniProgramHint)}</span>
163
+ <div class="file-import-card" data-file-import-controls>
164
+ <strong>${escapeHtml(copy.fileImportTitle)}</strong>
165
+ <ol>${fileImportSteps}</ol>
166
+ <button class="download-file-button" id="download-import-file" type="button">${escapeHtml(copy.downloadFile)}</button>
167
+ <span class="download-file-hint">${escapeHtml(copy.downloadFileHint)}</span>
168
+ <span class="download-status" id="download-status" role="status" aria-live="polite"></span>
169
+ <p>${escapeHtml(copy.fileImportPrivacy)}</p>
170
+ ${scanAlternative}
170
171
  </div>
171
- ${dataQrItems}
172
172
  </div>`;
173
173
 
174
174
  return `<!doctype html>
@@ -226,58 +226,150 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
226
226
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
227
227
  transition: background .2s ease, color .2s ease;
228
228
  }
229
- .page {
230
- width: min(100%, 540px);
229
+ .layout {
230
+ display: grid;
231
+ grid-template-columns: minmax(0, 540px) 180px;
232
+ gap: 240px;
233
+ max-width: 1020px;
231
234
  margin: 0 auto;
232
235
  padding: 30px 18px 54px;
236
+ align-items: start;
233
237
  }
234
- .toolbar {
235
- display: grid;
236
- justify-items: center;
238
+ .page {
239
+ width: 100%;
240
+ padding: 0;
241
+ }
242
+ .sidebar {
243
+ position: sticky;
244
+ top: 30px;
245
+ display: flex;
246
+ flex-direction: column;
237
247
  gap: 12px;
238
- margin-bottom: 22px;
248
+ }
249
+ .sidebar-card {
250
+ padding: 12px;
251
+ border: 1px solid #333;
252
+ border-radius: 8px;
253
+ background: #1a1a1a;
254
+ color: #ccc;
255
+ }
256
+ .sidebar-card__title {
257
+ display: flex;
258
+ align-items: center;
259
+ gap: 5px;
260
+ margin: 0 0 6px;
261
+ color: #e0e0e0;
262
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
263
+ font-size: 11px;
264
+ font-weight: 700;
265
+ letter-spacing: .03em;
266
+ }
267
+ .sidebar-card__title svg {
268
+ width: 12px;
269
+ height: 12px;
270
+ flex-shrink: 0;
271
+ color: #e0e0e0;
272
+ }
273
+ .sidebar-card p {
274
+ margin: 0 0 8px;
275
+ color: #999;
276
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
277
+ font-size: 11px;
278
+ line-height: 1.5;
279
+ }
280
+ .sidebar-card a {
281
+ color: #fff;
282
+ font-size: 11px;
283
+ text-decoration: none;
284
+ }
285
+ .sidebar-card a:hover,
286
+ .sidebar-card a:focus-visible {
287
+ color: #fff;
288
+ text-decoration: underline;
289
+ }
290
+ .sidebar-sponsor { text-align: center; }
291
+ .sidebar-sponsor__label {
292
+ display: block;
293
+ margin-bottom: 8px;
294
+ color: #777;
295
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
296
+ font-size: 9px;
297
+ letter-spacing: .08em;
298
+ text-transform: uppercase;
299
+ }
300
+ .sidebar-sponsor__link { display: block; }
301
+ .sidebar-sponsor img {
302
+ display: block;
303
+ width: 40px;
304
+ height: 40px;
305
+ margin: 0 auto 6px;
306
+ border-radius: 4px;
307
+ }
308
+ .sidebar-sponsor__name {
309
+ color: #ccc;
310
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
311
+ font-size: 11px;
312
+ font-weight: 600;
313
+ line-height: 1.4;
314
+ }
315
+ .toolbar {
316
+ display: flex;
317
+ width: 100%;
318
+ justify-content: space-between;
319
+ align-items: center;
320
+ margin-bottom: 20px;
239
321
  }
240
322
  .theme-switcher {
241
323
  display: flex;
242
- flex-wrap: wrap;
243
- justify-content: center;
244
- gap: 8px;
324
+ flex-wrap: nowrap;
245
325
  }
246
326
  .theme-button {
247
327
  appearance: none;
248
328
  border: 1px solid color-mix(in srgb, var(--ink) 28%, transparent);
249
- border-radius: 999px;
329
+ border-radius: 0;
250
330
  background: color-mix(in srgb, var(--paper) 74%, transparent);
251
331
  color: var(--ink);
252
332
  cursor: pointer;
253
333
  font: inherit;
254
334
  font-size: 12px;
255
- padding: 8px 12px;
335
+ padding: 7px 12px;
336
+ margin-left: -1px;
337
+ transition: background .15s ease, color .15s ease;
338
+ }
339
+ .theme-button:first-child {
340
+ margin-left: 0;
341
+ border-radius: 6px 0 0 6px;
342
+ }
343
+ .theme-button:last-child {
344
+ border-radius: 0 6px 6px 0;
256
345
  }
257
346
  .theme-button[aria-pressed="true"] {
347
+ position: relative;
348
+ z-index: 1;
349
+ border-color: var(--ink);
258
350
  background: var(--ink);
259
351
  color: var(--paper);
260
352
  }
261
353
  .export-actions {
262
- display: grid;
263
- justify-items: center;
264
- gap: 6px;
354
+ position: relative;
355
+ display: flex;
356
+ align-items: center;
357
+ gap: 8px;
265
358
  }
266
359
  .export-button {
267
360
  appearance: none;
268
- min-width: 168px;
269
- border: 1px solid var(--ink);
270
- border-radius: 999px;
271
- background: var(--ink);
272
- color: var(--paper);
361
+ border: 1px solid #333;
362
+ border-radius: 6px;
363
+ background: #222;
364
+ color: #fff;
273
365
  cursor: pointer;
274
366
  font: inherit;
275
- font-size: 13px;
276
- font-weight: 700;
277
- padding: 10px 18px;
367
+ font-size: 12px;
368
+ font-weight: 600;
369
+ padding: 7px 14px;
278
370
  transition: opacity .15s ease, transform .15s ease;
279
371
  }
280
- .export-button:hover { transform: translateY(-1px); }
372
+ .export-button:hover { opacity: .85; transform: translateY(-1px); }
281
373
  .export-button:disabled { cursor: wait; opacity: .62; transform: none; }
282
374
  .export-status {
283
375
  min-height: 16px;
@@ -423,13 +515,16 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
423
515
  .transfer-heading { text-align: center; }
424
516
  .transfer-heading h2 { margin: 0; font-size: 18px; letter-spacing: .08em; }
425
517
  .transfer-heading p { margin: 7px 0 0; color: var(--muted); font-size: 11px; }
426
- .qr-grid {
518
+ .transfer-layout {
427
519
  display: grid;
428
- grid-template-columns: 1fr 1fr;
429
- gap: 18px;
520
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr);
521
+ align-items: start;
522
+ gap: 22px;
430
523
  margin-top: 20px;
431
524
  }
525
+ .qr-switcher { min-width: 0; }
432
526
  .qr-item { text-align: center; }
527
+ .qr-panel[hidden] { display: none; }
433
528
  .qr-frame {
434
529
  display: grid;
435
530
  place-items: center;
@@ -443,29 +538,67 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
443
538
  .qr-frame img { display: block; width: 100%; height: 100%; object-fit: contain; }
444
539
  .qr-item strong { display: block; font-size: 12px; }
445
540
  .qr-item span { display: block; margin-top: 4px; color: var(--muted); font-size: 10px; line-height: 1.45; }
446
- .qr-grid--export-only { display: none; }
447
- .multipart-live { margin-top: 20px; }
448
- .multipart-panel {
449
- display: grid;
450
- place-items: center;
451
- min-height: 310px;
541
+ .qr-frame--large { width: min(100%, 210px); }
542
+ .file-import-card {
452
543
  padding: 18px;
453
544
  border: 1px solid var(--line);
454
545
  background: color-mix(in srgb, var(--paper) 94%, var(--ink));
546
+ }
547
+ .file-import-card > strong { display: block; font-size: 14px; }
548
+ .file-import-card ol {
549
+ margin: 12px 0 16px;
550
+ padding-left: 20px;
551
+ color: var(--muted);
552
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
553
+ font-size: 11px;
554
+ line-height: 1.65;
555
+ }
556
+ .download-file-button {
557
+ width: 100%;
558
+ padding: 10px 14px;
559
+ border: 1px solid var(--ink);
560
+ border-radius: 999px;
561
+ background: var(--ink);
562
+ color: var(--paper);
563
+ cursor: pointer;
564
+ font: inherit;
565
+ font-size: 12px;
566
+ font-weight: 800;
567
+ }
568
+ .download-file-button:hover { transform: translateY(-1px); }
569
+ .download-file-hint,
570
+ .download-status {
571
+ display: block;
572
+ margin-top: 6px;
573
+ color: var(--muted);
574
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
575
+ font-size: 10px;
576
+ line-height: 1.45;
577
+ text-align: center;
578
+ }
579
+ .download-status { min-height: 14px; }
580
+ .download-status[data-state="success"] { color: #287a36; }
581
+ .download-status[data-state="error"] { color: #b33a2e; }
582
+ .file-import-card > p {
583
+ margin: 14px 0 0;
584
+ color: var(--muted);
585
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
586
+ font-size: 10px;
587
+ line-height: 1.55;
588
+ text-align: center;
589
+ }
590
+ .scan-alternative {
591
+ margin-top: 16px;
592
+ padding-top: 14px;
593
+ border-top: 1px dashed var(--line);
455
594
  text-align: center;
456
595
  }
457
- .multipart-panel[hidden] { display: none; }
458
- .multipart-setup__item { width: 100%; }
459
- .qr-frame--large { width: min(100%, 250px); }
460
- .multipart-stage__title,
461
- .multipart-stage__part,
462
- .multipart-stage__hint { display: block; }
463
- .multipart-stage__title { margin-top: 4px; font-size: 14px; }
464
- .multipart-stage__part { margin-top: 8px; font-size: 12px; font-weight: 800; letter-spacing: .06em; }
465
- .multipart-stage__hint { max-width: 360px; margin-top: 6px; color: var(--muted); font-size: 10px; line-height: 1.5; }
466
- #multipart-active-qr[aria-busy="true"] { visibility: hidden; }
467
- .multipart-secondary {
468
- margin-top: 14px;
596
+ .scan-alternative strong,
597
+ .scan-alternative span { display: block; }
598
+ .scan-alternative strong { font-size: 12px; }
599
+ .scan-alternative span { margin-top: 4px; color: var(--muted); font-size: 10px; line-height: 1.45; }
600
+ .secondary-button {
601
+ margin-top: 10px;
469
602
  padding: 7px 12px;
470
603
  border: 1px solid var(--line);
471
604
  border-radius: 999px;
@@ -475,7 +608,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
475
608
  font-size: 10px;
476
609
  cursor: pointer;
477
610
  }
478
- .multipart-secondary:hover { background: color-mix(in srgb, var(--ink) 7%, transparent); }
611
+ .secondary-button:hover { background: color-mix(in srgb, var(--ink) 7%, transparent); }
479
612
  .mini-placeholder {
480
613
  display: grid;
481
614
  place-content: center;
@@ -513,16 +646,41 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
513
646
  line-height: 1.6;
514
647
  text-align: center;
515
648
  }
649
+ @media (max-width: 1020px) {
650
+ .layout {
651
+ grid-template-columns: 1fr;
652
+ max-width: 540px;
653
+ gap: 0;
654
+ }
655
+ .sidebar {
656
+ position: static;
657
+ flex-direction: row;
658
+ flex-wrap: wrap;
659
+ gap: 10px;
660
+ margin-top: 28px;
661
+ }
662
+ .sidebar-card {
663
+ flex: 1 1 160px;
664
+ min-width: 0;
665
+ }
666
+ }
516
667
  @media (max-width: 420px) {
517
- .page { padding-inline: 10px; }
668
+ .layout { padding-inline: 10px; }
669
+ .toolbar {
670
+ flex-direction: column;
671
+ gap: 10px;
672
+ }
518
673
  .receipt { padding-inline: 20px; }
519
674
  .meta { grid-template-columns: 1fr; }
520
675
  .meta div:nth-child(even) { text-align: left; }
521
- .qr-grid { grid-template-columns: 1fr; }
676
+ .transfer-layout { grid-template-columns: 1fr; }
677
+ .sidebar { flex-direction: column; }
678
+ .sidebar-card { flex-basis: auto; }
522
679
  }
523
680
  </style>
524
681
  </head>
525
682
  <body>
683
+ <div class="layout">
526
684
  <main class="page">
527
685
  <div class="toolbar">
528
686
  <nav class="theme-switcher" aria-label="${escapeHtml(copy.themeAria)}">
@@ -587,10 +745,35 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
587
745
 
588
746
  <p class="privacy">${escapeHtml(copy.privacy)}</p>
589
747
  </main>
748
+ <aside class="sidebar" aria-label="${escapeHtml(copy.sidebar.aria)}">
749
+ <div class="sidebar-card">
750
+ <h3 class="sidebar-card__title">
751
+ <svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.75.75 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z"/></svg>
752
+ ${escapeHtml(copy.sidebar.supportTitle)}
753
+ </h3>
754
+ <p>${escapeHtml(copy.sidebar.supportDescription)}</p>
755
+ <a class="github-star-link" href="${escapeHtml(githubStarPrompt.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(githubStarPrompt.label)}</a>
756
+ </div>
757
+ <div class="sidebar-card">
758
+ <h3 class="sidebar-card__title">
759
+ <svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/></svg>
760
+ ${escapeHtml(copy.sidebar.changelogTitle)}
761
+ </h3>
762
+ <a href="${escapeHtml(changelogUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(copy.sidebar.changelogLink)}</a>
763
+ </div>
764
+ <div class="sidebar-card sidebar-sponsor">
765
+ <span class="sidebar-sponsor__label">${escapeHtml(copy.sidebar.sponsorLabel)}</span>
766
+ <a class="sidebar-sponsor__link" href="${MODELFLARE_URL}" target="_blank" rel="noopener noreferrer">
767
+ <img src="${MODELFLARE_LOGO_DATA_URL}" alt="${escapeHtml(copy.sidebar.sponsorAlt)}" width="40" height="40">
768
+ <span class="sidebar-sponsor__name">ModelFlare<br>modelflare.dev</span>
769
+ </a>
770
+ </div>
771
+ </aside>
772
+ </div>
590
773
  <script>${inlineScript(DOM_TO_IMAGE_SOURCE)}</script>
591
774
  <script>
592
775
  const exportConfig = ${exportConfig};
593
- const multipartConfig = ${multipartConfig};
776
+ const transferConfig = ${transferConfig};
594
777
  const themes = new Set(["classic", "diner", "payroll"]);
595
778
  const buttons = [...document.querySelectorAll("[data-theme-value]")];
596
779
  function applyTheme(theme) {
@@ -604,86 +787,44 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
604
787
  try { savedTheme = localStorage.getItem("codex-work-receipt-theme"); } catch {}
605
788
  applyTheme(savedTheme || document.documentElement.dataset.theme);
606
789
 
607
- const multipartLive = document.getElementById("multipart-live");
608
- if (multipartConfig.enabled && multipartLive) {
609
- const setup = document.getElementById("multipart-setup");
610
- const setupHint = document.getElementById("multipart-setup-hint");
611
- const stage = document.getElementById("multipart-stage");
612
- const activeQr = document.getElementById("multipart-active-qr");
613
- const partLabel = document.getElementById("multipart-part-label");
614
- const showMiniButton = document.getElementById("multipart-show-mini");
615
- const urls = [...document.querySelectorAll(".qr-grid--export-only [data-data-qr-index] img")]
616
- .map((image) => image.src)
617
- .filter(Boolean);
618
- let setupTimer = null;
619
- let rotationTimer = null;
620
- let switchTimer = null;
621
- let activeIndex = 0;
622
-
623
- function stopMultipartTimers() {
624
- if (setupTimer !== null) clearInterval(setupTimer);
625
- if (rotationTimer !== null) clearInterval(rotationTimer);
626
- if (switchTimer !== null) clearTimeout(switchTimer);
627
- setupTimer = null;
628
- rotationTimer = null;
629
- switchTimer = null;
630
- }
631
-
632
- function setupHintText(seconds) {
633
- return String(multipartConfig.setupHint).replaceAll("{seconds}", String(seconds));
634
- }
635
-
636
- function partLabelText(index) {
637
- return String(multipartConfig.partLabel)
638
- .replaceAll("{current}", String(index + 1))
639
- .replaceAll("{total}", String(urls.length));
640
- }
641
-
642
- function showPart(index, immediate = false) {
643
- if (!urls.length) return;
644
- activeIndex = (index + urls.length) % urls.length;
645
- const applyPart = () => {
646
- activeQr.src = urls[activeIndex];
647
- activeQr.alt = partLabelText(activeIndex);
648
- activeQr.setAttribute("aria-busy", "false");
649
- partLabel.textContent = partLabelText(activeIndex);
650
- switchTimer = null;
651
- };
652
- if (immediate) {
653
- applyPart();
654
- return;
790
+ const downloadFileButton = document.getElementById("download-import-file");
791
+ const downloadStatus = document.getElementById("download-status");
792
+ if (downloadFileButton && downloadStatus) {
793
+ downloadFileButton.addEventListener("click", () => {
794
+ try {
795
+ const blob = new Blob([transferConfig.content], { type: transferConfig.mimeType });
796
+ const objectUrl = URL.createObjectURL(blob);
797
+ const link = document.createElement("a");
798
+ link.href = objectUrl;
799
+ link.download = transferConfig.filename;
800
+ link.rel = "noopener";
801
+ document.body.appendChild(link);
802
+ link.click();
803
+ link.remove();
804
+ setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
805
+ downloadStatus.textContent = transferConfig.successLabel;
806
+ downloadStatus.dataset.state = "success";
807
+ } catch (error) {
808
+ console.error(error);
809
+ downloadStatus.textContent = transferConfig.errorLabel;
810
+ downloadStatus.dataset.state = "error";
655
811
  }
656
- activeQr.setAttribute("aria-busy", "true");
657
- switchTimer = setTimeout(applyPart, multipartConfig.blankMs);
658
- }
659
-
660
- function startMultipartTransfer() {
661
- stopMultipartTimers();
662
- setup.hidden = true;
663
- stage.hidden = false;
664
- showPart(0, true);
665
- rotationTimer = setInterval(() => showPart(activeIndex + 1), multipartConfig.frameMs);
666
- }
667
-
668
- function showMultipartSetup() {
669
- stopMultipartTimers();
670
- stage.hidden = true;
671
- setup.hidden = false;
672
- let remaining = multipartConfig.setupSeconds;
673
- setupHint.textContent = setupHintText(remaining);
674
- setupTimer = setInterval(() => {
675
- remaining -= 1;
676
- if (remaining <= 0) {
677
- startMultipartTransfer();
678
- return;
679
- }
680
- setupHint.textContent = setupHintText(remaining);
681
- }, 1000);
682
- }
812
+ });
813
+ }
683
814
 
684
- showMiniButton.addEventListener("click", showMultipartSetup);
685
- window.addEventListener("beforeunload", stopMultipartTimers);
686
- showMultipartSetup();
815
+ const miniProgramPanel = document.querySelector("[data-mini-program-panel]");
816
+ const dataQrPanel = document.querySelector("[data-data-qr-panel]");
817
+ const showDataQrButton = document.querySelector("[data-show-data-qr]");
818
+ const showMiniProgramButton = document.querySelector("[data-show-mini-program]");
819
+ if (miniProgramPanel && dataQrPanel && showDataQrButton && showMiniProgramButton) {
820
+ showDataQrButton.addEventListener("click", () => {
821
+ miniProgramPanel.hidden = true;
822
+ dataQrPanel.hidden = false;
823
+ });
824
+ showMiniProgramButton.addEventListener("click", () => {
825
+ dataQrPanel.hidden = true;
826
+ miniProgramPanel.hidden = false;
827
+ });
687
828
  }
688
829
 
689
830
  const exportButton = document.getElementById("save-receipt-image");
@@ -706,33 +847,42 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
706
847
  }
707
848
 
708
849
  function sanitizeExportNode(node) {
709
- const multipartLiveNode = node.querySelector(".multipart-live");
710
- if (multipartLiveNode) multipartLiveNode.remove();
711
-
712
- const exportGridNode = node.querySelector(".qr-grid--export-only");
713
- if (exportGridNode) {
714
- exportGridNode.removeAttribute("hidden");
715
- exportGridNode.style.display = "grid";
850
+ const fileImportControls = node.querySelector("[data-file-import-controls]");
851
+ if (fileImportControls) fileImportControls.remove();
852
+ const dataQr = node.querySelector("[data-data-qr-panel]");
853
+ if (dataQr) dataQr.remove();
854
+ const miniProgram = node.querySelector("[data-mini-program-panel]");
855
+ if (miniProgram) miniProgram.hidden = false;
856
+ const transferLayout = node.querySelector(".transfer-layout");
857
+ if (transferLayout) {
858
+ transferLayout.style.gridTemplateColumns = "minmax(0, 1fr)";
859
+ transferLayout.style.justifyItems = "center";
716
860
  }
717
861
 
718
- node.querySelectorAll("[data-data-qr-index]").forEach((item) => item.remove());
719
- node.querySelectorAll(".qr-grid").forEach((grid) => {
720
- grid.style.gridTemplateColumns = "minmax(0, 1fr)";
721
- grid.style.justifyItems = "center";
722
- });
723
-
724
862
  const miniProgramLabel = node.querySelector("[data-export-mini-label]");
725
863
  if (miniProgramLabel) miniProgramLabel.textContent = exportConfig.miniProgramLabel;
726
864
  const transferDescription = node.querySelector(".transfer-heading p");
727
865
  if (transferDescription) transferDescription.remove();
728
- const transferNote = node.querySelector(".transfer-note");
729
- if (transferNote) transferNote.remove();
866
+ node.querySelectorAll(".transfer-note").forEach((note) => note.remove());
730
867
 
731
868
  node.querySelectorAll(".paper").forEach((paper) => {
732
869
  paper.style.boxShadow = "none";
733
870
  });
734
871
  }
735
872
 
873
+ function normalizeExportTextLayout(node) {
874
+ const selectors = ".meta > div, .receipt-row > span, .receipt-row > strong, .salary-line > span, .salary-line > strong";
875
+ node.querySelectorAll(selectors).forEach((item) => {
876
+ ["width", "height", "inline-size", "block-size"].forEach((property) => {
877
+ item.style.removeProperty(property);
878
+ });
879
+ });
880
+ node.querySelectorAll(".receipt-row > strong, .salary-line > strong").forEach((value) => {
881
+ value.style.setProperty("white-space", "nowrap");
882
+ value.style.setProperty("flex-shrink", "0");
883
+ });
884
+ }
885
+
736
886
  function createExportNode(source, paperColor) {
737
887
  const clone = source.cloneNode(true);
738
888
  sanitizeExportNode(clone);
@@ -785,6 +935,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
785
935
  style: { background: paperColor },
786
936
  onclone(clone) {
787
937
  sanitizeExportNode(clone);
938
+ normalizeExportTextLayout(clone);
788
939
  clone.style.position = "static";
789
940
  clone.style.left = "auto";
790
941
  clone.style.top = "auto";