@novacraft-engineering/mailbox 0.1.0 → 0.2.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.
Files changed (38) hide show
  1. package/README.md +7 -2
  2. package/app/api/mail/accessors/route.ts +28 -24
  3. package/app/api/mail/emails/[id]/attachments/download/route.ts +3 -1
  4. package/app/api/mail/emails/[id]/attachments/forward/route.ts +3 -1
  5. package/app/api/mail/emails/[id]/route.ts +17 -9
  6. package/app/api/mail/emails/route.ts +35 -4
  7. package/app/api/mail/inbox/counts/route.ts +5 -4
  8. package/app/api/mail/inbox/route.ts +41 -262
  9. package/app/api/mail/maintenance/received/route.ts +64 -0
  10. package/app/api/mail/pixel/[id]/route.ts +8 -2
  11. package/app/api/mail/request-reset/route.ts +29 -25
  12. package/app/api/mail/signature-logo/route.ts +33 -8
  13. package/app/api/mail/threads/route.ts +1 -1
  14. package/app/brand/[file]/placeholder.ts +70 -0
  15. package/app/brand/[file]/route.ts +7 -1
  16. package/app/layout.tsx +10 -2
  17. package/app/mail/MailSelect.tsx +14 -3
  18. package/app/mail/RichEditor.tsx +168 -52
  19. package/app/mail/Ticker.tsx +40 -0
  20. package/app/mail/page.module.css +624 -70
  21. package/app/mail/page.tsx +1059 -244
  22. package/app/mail/search.ts +1 -1
  23. package/app/share/[id]/page.tsx +5 -3
  24. package/app/share/[id]/share.module.css +5 -5
  25. package/lib/email-html.ts +9 -1
  26. package/lib/emails/index.ts +26 -0
  27. package/lib/emails/templates/action.html +47 -0
  28. package/lib/fonts.ts +7 -1
  29. package/lib/mail-provider.ts +50 -5
  30. package/lib/mailbox.ts +303 -34
  31. package/lib/receive.ts +318 -0
  32. package/lib/sent-access.ts +22 -0
  33. package/lib/ses-send.ts +49 -0
  34. package/package.json +2 -2
  35. package/public/brand/README.md +5 -1
  36. package/scripts/sample-files.mjs +208 -28
  37. package/scripts/seed-dev.mjs +69 -26
  38. package/tools/README.md +1 -1
@@ -515,6 +515,18 @@
515
515
  transition: color var(--dur-fast) var(--nc-ease-out), border-color var(--dur-fast) var(--nc-ease-out);
516
516
  }
517
517
  .refreshBtn:hover { color: var(--nc-fg-1); border-color: var(--nc-line-2); }
518
+ .crossFolderHint {
519
+ align-self: flex-start;
520
+ margin: 0 16px 8px;
521
+ padding: 6px 11px;
522
+ border: 1px solid var(--nc-line-1);
523
+ border-radius: 999px;
524
+ background: var(--nc-ink-2);
525
+ color: var(--nc-violet-500);
526
+ font: 500 12px/1 var(--nc-font-sans);
527
+ cursor: pointer;
528
+ }
529
+ .crossFolderHint:hover { border-color: var(--nc-violet-500); }
518
530
  .refreshBtn svg { width: 14px; height: 14px; }
519
531
  .filterChip {
520
532
  margin-left: auto;
@@ -652,12 +664,187 @@
652
664
  cursor: progress;
653
665
  }
654
666
 
667
+ /* Floats over the list rather than in it, so it does not move as rows load. The pane is
668
+ the positioning context; the list itself scrolls underneath. */
669
+ .toTop {
670
+ position: absolute;
671
+ right: 16px;
672
+ bottom: 18px;
673
+ z-index: 4;
674
+ width: 34px;
675
+ height: 34px;
676
+ display: grid;
677
+ place-items: center;
678
+ border: none;
679
+ border-radius: 999px;
680
+ background: var(--nc-violet-500);
681
+ color: #fff;
682
+ cursor: pointer;
683
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.22);
684
+ opacity: 0;
685
+ transform: translateY(8px);
686
+ pointer-events: none;
687
+ transition: opacity 140ms ease, transform 140ms ease;
688
+ }
689
+ .toTop svg {
690
+ width: 18px;
691
+ height: 18px;
692
+ /* The only chevron in the set points down. */
693
+ transform: rotate(180deg);
694
+ }
695
+ .toTopOn {
696
+ opacity: 1;
697
+ transform: translateY(0);
698
+ pointer-events: auto;
699
+ }
700
+ .toTop:hover {
701
+ background: var(--nc-violet-400);
702
+ }
703
+ .toTop:focus-visible {
704
+ outline: 2px solid var(--nc-violet-500);
705
+ outline-offset: 2px;
706
+ }
707
+ @media (prefers-reduced-motion: reduce) {
708
+ .toTop {
709
+ transition: none;
710
+ }
711
+ }
712
+
655
713
  .list {
656
714
  flex: 1;
657
715
  min-height: 0;
658
716
  overflow-y: auto;
659
717
  overscroll-behavior: contain;
660
718
  padding: 0 10px 16px;
719
+ /* Firefox, and Chrome from 121. Where these are honoured the WebKit rules below are
720
+ ignored outright, so the two have to describe the same scrollbar rather than
721
+ combine into one. */
722
+ scrollbar-width: thin;
723
+ scrollbar-color: color-mix(in srgb, var(--nc-violet-500) 55%, transparent) transparent;
724
+ }
725
+ .snoozeWrap {
726
+ position: relative;
727
+ display: inline-flex;
728
+ }
729
+ .snoozeMenu {
730
+ position: absolute;
731
+ top: calc(100% + 6px);
732
+ right: 0;
733
+ z-index: 60;
734
+ min-width: 232px;
735
+ padding: 6px;
736
+ display: flex;
737
+ flex-direction: column;
738
+ gap: 2px;
739
+ background: var(--nc-ink-1);
740
+ border: 1px solid var(--nc-line-2);
741
+ border-radius: var(--radius-card);
742
+ box-shadow: 0 18px 44px -18px rgba(0, 0, 0, .65);
743
+ }
744
+ .snoozeItem {
745
+ display: flex;
746
+ align-items: center;
747
+ justify-content: space-between;
748
+ gap: 16px;
749
+ min-height: 38px;
750
+ padding: 0 10px;
751
+ border: none;
752
+ border-radius: var(--radius-control);
753
+ background: transparent;
754
+ color: var(--nc-fg-1);
755
+ font: 500 13px/1.3 var(--nc-font-sans);
756
+ cursor: pointer;
757
+ text-align: left;
758
+ }
759
+ .snoozeItem:hover { background: var(--nc-ink-3); }
760
+ .snoozeWhen {
761
+ font: 500 12px/1.3 var(--nc-font-mono);
762
+ color: var(--nc-fg-3);
763
+ white-space: nowrap;
764
+ }
765
+
766
+ /* One band, moved to whichever row is being dragged, rather than a wrapper per row: the
767
+ rows are staggered on entry by :nth-child and wrapping them would reset every one of
768
+ those to the first position. */
769
+ .swipeBand {
770
+ position: absolute;
771
+ left: 10px;
772
+ right: 10px;
773
+ display: flex;
774
+ align-items: center;
775
+ padding: 0 20px;
776
+ border-radius: 12px;
777
+ font: 600 13px/1 var(--nc-font-sans);
778
+ color: #fff;
779
+ pointer-events: none;
780
+ }
781
+ .swipeBandRight {
782
+ justify-content: flex-start;
783
+ background: color-mix(in srgb, var(--nc-violet-500) 55%, transparent);
784
+ }
785
+ .swipeBandLeft {
786
+ justify-content: flex-end;
787
+ background: color-mix(in srgb, var(--nc-danger) 55%, transparent);
788
+ }
789
+ .swipeBandArmed.swipeBandRight { background: var(--nc-violet-500); }
790
+ .swipeBandArmed.swipeBandLeft { background: var(--nc-danger); }
791
+
792
+ .list {
793
+ position: relative;
794
+ }
795
+ /* The row rides the finger, so it must not animate back on every frame. */
796
+ .item {
797
+ touch-action: pan-y;
798
+ }
799
+
800
+ /* Sits between the header and the rows, growing out of the gap as the list is pulled
801
+ down, so the gesture has something to answer it. */
802
+ .pullHint {
803
+ display: flex;
804
+ align-items: center;
805
+ justify-content: center;
806
+ overflow: hidden;
807
+ transition: opacity 120ms ease;
808
+ }
809
+ .pullSpinner {
810
+ display: grid;
811
+ place-items: center;
812
+ width: 30px;
813
+ height: 30px;
814
+ border-radius: 999px;
815
+ background: var(--nc-ink-2);
816
+ border: 1px solid var(--nc-line-1);
817
+ color: var(--nc-fg-3);
818
+ }
819
+ .pullSpinner svg {
820
+ width: 16px;
821
+ height: 16px;
822
+ }
823
+ .pullSpinnerReady {
824
+ background: var(--nc-violet-500);
825
+ border-color: var(--nc-violet-500);
826
+ color: #fff;
827
+ }
828
+
829
+ /* Safari, and older Chrome. */
830
+ .list::-webkit-scrollbar {
831
+ width: 10px;
832
+ }
833
+ .list::-webkit-scrollbar-track {
834
+ background: transparent;
835
+ }
836
+ .list::-webkit-scrollbar-thumb {
837
+ /* The transparent border plus padding-box clipping is what insets the thumb: there is
838
+ no padding property on a scrollbar, and a full-width bar against the rows reads as
839
+ part of the list rather than a control over it. */
840
+ background: color-mix(in srgb, var(--nc-violet-500) 45%, transparent);
841
+ background-clip: padding-box;
842
+ border: 3px solid transparent;
843
+ border-radius: 999px;
844
+ }
845
+ .list::-webkit-scrollbar-thumb:hover {
846
+ background: color-mix(in srgb, var(--nc-violet-500) 78%, transparent);
847
+ background-clip: padding-box;
661
848
  }
662
849
  .app[data-density='relaxed'] {
663
850
  --row-pad: 18px 16px;
@@ -776,9 +963,9 @@
776
963
  color: var(--nc-success);
777
964
  border-color: color-mix(in srgb, var(--nc-success) 40%, transparent);
778
965
  }
779
- .chipScheduled { color: var(--nc-warn); border-color: rgba(251,191,36,.3); }
966
+ .chipScheduled { color: var(--nc-warn); border-color: color-mix(in srgb, var(--nc-warn) 30%, transparent); }
780
967
  .chipDelivered { color: var(--nc-success); border-color: rgba(52,211,153,.3); }
781
- .chipBounced { color: var(--nc-danger); border-color: rgba(248,113,113,.3); }
968
+ .chipBounced { color: var(--nc-danger); border-color: color-mix(in srgb, var(--nc-danger) 30%, transparent); }
782
969
  .chipDraft { color: var(--nc-violet-200); border-color: rgba(165,120,255,.3); }
783
970
 
784
971
  .emptyList {
@@ -918,8 +1105,8 @@
918
1105
  }
919
1106
  .actionBtn:hover { color: var(--nc-fg-1); border-color: var(--nc-line-2); background: var(--nc-ink-2); }
920
1107
  .actionBtn svg { width: 13px; height: 13px; }
921
- .actionDanger { color: var(--nc-danger); border-color: rgba(248,113,113,.3); }
922
- .actionDanger:hover { color: var(--nc-danger-soft); border-color: rgba(248,113,113,.55); background: rgba(248,113,113,.06); }
1108
+ .actionDanger { color: var(--nc-danger); border-color: color-mix(in srgb, var(--nc-danger) 30%, transparent); }
1109
+ .actionDanger:hover { color: var(--nc-danger-soft); border-color: color-mix(in srgb, var(--nc-danger) 55%, transparent); background: color-mix(in srgb, var(--nc-danger) 6%, transparent); }
923
1110
  .countdownChip {
924
1111
  display: inline-flex;
925
1112
  align-items: center;
@@ -928,7 +1115,7 @@
928
1115
  font-size: 12px;
929
1116
  color: var(--nc-warn);
930
1117
  padding: 8px 12px;
931
- border: 1px solid rgba(251,191,36,.3);
1118
+ border: 1px solid color-mix(in srgb, var(--nc-warn) 30%, transparent);
932
1119
  border-radius: 9px;
933
1120
  }
934
1121
  .activity {
@@ -1096,12 +1283,11 @@
1096
1283
  padding: 10px 0 2px;
1097
1284
  }
1098
1285
  .attachFloatBody .attachGrid { margin: 0 14px 10px; }
1099
- /* One column down the sheet: the text is capped and centred for readability, so anything
1100
- that follows it lines up with it instead of hugging the far left of a wide pane. */
1286
+ /* Tiles sit in the sheet's own bottom-left corner, flush with its border, the way they
1287
+ did before the sheet became a centred page. The page stays centred; only the tiles
1288
+ went back to the corner. */
1101
1289
  .threadMsgBody .attachGrid {
1102
- max-width: none;
1103
- margin: 0;
1104
- padding: 0 34px 30px;
1290
+ margin: 12px 0 0;
1105
1291
  }
1106
1292
  .attachPillWrap {
1107
1293
  position: absolute;
@@ -1131,23 +1317,20 @@
1131
1317
  .readerBody {
1132
1318
  flex: 1;
1133
1319
  margin: 0 28px 24px;
1134
- border-radius: var(--radius-card);
1135
- border: 1px solid var(--nc-line-1);
1136
1320
  overflow: hidden;
1137
- background: var(--nc-ink-1);
1138
1321
  min-height: 0;
1139
1322
  }
1140
1323
  .readerFrame {
1141
1324
  width: 100%;
1142
1325
  height: 100%;
1143
1326
  border: none;
1144
- background: var(--nc-ink-1);
1327
+ background: transparent;
1145
1328
  }
1146
1329
  .readerText {
1147
- max-width: 800px;
1148
- margin: 0 auto;
1149
- padding: 30px 34px;
1150
- font: 400 15px/1.7 var(--nc-font-sans);
1330
+ max-width: 860px;
1331
+ margin: 0;
1332
+ padding: 8px 0 24px;
1333
+ font: 400 15px/var(--mail-lead, 1.3) var(--nc-font-sans);
1151
1334
  color: var(--nc-fg-1);
1152
1335
  white-space: pre-wrap;
1153
1336
  word-break: break-word;
@@ -1156,7 +1339,6 @@
1156
1339
  overflow-y: auto;
1157
1340
  /* A plain-text part carries no styling of its own, so it follows the app rather
1158
1341
  than sitting on the white paper an HTML email brings with it. */
1159
- background: var(--nc-ink-2);
1160
1342
  -webkit-font-smoothing: antialiased;
1161
1343
  }
1162
1344
  .readerSource {
@@ -1186,7 +1368,73 @@
1186
1368
  padding: 10px 16px calc(12px + env(safe-area-inset-bottom, 0px));
1187
1369
  background: var(--nc-ink-0);
1188
1370
  border-top: 1px solid var(--nc-line-1);
1371
+ animation: ncReplyIn .24s cubic-bezier(.2, .7, .3, 1) both;
1372
+ }
1373
+ @keyframes ncReplyIn {
1374
+ from { opacity: 0; transform: translateY(14px); }
1375
+ to { opacity: 1; transform: none; }
1376
+ }
1377
+ @media (prefers-reduced-motion: reduce) {
1378
+ .quickReplyBar { animation: none; }
1379
+ }
1380
+ /* The phone's stand-in for the docked reply: one line that opens the composer as a page.
1381
+ It keeps the dock's place at the foot of the reader so the layout does not shift. */
1382
+ .replyLaunch {
1383
+ flex: 0 0 auto;
1384
+ margin-top: auto;
1385
+ position: sticky;
1386
+ bottom: 0;
1387
+ z-index: 6;
1388
+ display: flex;
1389
+ gap: 8px;
1390
+ padding: 10px 16px calc(12px + env(safe-area-inset-bottom, 0px));
1391
+ background: var(--nc-ink-0);
1392
+ border-top: 1px solid var(--nc-line-1);
1393
+ }
1394
+ .replyLaunchBtn {
1395
+ flex: 1;
1396
+ min-width: 0;
1397
+ display: flex;
1398
+ align-items: center;
1399
+ gap: 10px;
1400
+ min-height: 44px;
1401
+ padding: 0 16px;
1402
+ border-radius: 12px;
1403
+ border: 1px solid var(--nc-line-1);
1404
+ background: var(--nc-ink-2);
1405
+ color: var(--nc-fg-2);
1406
+ font: 400 14px/1.4 var(--nc-font-sans);
1407
+ text-align: left;
1408
+ cursor: pointer;
1409
+ }
1410
+ .replyLaunchBtn span {
1411
+ min-width: 0;
1412
+ overflow: hidden;
1413
+ white-space: nowrap;
1414
+ text-overflow: ellipsis;
1189
1415
  }
1416
+ .replyLaunchBtn svg {
1417
+ flex: 0 0 auto;
1418
+ width: 16px;
1419
+ height: 16px;
1420
+ color: var(--nc-fg-3);
1421
+ }
1422
+ .replyLaunchAll {
1423
+ flex: 0 0 44px;
1424
+ height: 44px;
1425
+ display: grid;
1426
+ place-items: center;
1427
+ border-radius: 12px;
1428
+ border: 1px solid var(--nc-line-1);
1429
+ background: transparent;
1430
+ color: var(--nc-fg-2);
1431
+ cursor: pointer;
1432
+ }
1433
+ .replyLaunchAll svg {
1434
+ width: 17px;
1435
+ height: 17px;
1436
+ }
1437
+
1190
1438
  .replyRecips {
1191
1439
  display: flex;
1192
1440
  flex-direction: column;
@@ -1445,6 +1693,58 @@
1445
1693
  padding: 0;
1446
1694
  }
1447
1695
  .rteSwatchHi { border-radius: 50%; }
1696
+ .tickerHost {
1697
+ display: inline-block;
1698
+ max-width: 100%;
1699
+ min-width: 0;
1700
+ overflow: hidden;
1701
+ white-space: nowrap;
1702
+ text-overflow: ellipsis;
1703
+ vertical-align: bottom;
1704
+ }
1705
+ .tickerBlock { display: block; }
1706
+ .tickerHost[data-ticker='on'] { text-overflow: clip; }
1707
+ .tickerText { display: inline-block; white-space: nowrap; }
1708
+ .tickerHost[data-ticker='on'] .tickerText { animation: ncTicker var(--ticker-duration, 8s) ease-in-out var(--ticker-delay, 0s) infinite alternate; }
1709
+ @keyframes ncTicker {
1710
+ 0%, 14% { transform: translateX(0); }
1711
+ 86%, 100% { transform: translateX(calc(-1 * var(--ticker-shift, 0px))); }
1712
+ }
1713
+ @media (prefers-reduced-motion: reduce) {
1714
+ .tickerHost[data-ticker='on'] .tickerText { animation: none; }
1715
+ .tickerHost[data-ticker='on'] { text-overflow: ellipsis; }
1716
+ }
1717
+ .itemSubject { display: flex; align-items: center; gap: 4px; }
1718
+ .itemSubjectText { flex: 1; }
1719
+ .threadMsgFrom { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; }
1720
+ .threadMsgFromText { flex: 1; }
1721
+ .rteColourDot {
1722
+ display: inline-block;
1723
+ width: 16px;
1724
+ height: 16px;
1725
+ border-radius: 50%;
1726
+ background: conic-gradient(#b91c1c, #b45309, #047857, #1d4ed8, #6d28d9, #b91c1c);
1727
+ }
1728
+ .rtePalette {
1729
+ display: flex;
1730
+ align-items: center;
1731
+ gap: 10px;
1732
+ padding: 0 12px;
1733
+ max-height: 0;
1734
+ opacity: 0;
1735
+ overflow: hidden;
1736
+ transform: translateY(-6px);
1737
+ background: var(--nc-ink-2);
1738
+ transition: max-height .28s var(--nc-ease-out), opacity .22s var(--nc-ease-out), transform .28s var(--nc-ease-out), padding .28s var(--nc-ease-out);
1739
+ }
1740
+ .rtePaletteOpen {
1741
+ max-height: 60px;
1742
+ opacity: 1;
1743
+ transform: none;
1744
+ padding: 8px 12px;
1745
+ border-bottom: 1px solid var(--nc-line-1);
1746
+ }
1747
+ .rtePaletteGroup { display: flex; align-items: center; gap: 6px; }
1448
1748
  .rteContent {
1449
1749
  flex: 1;
1450
1750
  min-height: 0;
@@ -1457,23 +1757,24 @@
1457
1757
  min-height: 260px;
1458
1758
  padding: 20px 22px;
1459
1759
  outline: none;
1460
- font: 400 var(--rte-size, 15px)/1.65 var(--rte-font, Arial, Helvetica, sans-serif);
1760
+ font: 400 var(--rte-size, 15px)/var(--rte-lead, 1.3) var(--rte-font, Arial, Helvetica, sans-serif);
1461
1761
  color: var(--nc-fg-1);
1462
1762
  }
1463
- .rteSurface p { margin: 0 0 14px; }
1763
+ .rteSurface p { margin: 0 0 var(--rte-gap, 8px); }
1464
1764
  .rteSurface h1 { font-size: 24px; margin: 22px 0 12px; }
1465
1765
  .rteSurface h2 { font-size: 20px; margin: 20px 0 10px; }
1466
1766
  .rteSurface h3 { font-size: 17px; margin: 18px 0 8px; }
1467
- .rteSurface ul, .rteSurface ol { margin: 0 0 14px; padding-left: 22px; }
1767
+ .rteSurface ul, .rteSurface ol { margin: 0 0 var(--rte-gap, 8px); padding-left: 22px; }
1468
1768
  .rteSurface li { margin: 0 0 6px; }
1469
1769
  .rteSurface a { color: var(--nc-violet-500); text-decoration: underline; }
1470
1770
  .rteSurface blockquote {
1471
- margin: 0 0 14px;
1771
+ margin: 0 0 var(--rte-gap, 8px);
1472
1772
  padding-left: 14px;
1473
1773
  border-left: 3px solid var(--nc-line-2);
1474
1774
  color: var(--nc-fg-3);
1475
1775
  }
1476
1776
  .rteSurface pre {
1777
+ margin: 0 0 var(--rte-gap, 8px);
1477
1778
  background: var(--nc-ink-3);
1478
1779
  padding: 12px 14px;
1479
1780
  border-radius: 6px;
@@ -1481,7 +1782,7 @@
1481
1782
  font: 400 13px/1.5 var(--nc-font-mono);
1482
1783
  }
1483
1784
  .rteSurface img { max-width: 100%; height: auto; }
1484
- .rteImageWrap { position: relative; margin: 0 0 14px; }
1785
+ .rteImageWrap { position: relative; margin: 0 0 var(--rte-gap, 8px); }
1485
1786
  .rteImageWrap[data-align='center'] { text-align: center; }
1486
1787
  .rteImageWrap[data-align='right'] { text-align: right; }
1487
1788
  .rteImageBox {
@@ -1563,7 +1864,7 @@
1563
1864
  font-size: 11px;
1564
1865
  color: var(--nc-fg-2);
1565
1866
  }
1566
- .rteSurface table { border-collapse: collapse; margin: 0 0 14px; width: 100%; }
1867
+ .rteSurface table { border-collapse: collapse; margin: 0 0 var(--rte-gap, 8px); width: 100%; }
1567
1868
  .rteSurface td, .rteSurface th {
1568
1869
  border: 1px solid var(--nc-line-2);
1569
1870
  padding: 7px 10px;
@@ -1607,8 +1908,8 @@
1607
1908
  max-height: 64px;
1608
1909
  margin-bottom: 10px;
1609
1910
  }
1610
- .composeSignatureBody { font: 400 13px/1.6 var(--nc-font-sans); color: var(--nc-fg-2); }
1611
- .composeSignatureBody p { margin: 0 0 6px; }
1911
+ .composeSignatureBody { font: 400 13px/1 var(--nc-font-sans); color: var(--nc-fg-2); }
1912
+ .composeSignatureBody p { margin: 0 0 2px; }
1612
1913
 
1613
1914
  /* Logo picker in settings. */
1614
1915
  .sigLogoRow {
@@ -1857,7 +2158,7 @@
1857
2158
  padding: 0 2px;
1858
2159
  }
1859
2160
  .addrChip button:hover { color: var(--nc-danger); }
1860
- .addrChipBad { border-color: rgba(248,113,113,.5); color: var(--nc-danger-soft); }
2161
+ .addrChipBad { border-color: color-mix(in srgb, var(--nc-danger) 50%, transparent); color: var(--nc-danger-soft); }
1861
2162
 
1862
2163
  .toolbar {
1863
2164
  display: flex;
@@ -1918,6 +2219,15 @@
1918
2219
  transition: border-color var(--dur-fast) var(--nc-ease-out), color var(--dur-fast) var(--nc-ease-out);
1919
2220
  }
1920
2221
  .selectBtn:hover { border-color: var(--nc-line-2); color: var(--nc-fg-1); }
2222
+ .selectGlyph {
2223
+ flex-shrink: 0;
2224
+ font: 600 11px/1 var(--nc-font-mono);
2225
+ letter-spacing: .02em;
2226
+ color: var(--nc-fg-4);
2227
+ }
2228
+ .selectGlyphSerif { font: 600 14px/1 Georgia, 'Times New Roman', serif; color: var(--nc-fg-3); }
2229
+ .selectEditable .selectGlyph { margin-left: 9px; }
2230
+ .selectEditable .selectGlyph + .selectInput { padding-left: 6px; }
1921
2231
  .selectBtn > span {
1922
2232
  overflow: hidden;
1923
2233
  text-overflow: ellipsis;
@@ -2029,7 +2339,7 @@
2029
2339
  border-radius: 7px;
2030
2340
  transition: color var(--dur-fast) var(--nc-ease-out), background-color var(--dur-fast) var(--nc-ease-out);
2031
2341
  }
2032
- .tplDelete:hover { color: var(--nc-danger); background: rgba(248, 113, 113, .08); }
2342
+ .tplDelete:hover { color: var(--nc-danger); background: color-mix(in srgb, var(--nc-danger) 8%, transparent); }
2033
2343
  .tplSaveRow {
2034
2344
  display: flex;
2035
2345
  gap: 6px;
@@ -2079,9 +2389,9 @@
2079
2389
  border: none;
2080
2390
  background: transparent;
2081
2391
  color: var(--nc-fg-3);
2082
- font: 600 12px/1 var(--nc-font-sans);
2083
- padding: 6px 10px;
2084
- border-radius: 6px;
2392
+ font: 600 11.5px/1 var(--nc-font-sans);
2393
+ padding: 4px 9px;
2394
+ border-radius: 5px;
2085
2395
  cursor: pointer;
2086
2396
  transition: background-color var(--dur-fast) var(--nc-ease-out), color var(--dur-fast) var(--nc-ease-out);
2087
2397
  }
@@ -2093,16 +2403,16 @@
2093
2403
  display: flex;
2094
2404
  align-items: center;
2095
2405
  justify-content: space-between;
2096
- gap: 10px;
2406
+ gap: 8px;
2097
2407
  flex-wrap: wrap;
2098
- margin: 0 28px 12px;
2408
+ margin: 0 28px 8px;
2099
2409
  }
2100
2410
  .readerModeTabs {
2101
2411
  display: inline-flex;
2102
2412
  gap: 2px;
2103
2413
  background: var(--nc-ink-2);
2104
2414
  border: 1px solid var(--nc-line-1);
2105
- border-radius: 8px;
2415
+ border-radius: 7px;
2106
2416
  padding: 2px;
2107
2417
  }
2108
2418
 
@@ -2235,18 +2545,21 @@
2235
2545
  }
2236
2546
  .threadMsg {
2237
2547
  flex: 0 0 auto;
2238
- border: 1px solid var(--nc-line-1);
2239
- border-radius: var(--radius-card);
2548
+ border: 0;
2549
+ border-radius: 0;
2240
2550
  overflow: hidden;
2241
- background: var(--nc-ink-1);
2551
+ background: transparent;
2242
2552
  }
2243
- .threadMsgOpen { border-color: var(--nc-line-2); }
2553
+ .threadMsg + .threadMsg { border-top: 1px solid var(--nc-line-1); }
2554
+ .threadMsgOpen .threadMsgHead { border-bottom: 1px solid var(--nc-line-1); }
2244
2555
  .threadMsgHead {
2245
2556
  display: flex;
2246
2557
  align-items: center;
2247
2558
  gap: 12px;
2248
2559
  width: 100%;
2249
2560
  padding: var(--thread-pad);
2561
+ padding-left: 0;
2562
+ padding-right: 0;
2250
2563
  background: transparent;
2251
2564
  border: none;
2252
2565
  cursor: pointer;
@@ -2287,12 +2600,32 @@
2287
2600
  .threadClip svg { width: 13px; height: 13px; }
2288
2601
  /* ── Attachment tiles ────────────────────────────────────── */
2289
2602
  .attachGrid {
2290
- display: grid;
2291
- grid-template-columns: repeat(auto-fill, minmax(278px, 1fr));
2603
+ display: flex;
2292
2604
  gap: 8px;
2293
2605
  margin: 0 28px 14px;
2606
+ overflow-x: auto;
2607
+ scroll-snap-type: x proximity;
2608
+ padding-bottom: 6px;
2609
+ scrollbar-width: thin;
2610
+ scrollbar-color: color-mix(in srgb, var(--nc-violet-500) 55%, transparent) transparent;
2611
+ }
2612
+ .attachGrid::-webkit-scrollbar { height: 8px; }
2613
+ .readerAttach .attachGrid { margin: 0; flex: 1 1 100%; }
2614
+ .attachGrid::-webkit-scrollbar-track { background: transparent; }
2615
+ .attachGrid::-webkit-scrollbar-thumb {
2616
+ background: color-mix(in srgb, var(--nc-violet-500) 45%, transparent);
2617
+ background-clip: padding-box;
2618
+ border: 2px solid transparent;
2619
+ border-radius: 999px;
2620
+ }
2621
+ .attachGrid::-webkit-scrollbar-thumb:hover {
2622
+ background: color-mix(in srgb, var(--nc-violet-500) 78%, transparent);
2623
+ background-clip: padding-box;
2294
2624
  }
2295
2625
  .attachTile {
2626
+ flex: 0 0 auto;
2627
+ width: min(300px, 78vw);
2628
+ scroll-snap-align: start;
2296
2629
  display: flex;
2297
2630
  align-items: center;
2298
2631
  gap: 9px;
@@ -2566,8 +2899,8 @@
2566
2899
  vertical-align: middle;
2567
2900
  white-space: nowrap;
2568
2901
  }
2569
- .linkBadgeWarn { color: var(--nc-warn); border: 1px solid rgba(251,191,36,.4); }
2570
- .linkBadgeBad { color: var(--nc-danger); border: 1px solid rgba(248,113,113,.45); }
2902
+ .linkBadgeWarn { color: var(--nc-warn); border: 1px solid color-mix(in srgb, var(--nc-warn) 40%, transparent); }
2903
+ .linkBadgeBad { color: var(--nc-danger); border: 1px solid color-mix(in srgb, var(--nc-danger) 45%, transparent); }
2571
2904
  .orderDivider {
2572
2905
  width: 1px;
2573
2906
  height: 16px;
@@ -2576,12 +2909,12 @@
2576
2909
  flex-shrink: 0;
2577
2910
  }
2578
2911
  .orderToggle {
2579
- padding: 5px 10px;
2580
- border-radius: 7px;
2912
+ padding: 3px 8px;
2913
+ border-radius: 6px;
2581
2914
  border: 1px solid var(--nc-line-1);
2582
2915
  background: transparent;
2583
2916
  color: var(--nc-fg-2);
2584
- font: 600 11.5px/1 var(--nc-font-mono);
2917
+ font: 600 11px/1 var(--nc-font-mono);
2585
2918
  cursor: pointer;
2586
2919
  white-space: nowrap;
2587
2920
  transition: color var(--dur-fast) var(--nc-ease-out), border-color var(--dur-fast) var(--nc-ease-out), background-color var(--dur-fast) var(--nc-ease-out);
@@ -2611,18 +2944,8 @@
2611
2944
  all four sides, so you can see where the mail begins and where it ends rather than a
2612
2945
  band of white running into the furniture above it. */
2613
2946
  .threadMsgBody {
2614
- /* A page, not a wall: the paper is the width of the column it carries and sits centred in
2615
- the pane, rather than stretching the full width with the text stranded in the middle. */
2616
- /* Auto margins on a grid item shrink it to its content, which sized the page to whatever
2617
- the longest line happened to be. The width is stated, and the margins only centre it. */
2618
- width: min(868px, calc(100% - var(--thread-inset) * 2));
2619
- margin: var(--thread-inset) auto;
2620
- border: 1px solid var(--nc-line-1);
2621
- border-radius: var(--radius-card);
2622
- /* The sheet is the paper the mail is printed on, so it is the paper's colour. Left on the
2623
- card's own tone it vanished into the card, and the only edge you could see was the one
2624
- the message's own white background drew, square, wherever its markup happened to start. */
2625
- background: var(--nc-ink-0);
2947
+ width: 100%;
2948
+ margin: 0 0 8px;
2626
2949
  font-size: var(--row-subject);
2627
2950
  line-height: var(--row-lead);
2628
2951
  overflow: hidden;
@@ -2631,7 +2954,7 @@
2631
2954
  width: 100%;
2632
2955
  min-height: 120px;
2633
2956
  border: none;
2634
- background: var(--nc-ink-1);
2957
+ background: transparent;
2635
2958
  display: block;
2636
2959
  }
2637
2960
  .threadMsgBody .readerText,
@@ -2645,9 +2968,10 @@
2645
2968
  .threadMsgBody .readerText,
2646
2969
  .threadMsgBody .readerSource {
2647
2970
  max-width: none;
2971
+ padding: 6px 0 12px;
2648
2972
  margin: 0;
2649
2973
  }
2650
- .threadMsgBody .readerText { padding-bottom: 16px; }
2974
+ .threadMsgBody .readerText { padding-bottom: 16px; padding-left: 0; padding-right: 0; }
2651
2975
  .threadMsgBody .readerAttach { padding-top: 14px; }
2652
2976
 
2653
2977
  .campaign {
@@ -3014,6 +3338,11 @@
3014
3338
  font: 500 14px/1 var(--nc-font-sans);
3015
3339
  color: var(--nc-fg-1);
3016
3340
  }
3341
+ .toastError {
3342
+ border-color: color-mix(in srgb, var(--nc-danger) 45%, transparent);
3343
+ color: var(--nc-danger);
3344
+ }
3345
+
3017
3346
  .toastCount {
3018
3347
  font-family: var(--nc-font-mono);
3019
3348
  color: var(--nc-warn);
@@ -3097,9 +3426,9 @@
3097
3426
  flex-direction: column;
3098
3427
  gap: 7px;
3099
3428
  padding: 11px 13px;
3100
- border: 1px solid rgba(248, 113, 113, .32);
3429
+ border: 1px solid color-mix(in srgb, var(--nc-danger) 32%, transparent);
3101
3430
  border-radius: var(--radius-control);
3102
- background: rgba(248, 113, 113, .07);
3431
+ background: color-mix(in srgb, var(--nc-danger) 7%, transparent);
3103
3432
  }
3104
3433
  .loginError {
3105
3434
  font: 500 13px/1.45 var(--nc-font-sans);
@@ -3368,8 +3697,8 @@
3368
3697
  margin: 0 28px 14px;
3369
3698
  padding: 10px 14px;
3370
3699
  border-radius: 10px;
3371
- border: 1px solid rgba(251,191,36,.3);
3372
- background: rgba(251,191,36,.06);
3700
+ border: 1px solid color-mix(in srgb, var(--nc-warn) 30%, transparent);
3701
+ background: color-mix(in srgb, var(--nc-warn) 6%, transparent);
3373
3702
  color: var(--nc-fg-2);
3374
3703
  font: 500 13px/1.4 var(--nc-font-sans);
3375
3704
  }
@@ -3471,7 +3800,8 @@
3471
3800
  position: fixed;
3472
3801
  inset: 0;
3473
3802
  background: rgba(5, 3, 9, .55);
3474
- z-index: 440;
3803
+ /* Above .topRight, which floats at 500 and was landing squarely on the close button. */
3804
+ z-index: 530;
3475
3805
  display: flex;
3476
3806
  align-items: center;
3477
3807
  justify-content: center;
@@ -3767,6 +4097,41 @@
3767
4097
  gap: 15px;
3768
4098
  padding: var(--thread-pad);
3769
4099
  }
4100
+ /* The whole reader, drawn as shapes, for the moment between clicking a conversation and
4101
+ its messages arriving. It stands in for the header as well as the body, because that is
4102
+ what is missing: the pane otherwise keeps showing "Nothing open" or the last message. */
4103
+ .readerSkeleton {
4104
+ display: flex;
4105
+ flex-direction: column;
4106
+ gap: 18px;
4107
+ /* The real header, actions and thread all sit 28px in; anything else makes the page
4108
+ jump sideways the moment it replaces the skeleton. */
4109
+ padding: 22px 28px 0;
4110
+ }
4111
+ /* Scoped under the skeleton so they outrank the plain .skelBar rule, which comes later in
4112
+ the file and would otherwise flatten the subject back to a 13px line. */
4113
+ .readerSkeleton .skelSubject {
4114
+ height: 22px;
4115
+ width: 62%;
4116
+ border-radius: 8px;
4117
+ }
4118
+ .skelActions {
4119
+ display: flex;
4120
+ gap: 10px;
4121
+ }
4122
+ .skelActions .skelBar {
4123
+ width: 96px;
4124
+ height: 40px;
4125
+ border-radius: var(--radius-control);
4126
+ }
4127
+ .readerSkeleton .bodySkeleton {
4128
+ padding: 0;
4129
+ }
4130
+ /* The block placeholder is ink-2, which is white in the light theme — the same white the
4131
+ reader is painted on, so it vanished. ink-3 is a visible step in all three themes. */
4132
+ .readerSkeleton .skelBlock {
4133
+ background: var(--nc-ink-3);
4134
+ }
3770
4135
  .skelHeadRow {
3771
4136
  display: flex;
3772
4137
  align-items: center;
@@ -4463,7 +4828,7 @@
4463
4828
  gap: 2px;
4464
4829
  background: var(--nc-ink-2);
4465
4830
  border: 1px solid var(--nc-line-1);
4466
- border-radius: 8px;
4831
+ border-radius: 7px;
4467
4832
  padding: 2px;
4468
4833
  }
4469
4834
 
@@ -5016,6 +5381,27 @@
5016
5381
  animation: ncNoticeIn .26s cubic-bezier(.2, .7, .3, 1) both;
5017
5382
  will-change: transform, opacity;
5018
5383
  }
5384
+ /* The card behind peeks out above the one in front, so the next thing to deal with is
5385
+ visible without being readable or clickable until it comes forward. */
5386
+ .pwNoticeBehind {
5387
+ z-index: calc(75 - var(--depth, 1));
5388
+ transform: translateY(calc(var(--depth, 1) * -12px)) scale(calc(1 - var(--depth, 1) * .04));
5389
+ opacity: .92;
5390
+ pointer-events: none;
5391
+ animation: none;
5392
+ transition: transform .26s cubic-bezier(.2, .7, .3, 1);
5393
+ }
5394
+ .pwNoticeMore {
5395
+ position: absolute;
5396
+ top: -9px;
5397
+ right: 14px;
5398
+ padding: 3px 8px;
5399
+ border-radius: 999px;
5400
+ background: var(--nc-violet-500);
5401
+ color: #fff;
5402
+ font: 600 11px/1 var(--nc-font-sans);
5403
+ letter-spacing: .01em;
5404
+ }
5019
5405
 
5020
5406
  .pwNoticeTitle {
5021
5407
  margin: 0;
@@ -5364,7 +5750,7 @@
5364
5750
  }
5365
5751
  .sigPreviewOpen .sigPreviewFold { max-height: 2000px; }
5366
5752
  .sigPreviewBody {
5367
- font: 400 13px/1.6 var(--nc-font-sans);
5753
+ font: 400 13px/1 var(--nc-font-sans);
5368
5754
  color: var(--nc-fg-2);
5369
5755
  }
5370
5756
  .sigPreviewBody a { color: var(--nc-violet-500); }
@@ -5910,9 +6296,7 @@
5910
6296
  background: linear-gradient(to bottom, transparent 0%, var(--nc-ink-1) 78%);
5911
6297
  pointer-events: none;
5912
6298
  }
5913
- /* Its measured height, set by the element itself, so the reveal finishes with the
5914
- content rather than running on against an arbitrary ceiling. */
5915
- .composeSignatureOpen .composeSignatureBody { max-height: var(--sig-h, 1200px); cursor: default; }
6299
+ .composeSignatureOpen .composeSignatureBody { max-height: none; overflow: visible; cursor: default; }
5916
6300
  .composeSignatureOpen { padding-bottom: 30px; }
5917
6301
 
5918
6302
  @media (prefers-reduced-motion: reduce) {
@@ -6259,6 +6643,12 @@
6259
6643
  right: calc(18px + env(safe-area-inset-right, 0px));
6260
6644
  bottom: calc(18px + env(safe-area-inset-bottom, 0px));
6261
6645
  }
6646
+ /* Above the compose button, which appears at this width in the same corner: 54px tall
6647
+ sitting 18px off the bottom, so anything below 84px is underneath it. */
6648
+ .toTop {
6649
+ right: calc(18px + env(safe-area-inset-right, 0px));
6650
+ bottom: calc(84px + env(safe-area-inset-bottom, 0px));
6651
+ }
6262
6652
  .list {
6263
6653
  padding-left: calc(10px + env(safe-area-inset-left, 0px));
6264
6654
  padding-right: calc(10px + env(safe-area-inset-right, 0px));
@@ -6274,6 +6664,7 @@
6274
6664
  width: auto;
6275
6665
  z-index: 445;
6276
6666
  }
6667
+ .pwNoticeBehind { z-index: calc(445 - var(--depth, 1)); }
6277
6668
  /* Forty-four pixels is the smallest thing a thumb finds reliably. */
6278
6669
  .itemStar,
6279
6670
  .iconBtn,
@@ -6318,3 +6709,166 @@
6318
6709
  max-width: none;
6319
6710
  }
6320
6711
  }
6712
+
6713
+ /* ---- Phone corrections ------------------------------------------------------------
6714
+ Everything here was found by hit-testing the rendered page at 375x812 rather than by
6715
+ reading the stylesheet, because each fault only appears once the list is full width. */
6716
+ @media (max-width: 720px) {
6717
+ /* The notification and theme buttons float over the top-right corner. With a reading
6718
+ pane beside it that corner is empty; without one it is the list header, and these two
6719
+ sat on top of Refresh so it could not be tapped at all. Drop them behind anything that
6720
+ opens over the mailbox, and give the header room to put Refresh beside them. */
6721
+ .topRight {
6722
+ top: 18px;
6723
+ right: 12px;
6724
+ gap: 6px;
6725
+ z-index: 405;
6726
+ }
6727
+ .listHead .refreshBtn { display: none; }
6728
+ .actions { row-gap: 8px; gap: 6px; padding: 12px 15px; }
6729
+ .app { --thread-inset: 15px; }
6730
+ .readerHead { padding: 18px 15px 0; }
6731
+ .readerBody { margin: 0 15px 16px; }
6732
+ .readerModeBar { margin: 0 15px 12px; }
6733
+ .thread { padding-left: 15px; padding-right: 15px; }
6734
+ .attachGrid { margin: 0 15px 12px; }
6735
+ .activity { margin: 0 15px 14px; padding: 10px 12px; }
6736
+ .readerAttach { margin: 0 15px 14px; }
6737
+ .attachFloat {
6738
+ left: 0;
6739
+ right: 0;
6740
+ bottom: 0;
6741
+ border-left: 0;
6742
+ border-right: 0;
6743
+ border-bottom: 0;
6744
+ border-radius: 14px 14px 0 0;
6745
+ }
6746
+ .attachFloatBody .attachGrid { margin: 0 15px 10px; }
6747
+ .readerText { padding: 16px 15px; }
6748
+ .composeSignature { margin: 0 15px 14px; }
6749
+ .quickReplyBar { padding-left: 15px; padding-right: 15px; }
6750
+ .actions .iconBtn { min-width: 40px; width: 40px; padding: 0; }
6751
+ .actionSpacer { flex: 1 0 100%; min-width: 0; height: 0; }
6752
+ .rteToolbar {
6753
+ flex-wrap: nowrap;
6754
+ overflow-x: auto;
6755
+ gap: 6px;
6756
+ padding: 8px 12px;
6757
+ scroll-snap-type: x proximity;
6758
+ scrollbar-width: none;
6759
+ -webkit-overflow-scrolling: touch;
6760
+ }
6761
+ .rteToolbar::-webkit-scrollbar { display: none; }
6762
+ .rteGroup { flex: 0 0 auto; scroll-snap-align: start; }
6763
+ .rtePalette .rteSwatch { width: 24px; height: 24px; }
6764
+ .compose,
6765
+ .composeExpanded {
6766
+ top: auto;
6767
+ bottom: 0;
6768
+ height: 80dvh;
6769
+ max-height: 80dvh;
6770
+ border-radius: 18px 18px 0 0;
6771
+ }
6772
+ .listHead {
6773
+ padding-right: 106px;
6774
+ }
6775
+ .listTitle {
6776
+ flex: 0 0 auto;
6777
+ }
6778
+ .listMeta {
6779
+ min-width: 0;
6780
+ overflow: hidden;
6781
+ white-space: nowrap;
6782
+ text-overflow: ellipsis;
6783
+ }
6784
+ /* "60 convers…" reads as a fault rather than as a count. Below this width the number
6785
+ carries it alone; the unit is still in the header's tooltip and spelled out in full
6786
+ at the end of the list. */
6787
+ .listMetaUnit {
6788
+ display: none;
6789
+ }
6790
+ /* An earlier rule lifted this clear of the compose button; a later one pinned it back
6791
+ over the top, so its own buttons were half covered. */
6792
+ .pwNotice {
6793
+ bottom: calc(84px + env(safe-area-inset-bottom, 0px));
6794
+ }
6795
+ /* Nine formatting buttons at a size a finger can hit wrap onto three rows and take 134px
6796
+ of a reply that has not been written yet. One row that scrolls keeps every control and
6797
+ costs a third of the height. */
6798
+ .replyToolbar,
6799
+ .readerModeTabs,
6800
+ .readerLayoutTabs {
6801
+ flex-wrap: nowrap;
6802
+ overflow-x: auto;
6803
+ scrollbar-width: none;
6804
+ }
6805
+ .replyToolbar::-webkit-scrollbar,
6806
+ .readerModeTabs::-webkit-scrollbar,
6807
+ .readerLayoutTabs::-webkit-scrollbar {
6808
+ display: none;
6809
+ }
6810
+ .replyToolbar > *,
6811
+ .readerModeTabs > *,
6812
+ .readerLayoutTabs > * {
6813
+ flex: 0 0 auto;
6814
+ }
6815
+
6816
+ /* The card scrolls, and the footer scrolled away with it, so Save was reachable only
6817
+ after finding the bottom of a long form. */
6818
+ .settingsFoot {
6819
+ position: sticky;
6820
+ bottom: 0;
6821
+ z-index: 1;
6822
+ margin-top: 8px;
6823
+ padding: 10px 0 2px;
6824
+ background: var(--nc-ink-1);
6825
+ box-shadow: 0 -10px 16px -12px rgba(0, 0, 0, .5);
6826
+ }
6827
+ }
6828
+
6829
+ /* A finger is not a mouse pointer: anything it has to hit needs to be a target, not a
6830
+ label. These were 15px to 32px tall, which is a miss more often than a hit. */
6831
+ @media (pointer: coarse) {
6832
+ .modeTab,
6833
+ .orderToggle {
6834
+ min-height: 24px;
6835
+ }
6836
+ .settingsNavItem {
6837
+ min-height: 40px;
6838
+ }
6839
+ .toolBtn {
6840
+ min-width: 40px;
6841
+ min-height: 40px;
6842
+ }
6843
+ .headerToggle,
6844
+ .replyRecipsToggle,
6845
+ .composeSignatureLabel,
6846
+ .composeSignatureEdit,
6847
+ .composeSignatureMore {
6848
+ min-height: 36px;
6849
+ padding-top: 2px;
6850
+ padding-bottom: 2px;
6851
+ }
6852
+ .headerToggle {
6853
+ padding-left: 8px;
6854
+ padding-right: 8px;
6855
+ }
6856
+ }
6857
+
6858
+ @media (max-width: 720px) {
6859
+ /* Compose is already the whole screen here, so a control that makes it full width has
6860
+ nothing to do, and a phone has no Esc key to press. Between them they wrapped the
6861
+ compose header onto four lines. */
6862
+ .composeExpandBtn,
6863
+ .composeHintKeys {
6864
+ display: none;
6865
+ }
6866
+ /* The expanded composer takes its left edge from --list-w, and the chosen list width
6867
+ is written onto .app as an inline variable — which outranks this breakpoint's zero.
6868
+ So it opened 400px to the right of a 375px screen, wholly out of view. */
6869
+ .composeExpanded {
6870
+ left: 0;
6871
+ right: 0;
6872
+ width: auto;
6873
+ }
6874
+ }