codex-work-receipt 0.7.4 → 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,
@@ -13,7 +14,14 @@ import {
13
14
  import { getHtmlStarPrompt } from "../core/open-source.mjs";
14
15
 
15
16
  const require = createRequire(import.meta.url);
16
- 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";
17
25
 
18
26
  function inlineScript(value) {
19
27
  return String(value).replaceAll("</script", "<\\/script");
@@ -54,17 +62,11 @@ function formatDateKey(value, locale) {
54
62
  return locale === "en" ? `${match[2]}/${match[3]}/${match[1]}` : `${match[1]}/${match[2]}/${match[3]}`;
55
63
  }
56
64
 
57
- function formatCopy(template, values) {
58
- return Object.entries(values).reduce(
59
- (result, [key, value]) => result.replaceAll(`{${key}}`, String(value)),
60
- String(template),
61
- );
62
- }
63
-
64
- export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null, miniProgramCodeDataUrl = null }) {
65
+ export function renderHtml({ record, dataQrDataUrl = null, miniProgramCodeDataUrl = null, transferFile = null }) {
65
66
  const locale = record.locale || DEFAULT_LOCALE;
66
67
  const copy = getReceiptCopy(locale);
67
68
  const githubStarPrompt = getHtmlStarPrompt(locale);
69
+ const changelogUrl = `${githubStarPrompt.url}/blob/main/CHANGELOG.md`;
68
70
  const startAt = new Date(record.period.start_at);
69
71
  const endAt = new Date(record.period.end_at);
70
72
  const timezone = record.period.timezone;
@@ -92,13 +94,25 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
92
94
  minimumFractionDigits: 1,
93
95
  maximumFractionDigits: 1,
94
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)}`}`;
95
98
  const exportConfig = JSON.stringify({
96
99
  idleLabel: copy.exportImage,
97
100
  busyLabel: copy.exportingImage,
98
101
  successLabel: copy.exportSuccess,
99
102
  errorLabel: copy.exportError,
100
103
  miniProgramLabel: copy.exportMiniProgramLabel,
101
- 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,
102
116
  }).replaceAll("<", "\\u003c");
103
117
  const rows = [
104
118
  receiptRow(copy.rows.scope, scopeLabel),
@@ -116,61 +130,45 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
116
130
  const miniProgramVisual = miniProgramCodeDataUrl
117
131
  ? `<img src="${miniProgramCodeDataUrl}" alt="${escapeHtml(copy.miniProgramAlt)}">`
118
132
  : `<div class="mini-placeholder" role="img" aria-label="${escapeHtml(copy.placeholderAria)}"><span>${escapeHtml(copy.placeholderLabel)}</span><strong>${escapeHtml(copy.placeholderValue)}</strong></div>`;
119
- const qrUrls = Array.isArray(dataQrDataUrls) && dataQrDataUrls.length
120
- ? dataQrDataUrls
121
- : dataQrDataUrl
122
- ? [dataQrDataUrl]
123
- : [];
124
- const dataQrItems = qrUrls.map((url, index) => `
125
- <div class="qr-item" data-data-qr-index="${index}">
126
- <div class="qr-frame"><img src="${url}" alt="${escapeHtml(copy.dataQrAlt)} ${index + 1}/${qrUrls.length}"></div>
127
- <strong>${escapeHtml(copy.importData)}${qrUrls.length > 1 ? ` ${index + 1}/${qrUrls.length}` : ""}</strong>
128
- <span>${escapeHtml(copy.importDataHint)}</span>
129
- </div>`).join("");
130
- const isMultipart = qrUrls.length > 1;
131
- const multipartSetupSeconds = 10;
132
- const multipartConfig = JSON.stringify({
133
- enabled: isMultipart,
134
- setupSeconds: multipartSetupSeconds,
135
- frameMs: 4000,
136
- blankMs: 240,
137
- setupHint: copy.multipartOpenHint,
138
- partLabel: copy.multipartPartLabel,
139
- }).replaceAll("<", "\\u003c");
140
- const transferVisual = isMultipart
133
+ const fileImportSteps = copy.fileImportSteps
134
+ .map((step) => `<li>${escapeHtml(step)}</li>`)
135
+ .join("");
136
+ const dataQrPanel = dataQrDataUrl
141
137
  ? `
142
- <div class="multipart-live" id="multipart-live">
143
- <div class="multipart-panel multipart-setup" id="multipart-setup">
144
- <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>
145
157
  <div class="qr-frame qr-frame--large">${miniProgramVisual}</div>
146
- <strong>${escapeHtml(copy.multipartOpenTitle)}</strong>
147
- <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>
148
160
  </div>
161
+ ${dataQrPanel}
149
162
  </div>
150
- <div class="multipart-panel multipart-stage" id="multipart-stage" hidden>
151
- <div class="qr-frame qr-frame--large"><img id="multipart-active-qr" alt="${escapeHtml(copy.dataQrAlt)}"></div>
152
- <strong class="multipart-stage__title">${escapeHtml(copy.multipartTransferTitle)}</strong>
153
- <span class="multipart-stage__part" id="multipart-part-label">${escapeHtml(formatCopy(copy.multipartPartLabel, { current: 1, total: qrUrls.length }))}</span>
154
- <span class="multipart-stage__hint">${escapeHtml(copy.multipartTransferHint)}</span>
155
- <button class="multipart-secondary" id="multipart-show-mini" type="button">${escapeHtml(copy.multipartRestart)}</button>
156
- </div>
157
- </div>
158
- <div class="qr-grid qr-grid--export-only" hidden>
159
- <div class="qr-item">
160
- <div class="qr-frame">${miniProgramVisual}</div>
161
- <strong data-export-mini-label>${escapeHtml(copy.openMiniProgram)}</strong>
162
- <span>${escapeHtml(copy.openMiniProgramHint)}</span>
163
- </div>
164
- ${dataQrItems}
165
- </div>`
166
- : `
167
- <div class="qr-grid qr-grid--single">
168
- <div class="qr-item">
169
- <div class="qr-frame">${miniProgramVisual}</div>
170
- <strong data-export-mini-label>${escapeHtml(copy.openMiniProgram)}</strong>
171
- <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}
172
171
  </div>
173
- ${dataQrItems}
174
172
  </div>`;
175
173
 
176
174
  return `<!doctype html>
@@ -228,82 +226,150 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
228
226
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
229
227
  transition: background .2s ease, color .2s ease;
230
228
  }
231
- .page {
232
- width: min(100%, 540px);
229
+ .layout {
230
+ display: grid;
231
+ grid-template-columns: minmax(0, 540px) 180px;
232
+ gap: 240px;
233
+ max-width: 1020px;
233
234
  margin: 0 auto;
234
235
  padding: 30px 18px 54px;
236
+ align-items: start;
235
237
  }
236
- .toolbar {
237
- display: grid;
238
+ .page {
238
239
  width: 100%;
239
- justify-items: center;
240
+ padding: 0;
241
+ }
242
+ .sidebar {
243
+ position: sticky;
244
+ top: 30px;
245
+ display: flex;
246
+ flex-direction: column;
240
247
  gap: 12px;
241
- margin-bottom: 22px;
242
248
  }
243
- .github-star-link {
244
- display: inline-flex;
245
- justify-self: end;
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;
246
258
  align-items: center;
247
- min-height: 32px;
248
- padding: 7px 11px;
249
- border: 1px solid color-mix(in srgb, var(--ink) 38%, transparent);
250
- border-radius: 999px;
251
- background: color-mix(in srgb, var(--paper) 78%, transparent);
252
- color: var(--ink);
259
+ gap: 5px;
260
+ margin: 0 0 6px;
261
+ color: #e0e0e0;
262
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
253
263
  font-size: 11px;
254
264
  font-weight: 700;
255
- line-height: 1.35;
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;
256
283
  text-decoration: none;
257
- transition: background .15s ease, color .15s ease, transform .15s ease;
258
284
  }
259
- .github-star-link:hover,
260
- .github-star-link:focus-visible {
261
- background: var(--ink);
262
- color: var(--paper);
263
- transform: translateY(-1px);
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;
264
321
  }
265
- .github-star-link:active { transform: translateY(0); }
266
322
  .theme-switcher {
267
323
  display: flex;
268
- flex-wrap: wrap;
269
- justify-content: center;
270
- gap: 8px;
324
+ flex-wrap: nowrap;
271
325
  }
272
326
  .theme-button {
273
327
  appearance: none;
274
328
  border: 1px solid color-mix(in srgb, var(--ink) 28%, transparent);
275
- border-radius: 999px;
329
+ border-radius: 0;
276
330
  background: color-mix(in srgb, var(--paper) 74%, transparent);
277
331
  color: var(--ink);
278
332
  cursor: pointer;
279
333
  font: inherit;
280
334
  font-size: 12px;
281
- 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;
282
345
  }
283
346
  .theme-button[aria-pressed="true"] {
347
+ position: relative;
348
+ z-index: 1;
349
+ border-color: var(--ink);
284
350
  background: var(--ink);
285
351
  color: var(--paper);
286
352
  }
287
353
  .export-actions {
288
- display: grid;
289
- justify-items: center;
290
- gap: 6px;
354
+ position: relative;
355
+ display: flex;
356
+ align-items: center;
357
+ gap: 8px;
291
358
  }
292
359
  .export-button {
293
360
  appearance: none;
294
- min-width: 168px;
295
- border: 1px solid var(--ink);
296
- border-radius: 999px;
297
- background: var(--ink);
298
- color: var(--paper);
361
+ border: 1px solid #333;
362
+ border-radius: 6px;
363
+ background: #222;
364
+ color: #fff;
299
365
  cursor: pointer;
300
366
  font: inherit;
301
- font-size: 13px;
302
- font-weight: 700;
303
- padding: 10px 18px;
367
+ font-size: 12px;
368
+ font-weight: 600;
369
+ padding: 7px 14px;
304
370
  transition: opacity .15s ease, transform .15s ease;
305
371
  }
306
- .export-button:hover { transform: translateY(-1px); }
372
+ .export-button:hover { opacity: .85; transform: translateY(-1px); }
307
373
  .export-button:disabled { cursor: wait; opacity: .62; transform: none; }
308
374
  .export-status {
309
375
  min-height: 16px;
@@ -449,13 +515,16 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
449
515
  .transfer-heading { text-align: center; }
450
516
  .transfer-heading h2 { margin: 0; font-size: 18px; letter-spacing: .08em; }
451
517
  .transfer-heading p { margin: 7px 0 0; color: var(--muted); font-size: 11px; }
452
- .qr-grid {
518
+ .transfer-layout {
453
519
  display: grid;
454
- grid-template-columns: 1fr 1fr;
455
- gap: 18px;
520
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr);
521
+ align-items: start;
522
+ gap: 22px;
456
523
  margin-top: 20px;
457
524
  }
525
+ .qr-switcher { min-width: 0; }
458
526
  .qr-item { text-align: center; }
527
+ .qr-panel[hidden] { display: none; }
459
528
  .qr-frame {
460
529
  display: grid;
461
530
  place-items: center;
@@ -469,29 +538,67 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
469
538
  .qr-frame img { display: block; width: 100%; height: 100%; object-fit: contain; }
470
539
  .qr-item strong { display: block; font-size: 12px; }
471
540
  .qr-item span { display: block; margin-top: 4px; color: var(--muted); font-size: 10px; line-height: 1.45; }
472
- .qr-grid--export-only { display: none; }
473
- .multipart-live { margin-top: 20px; }
474
- .multipart-panel {
475
- display: grid;
476
- place-items: center;
477
- min-height: 310px;
541
+ .qr-frame--large { width: min(100%, 210px); }
542
+ .file-import-card {
478
543
  padding: 18px;
479
544
  border: 1px solid var(--line);
480
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);
481
594
  text-align: center;
482
595
  }
483
- .multipart-panel[hidden] { display: none; }
484
- .multipart-setup__item { width: 100%; }
485
- .qr-frame--large { width: min(100%, 250px); }
486
- .multipart-stage__title,
487
- .multipart-stage__part,
488
- .multipart-stage__hint { display: block; }
489
- .multipart-stage__title { margin-top: 4px; font-size: 14px; }
490
- .multipart-stage__part { margin-top: 8px; font-size: 12px; font-weight: 800; letter-spacing: .06em; }
491
- .multipart-stage__hint { max-width: 360px; margin-top: 6px; color: var(--muted); font-size: 10px; line-height: 1.5; }
492
- #multipart-active-qr[aria-busy="true"] { visibility: hidden; }
493
- .multipart-secondary {
494
- 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;
495
602
  padding: 7px 12px;
496
603
  border: 1px solid var(--line);
497
604
  border-radius: 999px;
@@ -501,7 +608,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
501
608
  font-size: 10px;
502
609
  cursor: pointer;
503
610
  }
504
- .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); }
505
612
  .mini-placeholder {
506
613
  display: grid;
507
614
  place-content: center;
@@ -539,20 +646,43 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
539
646
  line-height: 1.6;
540
647
  text-align: center;
541
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
+ }
542
667
  @media (max-width: 420px) {
543
- .page { padding-inline: 10px; }
544
- .github-star-link { justify-self: center; text-align: center; }
668
+ .layout { padding-inline: 10px; }
669
+ .toolbar {
670
+ flex-direction: column;
671
+ gap: 10px;
672
+ }
545
673
  .receipt { padding-inline: 20px; }
546
674
  .meta { grid-template-columns: 1fr; }
547
675
  .meta div:nth-child(even) { text-align: left; }
548
- .qr-grid { grid-template-columns: 1fr; }
676
+ .transfer-layout { grid-template-columns: 1fr; }
677
+ .sidebar { flex-direction: column; }
678
+ .sidebar-card { flex-basis: auto; }
549
679
  }
550
680
  </style>
551
681
  </head>
552
682
  <body>
683
+ <div class="layout">
553
684
  <main class="page">
554
685
  <div class="toolbar">
555
- <a class="github-star-link" href="${escapeHtml(githubStarPrompt.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(githubStarPrompt.label)}</a>
556
686
  <nav class="theme-switcher" aria-label="${escapeHtml(copy.themeAria)}">
557
687
  <button class="theme-button" type="button" data-theme-value="classic">${escapeHtml(copy.themes.classic)}</button>
558
688
  <button class="theme-button" type="button" data-theme-value="diner">${escapeHtml(copy.themes.diner)}</button>
@@ -615,10 +745,35 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
615
745
 
616
746
  <p class="privacy">${escapeHtml(copy.privacy)}</p>
617
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>
618
773
  <script>${inlineScript(DOM_TO_IMAGE_SOURCE)}</script>
619
774
  <script>
620
775
  const exportConfig = ${exportConfig};
621
- const multipartConfig = ${multipartConfig};
776
+ const transferConfig = ${transferConfig};
622
777
  const themes = new Set(["classic", "diner", "payroll"]);
623
778
  const buttons = [...document.querySelectorAll("[data-theme-value]")];
624
779
  function applyTheme(theme) {
@@ -632,86 +787,44 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
632
787
  try { savedTheme = localStorage.getItem("codex-work-receipt-theme"); } catch {}
633
788
  applyTheme(savedTheme || document.documentElement.dataset.theme);
634
789
 
635
- const multipartLive = document.getElementById("multipart-live");
636
- if (multipartConfig.enabled && multipartLive) {
637
- const setup = document.getElementById("multipart-setup");
638
- const setupHint = document.getElementById("multipart-setup-hint");
639
- const stage = document.getElementById("multipart-stage");
640
- const activeQr = document.getElementById("multipart-active-qr");
641
- const partLabel = document.getElementById("multipart-part-label");
642
- const showMiniButton = document.getElementById("multipart-show-mini");
643
- const urls = [...document.querySelectorAll(".qr-grid--export-only [data-data-qr-index] img")]
644
- .map((image) => image.src)
645
- .filter(Boolean);
646
- let setupTimer = null;
647
- let rotationTimer = null;
648
- let switchTimer = null;
649
- let activeIndex = 0;
650
-
651
- function stopMultipartTimers() {
652
- if (setupTimer !== null) clearInterval(setupTimer);
653
- if (rotationTimer !== null) clearInterval(rotationTimer);
654
- if (switchTimer !== null) clearTimeout(switchTimer);
655
- setupTimer = null;
656
- rotationTimer = null;
657
- switchTimer = null;
658
- }
659
-
660
- function setupHintText(seconds) {
661
- return String(multipartConfig.setupHint).replaceAll("{seconds}", String(seconds));
662
- }
663
-
664
- function partLabelText(index) {
665
- return String(multipartConfig.partLabel)
666
- .replaceAll("{current}", String(index + 1))
667
- .replaceAll("{total}", String(urls.length));
668
- }
669
-
670
- function showPart(index, immediate = false) {
671
- if (!urls.length) return;
672
- activeIndex = (index + urls.length) % urls.length;
673
- const applyPart = () => {
674
- activeQr.src = urls[activeIndex];
675
- activeQr.alt = partLabelText(activeIndex);
676
- activeQr.setAttribute("aria-busy", "false");
677
- partLabel.textContent = partLabelText(activeIndex);
678
- switchTimer = null;
679
- };
680
- if (immediate) {
681
- applyPart();
682
- 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";
683
811
  }
684
- activeQr.setAttribute("aria-busy", "true");
685
- switchTimer = setTimeout(applyPart, multipartConfig.blankMs);
686
- }
687
-
688
- function startMultipartTransfer() {
689
- stopMultipartTimers();
690
- setup.hidden = true;
691
- stage.hidden = false;
692
- showPart(0, true);
693
- rotationTimer = setInterval(() => showPart(activeIndex + 1), multipartConfig.frameMs);
694
- }
695
-
696
- function showMultipartSetup() {
697
- stopMultipartTimers();
698
- stage.hidden = true;
699
- setup.hidden = false;
700
- let remaining = multipartConfig.setupSeconds;
701
- setupHint.textContent = setupHintText(remaining);
702
- setupTimer = setInterval(() => {
703
- remaining -= 1;
704
- if (remaining <= 0) {
705
- startMultipartTransfer();
706
- return;
707
- }
708
- setupHint.textContent = setupHintText(remaining);
709
- }, 1000);
710
- }
812
+ });
813
+ }
711
814
 
712
- showMiniButton.addEventListener("click", showMultipartSetup);
713
- window.addEventListener("beforeunload", stopMultipartTimers);
714
- 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
+ });
715
828
  }
716
829
 
717
830
  const exportButton = document.getElementById("save-receipt-image");
@@ -734,33 +847,42 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
734
847
  }
735
848
 
736
849
  function sanitizeExportNode(node) {
737
- const multipartLiveNode = node.querySelector(".multipart-live");
738
- if (multipartLiveNode) multipartLiveNode.remove();
739
-
740
- const exportGridNode = node.querySelector(".qr-grid--export-only");
741
- if (exportGridNode) {
742
- exportGridNode.removeAttribute("hidden");
743
- 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";
744
860
  }
745
861
 
746
- node.querySelectorAll("[data-data-qr-index]").forEach((item) => item.remove());
747
- node.querySelectorAll(".qr-grid").forEach((grid) => {
748
- grid.style.gridTemplateColumns = "minmax(0, 1fr)";
749
- grid.style.justifyItems = "center";
750
- });
751
-
752
862
  const miniProgramLabel = node.querySelector("[data-export-mini-label]");
753
863
  if (miniProgramLabel) miniProgramLabel.textContent = exportConfig.miniProgramLabel;
754
864
  const transferDescription = node.querySelector(".transfer-heading p");
755
865
  if (transferDescription) transferDescription.remove();
756
- const transferNote = node.querySelector(".transfer-note");
757
- if (transferNote) transferNote.remove();
866
+ node.querySelectorAll(".transfer-note").forEach((note) => note.remove());
758
867
 
759
868
  node.querySelectorAll(".paper").forEach((paper) => {
760
869
  paper.style.boxShadow = "none";
761
870
  });
762
871
  }
763
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
+
764
886
  function createExportNode(source, paperColor) {
765
887
  const clone = source.cloneNode(true);
766
888
  sanitizeExportNode(clone);
@@ -813,6 +935,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
813
935
  style: { background: paperColor },
814
936
  onclone(clone) {
815
937
  sanitizeExportNode(clone);
938
+ normalizeExportTextLayout(clone);
816
939
  clone.style.position = "static";
817
940
  clone.style.left = "auto";
818
941
  clone.style.top = "auto";