@kurtbruns/kestrel 1.11.0 → 1.13.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 (39) hide show
  1. package/CHANGELOG.md +154 -1
  2. package/README.md +6 -7
  3. package/cli/dev-api.mjs +1 -1
  4. package/cli/reset.mjs +2 -0
  5. package/cli/seed.mjs +3 -1
  6. package/demo/README.md +4 -2
  7. package/demo/posts/0-test-email/index.md +13 -0
  8. package/demo/posts/5-a-quick-note/index.md +2 -0
  9. package/dist/public/dashboard/app.js +383 -324
  10. package/dist/public/dashboard/app.js.map +4 -4
  11. package/dist/public/dashboard/index.html +2 -2
  12. package/dist/public/dashboard/styles.css +850 -191
  13. package/dist/worker/index.js +11327 -6891
  14. package/docs/get-started/02-deploy.md +1 -1
  15. package/docs/get-started/03-access.md +3 -3
  16. package/docs/get-started/04-ses.md +24 -4
  17. package/docs/get-started/05-verify.md +21 -5
  18. package/docs/get-started/06-go-live.md +2 -2
  19. package/docs/guides/01-connect-claude.md +8 -3
  20. package/docs/guides/09-as-your-list-grows.md +6 -2
  21. package/docs/publishing/01-your-list.md +35 -30
  22. package/docs/publishing/02-sending-rules.md +3 -3
  23. package/docs/publishing/04-write-a-post.md +20 -6
  24. package/docs/publishing/06-troubleshooting.md +46 -12
  25. package/docs/publishing/07-working-with-claude.md +4 -4
  26. package/docs/publishing/09-share-your-subscribe-link.md +2 -2
  27. package/docs/publishing/10-check-your-setup.md +2 -2
  28. package/docs/publishing/11-email-template.md +3 -1
  29. package/docs/publishing/README.md +1 -1
  30. package/docs/reference/01-configuration.md +3 -3
  31. package/migrations/0007_sends_progress_at.sql +12 -0
  32. package/migrations/0008_notifications_sandbox.sql +35 -0
  33. package/migrations/0009_post_email_only.sql +12 -0
  34. package/migrations/0010_unlisted_links.sql +43 -0
  35. package/migrations/0011_sends_render_warnings.sql +13 -0
  36. package/migrations/0012_unsubscribe_cause.sql +139 -0
  37. package/package.json +1 -1
  38. package/template/claude/CLAUDE.md +13 -9
  39. package/template/claude/skills/migrate-list/SKILL.md +5 -4
@@ -1558,6 +1558,42 @@ td a:hover {
1558
1558
  color: var(--status-sending-fg);
1559
1559
  border-color: var(--status-sending-line);
1560
1560
  }
1561
+ /* A list's title with its Email only mark (sends/progress.ts): the title wraps within its room
1562
+ and the mark, an envelope and muted words, stays whole on the title's line, so it never drops
1563
+ onto a line of its own. */
1564
+ .title-with-label {
1565
+ display: flex;
1566
+ /* Centered, not on a baseline: the mark's text is smaller and led by an icon, and only
1567
+ their centers line up with the title's. */
1568
+ align-items: center;
1569
+ gap: 8px;
1570
+ min-width: 0;
1571
+ }
1572
+ .title-with-label .title-text {
1573
+ min-width: 0;
1574
+ }
1575
+ /* Where a table's columns are fixed, a long title ends in an ellipsis, so the row stays one
1576
+ line tall; the whole title is a click away in the post. */
1577
+ .list-table .title-with-label .title-text,
1578
+ .dash-drafts .title-with-label .title-text {
1579
+ overflow: hidden;
1580
+ text-overflow: ellipsis;
1581
+ white-space: nowrap;
1582
+ }
1583
+ .email-only-mark {
1584
+ display: inline-flex;
1585
+ align-items: center;
1586
+ gap: 4px;
1587
+ flex: none;
1588
+ font-size: 12.5px;
1589
+ font-weight: 400;
1590
+ color: var(--muted);
1591
+ white-space: nowrap;
1592
+ }
1593
+ .email-only-mark svg {
1594
+ width: 13px;
1595
+ height: 13px;
1596
+ }
1561
1597
  /* Subscriber consent states borrow a hue that matches the state's meaning: confirmed →
1562
1598
  the sent green, unsubscribed → the draft grey, suppressed → danger red (below). Pending
1563
1599
  awaits double-opt-in — a waiting/attention state — so it takes the amber `warn` scale,
@@ -1810,16 +1846,38 @@ td a:hover {
1810
1846
  .list-table .c-delivered {
1811
1847
  width: 176px;
1812
1848
  }
1849
+ /* Every Sent row, on the Sent page and the dashboard, as tall as one with a delivery note under its count, so a row with none (a
1850
+ one-recipient test) sits in the same rhythm as the rest. A row's height is its floor; once
1851
+ the table stacks, each row is a block that takes its own height. */
1852
+ /* The dashboard's Drafts card is half the page wide: its date stays on one line, and the title
1853
+ takes what room is left. */
1854
+ .dash-drafts {
1855
+ table-layout: fixed;
1856
+ }
1857
+ /* Wide enough for the longest "edited <date>", a two-digit day and hour included, with the
1858
+ cell's padding: the fixed layout clips anything wider rather than growing the column. */
1859
+ .dash-drafts td.edited {
1860
+ width: 15em;
1861
+ white-space: nowrap;
1862
+ }
1863
+ @media (min-width: 721px) {
1864
+ .sent-table tbody tr,
1865
+ .dash-sent tbody tr {
1866
+ height: 56px;
1867
+ }
1868
+ }
1813
1869
  .list-table .c-act {
1814
1870
  width: 56px;
1815
1871
  }
1816
- /* Subscribers: how each joined ("Imported · Sendy, single opt-in"), which may wrap to two
1817
- lines; the table takes a wider floor so the email column keeps room beside it. */
1818
- .list-table .c-how {
1819
- width: 152px;
1820
- }
1872
+ /* Subscribers: a floor that keeps the email column about 250px beside its two others. With
1873
+ only Status and Joined beside an address, which rarely needs more than half the row, fixed
1874
+ widths left one wide gap before them; a share of the row each spreads the space evenly. */
1821
1875
  .list-table.subs-table {
1822
- min-width: 680px;
1876
+ min-width: 610px;
1877
+ }
1878
+ .list-table.subs-table .c-status,
1879
+ .list-table.subs-table .c-date {
1880
+ width: 24%;
1823
1881
  }
1824
1882
 
1825
1883
  .dash-section table {
@@ -1880,20 +1938,143 @@ th.num .th-sort {
1880
1938
  cursor: default;
1881
1939
  }
1882
1940
 
1883
- /* API reference: query-param table inside a route card */
1884
- .api-query {
1941
+ /* A route's or an object's sections (query, body, returns, refusals, fields) as Stripe's and
1942
+ Mux's references set theirs: a heading over a rule, then unboxed rows, each with the name,
1943
+ its type, and Required on one line and what it means under it, so a long name never
1944
+ squeezes its description. A name breaks only where its words join (the <wbr> the page puts
1945
+ after each _ . /). */
1946
+ .block.api-sec {
1947
+ margin-top: 22px;
1948
+ }
1949
+ .api-sec > .block-head {
1885
1950
  margin: 0;
1951
+ padding-bottom: 8px;
1952
+ border-bottom: 1px solid var(--line-soft);
1886
1953
  }
1887
- .api-query td {
1888
- padding: 5px 10px;
1954
+ .api-sec > .block-head .block-label {
1955
+ font-size: 15px;
1956
+ font-weight: 600;
1957
+ color: var(--fg);
1958
+ }
1959
+ .api-sec-meta {
1960
+ margin-left: 2px;
1961
+ font: 400 12px var(--mono);
1962
+ color: var(--muted);
1963
+ }
1964
+ /* Rules divide rows; the last row in a section has none under it. */
1965
+ .api-row,
1966
+ .api-lrow {
1967
+ padding: 11px 0;
1968
+ border-bottom: 1px solid var(--line);
1969
+ }
1970
+ .api-row:last-child,
1971
+ .api-lrow:last-child {
1972
+ border-bottom: 0;
1973
+ }
1974
+ .api-row-head {
1975
+ display: flex;
1976
+ align-items: baseline;
1977
+ flex-wrap: wrap;
1978
+ gap: 8px;
1979
+ }
1980
+ code.api-row-name {
1889
1981
  font-size: 13px;
1890
- vertical-align: top;
1982
+ font-weight: 600;
1983
+ overflow-wrap: break-word;
1891
1984
  }
1892
- .api-query td:first-child {
1893
- white-space: nowrap;
1894
- width: 1%;
1985
+ .api-row-type {
1986
+ font: 12px var(--mono);
1987
+ color: var(--muted);
1988
+ }
1989
+ /* A type that names an object links to it, and reads as a link. */
1990
+ .api-row-type a {
1991
+ color: var(--fg);
1992
+ text-decoration: underline;
1993
+ text-underline-offset: 2px;
1994
+ }
1995
+ .api-row-desc {
1996
+ margin-top: 3px;
1997
+ font-size: 13.5px;
1998
+ }
1999
+ /* The refusals and the statuses are a ledger: the status in a column of its own, tinted by
2000
+ its class, so a reader scans by status. */
2001
+ .api-lrow {
2002
+ display: grid;
2003
+ grid-template-columns: 44px minmax(0, 1fr);
2004
+ gap: 6px;
2005
+ }
2006
+ .api-st {
2007
+ font: 600 12.5px / 20px var(--mono);
2008
+ color: var(--muted);
2009
+ }
2010
+ .api-st-4xx {
2011
+ color: var(--warn-fg);
2012
+ }
2013
+ .api-st-5xx {
2014
+ color: var(--danger-fg);
2015
+ }
2016
+ .api-st-2xx {
2017
+ color: var(--ok-fg);
2018
+ }
2019
+ .api-with {
2020
+ margin-top: 2px;
2021
+ font-size: 12px;
2022
+ color: var(--muted);
2023
+ }
2024
+ /* A status's name is words, not a code. */
2025
+ .api-statuses code.api-row-name {
2026
+ font-family: inherit;
2027
+ }
2028
+ /* A value's short cells beside its name (a condition kind's severity and fields), each led by
2029
+ its column's name, and the label of a later long line ("Remedy:"). */
2030
+ .api-row-meta {
2031
+ font-size: 12.5px;
2032
+ color: var(--muted);
2033
+ }
2034
+ .api-row-key {
2035
+ font-size: 12px;
2036
+ font-weight: 600;
2037
+ color: var(--muted);
2038
+ }
2039
+ /* What every route shares, described once ahead of the routes: concept prose, an object's
2040
+ description, an enum's values under a field's line, and the refusal table's next moves. */
2041
+ /* The reference's paragraphs, each with space below it, as a doc page's are. */
2042
+ .api-prose {
2043
+ margin: 0 0 10px;
2044
+ font-size: 14px;
2045
+ }
2046
+ /* A list in the reference's prose (the status ranges under Requests), set like its paragraphs. */
2047
+ .prose-list {
2048
+ padding-left: 20px;
2049
+ }
2050
+ .prose-list li {
2051
+ margin: 6px 0;
2052
+ }
2053
+ .api-values {
2054
+ margin: 4px 0 0;
2055
+ padding-left: 16px;
2056
+ }
2057
+ .api-values li {
2058
+ margin: 2px 0;
2059
+ }
2060
+ /* The name of a group an enum's values fall into, over its bullets. */
2061
+ .api-values-group {
2062
+ margin: 8px 0 0;
2063
+ font-size: 12.5px;
2064
+ font-weight: 600;
2065
+ color: var(--muted);
2066
+ }
2067
+ .api-returns {
2068
+ margin: 4px 0 6px;
2069
+ font-size: 13.5px;
2070
+ }
2071
+ /* A refusal's next move, under what it means: quieter, the step a client takes from it. */
2072
+ .api-move-next {
2073
+ display: block;
2074
+ margin-top: 2px;
2075
+ font-size: 12.5px;
2076
+ color: var(--muted);
1895
2077
  }
1896
-
1897
2078
  /* editor header */
1898
2079
  .editor-head {
1899
2080
  display: flex;
@@ -1965,14 +2146,16 @@ body.web-mode .content {
1965
2146
  padding: 10px 44px;
1966
2147
  text-align: left;
1967
2148
  }
2149
+ /* The reader pages' Dev pill exactly (`.r-dev-tag` in src/lib/page.ts), so the two read as one. */
1968
2150
  .web-bar-tag {
1969
2151
  display: inline-block;
1970
2152
  margin-right: 10px;
1971
- font-size: 10px;
2153
+ font-size: 9.5px;
2154
+ line-height: 1.6;
1972
2155
  letter-spacing: 0.09em;
1973
2156
  text-transform: uppercase;
1974
2157
  font-weight: 700;
1975
- padding: 1px 7px;
2158
+ padding: 2px 7px;
1976
2159
  border-radius: 999px;
1977
2160
  background: var(--accent-on);
1978
2161
  color: var(--accent);
@@ -2186,7 +2369,7 @@ body.web-mode .content {
2186
2369
  font-size: 22px;
2187
2370
  letter-spacing: -0.01em;
2188
2371
  }
2189
- /* The derived-phase pill (progressing / retrying / backing-off / needs-attention /
2372
+ /* The derived-phase pill (progressing / retrying / backing_off / needs_attention /
2190
2373
  settling / …). Tones reuse the status + semantic palettes so a phase reads the same
2191
2374
  hue as its badge elsewhere. A plain label, so the default cursor (DESIGN §11). */
2192
2375
  .phase-pill {
@@ -2415,6 +2598,12 @@ body.web-mode .content {
2415
2598
  border-color: var(--ring);
2416
2599
  box-shadow: 0 0 0 1px var(--ring);
2417
2600
  }
2601
+ /* A scheduled post's body is locked, so focus rings it in grey, as the locked subject's is,
2602
+ rather than in the blue that says "type here" (DESIGN §3). */
2603
+ .composer.composer-locked:focus-within {
2604
+ border-color: var(--muted);
2605
+ box-shadow: 0 0 0 1px var(--muted);
2606
+ }
2418
2607
  .composer-head {
2419
2608
  display: flex;
2420
2609
  align-items: flex-end;
@@ -2621,14 +2810,25 @@ body.web-mode .content {
2621
2810
  }
2622
2811
  .composer-body.locked textarea.editor {
2623
2812
  caret-color: transparent;
2813
+ cursor: default;
2814
+ }
2815
+ /* Its text is muted, as the subject's is, so the body reads as locked at a glance. */
2816
+ .composer-body.locked .md-hl code {
2817
+ opacity: 0.72;
2624
2818
  }
2625
- /* The subject and slug of a scheduled post are read-only too, and readonly (unlike disabled)
2626
- carries no native greying, so they take the same recessed surface as the body: otherwise
2627
- they read as editable, and the refusal pulses a line far below where the keystroke landed. */
2628
- #f-subject[readonly],
2629
- #f-slug[readonly] {
2819
+ /* The subject of a scheduled post is read-only too, and readonly (unlike disabled) carries no
2820
+ native greying, so it is styled to look locked while keeping what disabled would take away:
2821
+ it can still be focused, selected, and copied, and a keystroke still pulses the foot line.
2822
+ The recessed surface, muted text, an arrow cursor, and a grey focus ring in place of the
2823
+ blue one that says "type here" (DESIGN §3). */
2824
+ #f-subject[readonly] {
2630
2825
  background: var(--sunken);
2631
2826
  border-color: var(--line-soft);
2827
+ color: var(--muted);
2828
+ cursor: default;
2829
+ }
2830
+ #f-subject[readonly]:focus-visible {
2831
+ outline-color: var(--muted);
2632
2832
  }
2633
2833
  .composer-body.dragover::after {
2634
2834
  content: "Drop images to upload";
@@ -2696,6 +2896,11 @@ body.web-mode .content {
2696
2896
  }
2697
2897
  }
2698
2898
 
2899
+ /* The editor's subject, body, and actions sit on the page itself, with no card around them:
2900
+ the fields and the composer carry their own borders, aligned with the head and the banner. */
2901
+ .editor-form > label:first-child {
2902
+ margin-top: 6px;
2903
+ }
2699
2904
  .actions-bar {
2700
2905
  display: flex;
2701
2906
  align-items: center;
@@ -2910,13 +3115,10 @@ body.web-mode .content {
2910
3115
  flex: none;
2911
3116
  white-space: nowrap;
2912
3117
  }
2913
- /* The back link at the top of the Send-now view needs no divider — it returns from,
2914
- rather than demotes below, the Schedule view. */
2915
- .modal .altrow-top {
2916
- margin: -4px 0 10px;
2917
- padding-top: 0;
2918
- border-top: 0;
2919
- justify-content: flex-start;
3118
+ /* The Send-now view's way back to the Schedule view, at the start of its button row: it
3119
+ returns from, rather than demotes below, the Schedule view, so it takes no row of its own. */
3120
+ .modal .actions .actions-back {
3121
+ margin-right: auto;
2920
3122
  }
2921
3123
  /* Link — a text-link-styled action (DESIGN §7), not one of the button roles. Reserved
2922
3124
  for a deliberately subordinate choice, like the in-modal Send now that sits below
@@ -3122,16 +3324,6 @@ textarea.is-invalid {
3122
3324
  .modal .dialog-error {
3123
3325
  margin-top: 12px;
3124
3326
  }
3125
- /* Label + info icon grouped at the top of the slug field. */
3126
- .label-row {
3127
- display: flex;
3128
- align-items: center;
3129
- gap: 6px;
3130
- margin: 14px 0 8px;
3131
- }
3132
- .label-row label {
3133
- margin: 0;
3134
- }
3135
3327
  /* Info icon: the field explanation lives in its hover/focus tooltip. The padding
3136
3328
  + equal negative margin enlarge the hover/click target to ~28px without moving
3137
3329
  the glyph or growing the label row. */
@@ -3196,28 +3388,6 @@ textarea.is-invalid {
3196
3388
  visibility: visible;
3197
3389
  transform: translateY(0);
3198
3390
  }
3199
- /* The "Auto-generate from subject" toggle, grouped just under the slug field. */
3200
- .slug-auto-toggle {
3201
- display: inline-flex;
3202
- align-items: center;
3203
- gap: 6px;
3204
- margin-top: 8px;
3205
- font-size: 14px;
3206
- color: var(--muted);
3207
- cursor: pointer;
3208
- user-select: none;
3209
- }
3210
- .slug-auto-toggle input {
3211
- width: 14px;
3212
- height: 14px;
3213
- margin: 0;
3214
- accent-color: var(--ring);
3215
- cursor: pointer;
3216
- }
3217
- /* Slug is muted while it follows the subject; normal once set by hand. */
3218
- #f-slug.slug-auto {
3219
- color: var(--muted);
3220
- }
3221
3391
  /* --- Editor-state banner (DESIGN §5): a standing, in-flow line at the top of the
3222
3392
  editor, in two variants. `scheduled` marks a frozen, soft-locked post (SPEC §6);
3223
3393
  `conflict` warns the draft changed elsewhere (another tab, or Claude), and its two
@@ -3255,46 +3425,357 @@ textarea.is-invalid {
3255
3425
  banner that says to send a new test, and a mark on the dashboard's scheduled card. */
3256
3426
  .sched-remade {
3257
3427
  display: flex;
3258
- flex-wrap: wrap;
3428
+ flex-wrap: wrap;
3429
+ align-items: center;
3430
+ gap: 4px 10px;
3431
+ margin-top: 6px;
3432
+ font-size: 13px;
3433
+ }
3434
+ /* The scheduled banner's notes under the countdown (DESIGN §4): a send that will be held
3435
+ with no provider connected, and one too large for D1's daily writes on Workers Free's
3436
+ limits. Each keeps the banner's own violet, since amber words on violet fall short of
3437
+ contrast and mix two of the color meanings, and an amber ⚠️ leads it to say it's worth a
3438
+ look. Held is the stronger of the two (the send won't go out), so it keeps the weight. */
3439
+ .sched-note {
3440
+ display: flex;
3441
+ gap: 6px;
3442
+ align-items: baseline;
3443
+ margin-top: 6px;
3444
+ font-size: 12.5px;
3445
+ color: var(--status-scheduled-fg);
3446
+ }
3447
+ .sched-note-mark {
3448
+ flex: none;
3449
+ }
3450
+ .sched-note-held {
3451
+ font-weight: 500;
3452
+ }
3453
+ .sched-remade-mark {
3454
+ margin-top: 4px;
3455
+ font-size: 12.5px;
3456
+ color: var(--status-scheduled-fg);
3457
+ }
3458
+ /* The Email only box under a dialog's time: the label, then its helper beneath. */
3459
+ .listing-check {
3460
+ display: flex;
3461
+ align-items: flex-start;
3462
+ gap: 10px;
3463
+ margin: 14px 0 0;
3464
+ font-size: 14px;
3465
+ color: var(--fg);
3466
+ cursor: pointer;
3467
+ }
3468
+ .listing-check input {
3469
+ width: auto;
3470
+ margin: 3px 0 0;
3471
+ flex: 0 0 auto;
3472
+ accent-color: var(--ring);
3473
+ cursor: pointer;
3474
+ }
3475
+ .listing-check strong {
3476
+ font-weight: 600;
3477
+ }
3478
+ .listing-help {
3479
+ display: block;
3480
+ margin-top: 2px;
3481
+ font-size: 13px;
3482
+ line-height: 1.45;
3483
+ color: var(--muted);
3484
+ }
3485
+ /* A scheduled post's banner in two rows (DESIGN §4): the time, its notes, and the controls that
3486
+ act on it; then, under a hairline, where the post is going, on a slightly deeper ground. */
3487
+ .banner-split {
3488
+ display: block;
3489
+ padding: 0;
3490
+ overflow: hidden;
3491
+ }
3492
+ .banner-top {
3493
+ display: flex;
3494
+ align-items: center;
3495
+ justify-content: space-between;
3496
+ gap: 12px;
3497
+ flex-wrap: wrap;
3498
+ padding: 12px 16px;
3499
+ font-size: 14px;
3500
+ }
3501
+ .banner-bottom {
3502
+ display: flex;
3503
+ align-items: center;
3504
+ gap: 8px;
3505
+ padding: 9px 16px;
3506
+ border-top: 1px solid var(--status-scheduled-line);
3507
+ /* A shade deeper than the banner, so the row reads as its footer rather than a strip of its own. */
3508
+ background: color-mix(in srgb, var(--status-scheduled-fg) 6%, var(--status-scheduled-bg));
3509
+ font-size: 13px;
3510
+ }
3511
+ .banner-bottom svg {
3512
+ flex: none;
3513
+ width: 14px;
3514
+ height: 14px;
3515
+ }
3516
+ .sched-dest-icon {
3517
+ display: flex;
3518
+ flex: none;
3519
+ }
3520
+ /* A filled icon takes the row's violet; a stroked one (fill="none") already draws in it. */
3521
+ .banner-bottom svg:not([fill="none"]),
3522
+ #settingsBtn svg {
3523
+ fill: currentColor;
3524
+ }
3525
+ #schedDest {
3526
+ min-width: 0;
3527
+ overflow-wrap: anywhere;
3528
+ }
3529
+ .sched-dest code {
3530
+ font-family: var(--mono);
3531
+ font-size: 12.5px;
3532
+ }
3533
+ .pub-taken code {
3534
+ font-family: var(--mono);
3535
+ font-size: 12.5px;
3536
+ color: var(--fg);
3537
+ }
3538
+ /* The settings form in a dialog under the time, set off from it by a rule. */
3539
+ .dialog-settings {
3540
+ margin-top: 14px;
3541
+ padding-top: 2px;
3542
+ }
3543
+ .dialog-settings .listing-check {
3544
+ margin-top: 0;
3545
+ }
3546
+ /* The archive address as one URL field: the base greyed and fixed, then the slug, the one part
3547
+ to type. The whole field is the label, so a click on the base lands in the slug too. */
3548
+ .pub-address {
3549
+ margin-top: 14px;
3550
+ }
3551
+ .pub-label {
3552
+ display: block;
3553
+ margin: 0 0 6px;
3554
+ font-size: 13px;
3555
+ color: var(--muted);
3556
+ }
3557
+ .url-field {
3558
+ display: flex;
3559
+ align-items: stretch;
3560
+ min-width: 0;
3561
+ margin: 0;
3562
+ overflow: hidden;
3563
+ border: 1px solid var(--line);
3564
+ border-radius: 6px;
3565
+ background: var(--card);
3566
+ font-family: var(--mono);
3567
+ font-size: 13px;
3568
+ cursor: text;
3569
+ }
3570
+ .url-field:focus-within {
3571
+ border-color: var(--ring);
3572
+ box-shadow: 0 0 0 1px var(--ring);
3573
+ }
3574
+ .url-field.is-invalid {
3575
+ border-color: var(--danger-fg);
3576
+ }
3577
+ .url-base {
3578
+ display: flex;
3579
+ align-items: center;
3580
+ padding: 8px 0 8px 10px;
3581
+ white-space: nowrap;
3582
+ color: var(--muted);
3583
+ }
3584
+ .url-field input {
3585
+ flex: 1;
3586
+ min-width: 6em;
3587
+ border: 0;
3588
+ border-radius: 0;
3589
+ padding: 8px 10px 8px 0;
3590
+ font-family: var(--mono);
3591
+ font-size: 13px;
3592
+ font-weight: 600;
3593
+ }
3594
+ .url-field input:focus-visible {
3595
+ outline: none;
3596
+ }
3597
+ .url-field.readonly {
3598
+ cursor: default;
3599
+ background: var(--sunken);
3600
+ }
3601
+ .url-field.readonly input {
3602
+ color: var(--muted);
3603
+ font-weight: 500;
3604
+ background: transparent;
3605
+ }
3606
+ @media (max-width: 480px) {
3607
+ .url-field {
3608
+ flex-direction: column;
3609
+ }
3610
+ .url-base {
3611
+ padding-bottom: 0;
3612
+ }
3613
+ .url-field input {
3614
+ padding-left: 10px;
3615
+ }
3616
+ }
3617
+ /* A refused address, in the server's words, under its field. */
3618
+ .pub-error {
3619
+ margin: 8px 0 0;
3620
+ font-size: 13px;
3621
+ color: var(--danger-fg);
3622
+ }
3623
+ /* The Post settings button carries a gear before its words. */
3624
+ #settingsBtn {
3625
+ display: inline-flex;
3626
+ align-items: center;
3627
+ gap: 6px;
3628
+ }
3629
+ #settingsBtn svg {
3630
+ width: 16px;
3631
+ height: 16px;
3632
+ }
3633
+ .pub-taken {
3634
+ display: flex;
3635
+ align-items: flex-start;
3636
+ gap: 6px;
3637
+ margin: 8px 0 0;
3638
+ font-size: 13px;
3639
+ line-height: 1.5;
3640
+ color: var(--muted);
3641
+ }
3642
+ .pub-taken svg {
3643
+ width: 15px;
3644
+ height: 15px;
3645
+ flex: none;
3646
+ margin-top: 2px;
3647
+ }
3648
+ .pub-note {
3649
+ margin: 8px 0 0;
3650
+ font-size: 13px;
3651
+ color: var(--muted);
3652
+ }
3653
+ /* A sent post's page in two parts (DESIGN §3): the post, then its delivery. */
3654
+ .rec-head-page {
3655
+ margin-bottom: 14px;
3656
+ }
3657
+ .rec-part-title {
3658
+ margin: 0 0 10px;
3659
+ font-size: 11px;
3660
+ font-weight: 600;
3661
+ letter-spacing: 0.08em;
3662
+ text-transform: uppercase;
3663
+ color: var(--muted);
3664
+ }
3665
+ .rec-web {
3666
+ display: grid;
3667
+ }
3668
+ .rec-link-label {
3669
+ margin: 0 0 6px;
3670
+ font-size: 12.5px;
3671
+ color: var(--muted);
3672
+ }
3673
+ /* The link as one field: the address, then Copy and Open attached to it. */
3674
+ .rec-link {
3675
+ display: flex;
3676
+ align-items: stretch;
3677
+ min-width: 0;
3678
+ overflow: hidden;
3679
+ border: 1px solid var(--line);
3680
+ border-radius: 7px;
3681
+ background: var(--sunken);
3682
+ }
3683
+ /* An address is one line, shortened at its end, since Copy carries it whole. */
3684
+ .rec-link code {
3685
+ flex: 1 1 auto;
3686
+ align-self: center;
3687
+ min-width: 0;
3688
+ padding: 8px 10px;
3689
+ font-family: var(--mono);
3690
+ font-size: 12.5px;
3691
+ white-space: nowrap;
3692
+ overflow: hidden;
3693
+ text-overflow: ellipsis;
3694
+ }
3695
+ .rec-link button {
3696
+ flex: none;
3697
+ display: inline-flex;
3698
+ align-items: center;
3699
+ gap: 5px;
3700
+ min-height: 35px;
3701
+ padding: 0 11px;
3702
+ border: 0;
3703
+ border-left: 1px solid var(--line);
3704
+ border-radius: 0;
3705
+ background: var(--card);
3706
+ color: var(--muted);
3707
+ font-size: 13px;
3708
+ white-space: nowrap;
3709
+ }
3710
+ .rec-link button:hover {
3711
+ color: var(--fg);
3712
+ background: var(--hover);
3713
+ }
3714
+ .rec-link button.rec-open {
3715
+ color: var(--accent);
3716
+ font-weight: 500;
3717
+ }
3718
+ .rec-link-note {
3719
+ margin: 6px 0 0;
3720
+ font-size: 12.5px;
3721
+ }
3722
+ /* Where the post stands, in words beside the one change it allows (which asks first). */
3723
+ .rec-arch {
3724
+ display: flex;
3259
3725
  align-items: center;
3260
- gap: 4px 10px;
3261
- margin-top: 6px;
3262
- font-size: 13px;
3726
+ justify-content: space-between;
3727
+ flex-wrap: wrap;
3728
+ gap: 10px 16px;
3729
+ margin: 14px -16px 0;
3730
+ padding: 14px 16px 0;
3731
+ border-top: 1px solid var(--line);
3263
3732
  }
3264
- /* The scheduled banner's notes under the countdown (DESIGN §4): a send that will be held
3265
- with no provider connected, and one too large for D1's daily writes on Workers Free's
3266
- limits. Each keeps the banner's own violet, since amber words on violet fall short of
3267
- contrast and mix two of the color meanings, and an amber ⚠️ leads it to say it's worth a
3268
- look. Held is the stronger of the two (the send won't go out), so it keeps the weight. */
3269
- .sched-note {
3733
+ /* A post with no web link yet has only its standing to show, with no rule above it. */
3734
+ .rec-web > .rec-arch:first-child {
3735
+ margin-top: 0;
3736
+ padding-top: 0;
3737
+ border-top: 0;
3738
+ }
3739
+ .rec-arch-what {
3740
+ flex: 1 1 220px;
3741
+ min-width: 0;
3742
+ font-size: 14px;
3743
+ }
3744
+ .rec-arch-what strong {
3270
3745
  display: flex;
3746
+ align-items: center;
3271
3747
  gap: 6px;
3272
- align-items: baseline;
3273
- margin-top: 6px;
3274
- font-size: 12.5px;
3275
- color: var(--status-scheduled-fg);
3748
+ font-weight: 600;
3276
3749
  }
3277
- .sched-note-mark {
3750
+ .rec-arch-what svg {
3751
+ width: 16px;
3752
+ height: 16px;
3278
3753
  flex: none;
3754
+ color: var(--muted);
3279
3755
  }
3280
- .sched-note-held {
3281
- font-weight: 500;
3756
+ .rec-arch-what svg:not([fill="none"]) {
3757
+ fill: currentColor;
3282
3758
  }
3283
- .sched-remade-mark {
3284
- margin-top: 4px;
3285
- font-size: 12.5px;
3286
- color: var(--status-scheduled-fg);
3759
+ .rec-arch-note {
3760
+ display: block;
3761
+ margin-top: 2px;
3762
+ font-size: 13px;
3763
+ line-height: 1.45;
3764
+ color: var(--muted);
3287
3765
  }
3288
-
3289
- .grid2 {
3290
- display: grid;
3291
- grid-template-columns: 1fr 1fr;
3292
- gap: 10px;
3766
+ /* The address Remove from archive takes down, struck through in its dialog. */
3767
+ .url-gone {
3768
+ padding: 8px 10px;
3769
+ background: var(--sunken);
3770
+ border: 1px solid var(--line);
3771
+ border-radius: 6px;
3772
+ font-family: var(--mono);
3773
+ font-size: 13px;
3774
+ color: var(--muted);
3775
+ overflow-wrap: anywhere;
3293
3776
  }
3777
+
3294
3778
  @media (max-width: 620px) {
3295
- .grid2 {
3296
- grid-template-columns: 1fr;
3297
- }
3298
3779
  .tb {
3299
3780
  width: 34px;
3300
3781
  height: 34px;
@@ -3662,16 +4143,27 @@ button.api-filter-clear svg {
3662
4143
  color: var(--fg);
3663
4144
  background: var(--hover);
3664
4145
  }
4146
+ /* The reference reads as a document: a doc page's line height, not the browser's default. */
3665
4147
  .api-content {
3666
4148
  min-width: 0;
3667
- max-width: 820px;
4149
+ max-width: 1440px;
4150
+ line-height: 1.5;
4151
+ }
4152
+ /* One reading measure for the page, a doc page's (720px): the intro, the topics, and each
4153
+ route's and object's prose and tables keep it, and only a route's examples, beside its
4154
+ prose on a wide screen, reach past it. */
4155
+ .api-head,
4156
+ .api-blurb,
4157
+ .api-section[data-tier="overview"] .api-res,
4158
+ .api-op-body {
4159
+ max-width: 720px;
3668
4160
  }
3669
4161
  .api-head {
3670
4162
  margin: 0 0 12px;
3671
4163
  }
3672
4164
  .api-head h1 {
3673
4165
  margin: 0 0 4px;
3674
- font-size: 22px;
4166
+ font-size: 28px;
3675
4167
  }
3676
4168
  .api-section {
3677
4169
  scroll-margin-top: calc(var(--bar-h) + 2px);
@@ -3682,35 +4174,53 @@ button.api-filter-clear svg {
3682
4174
  .api-section.api-last {
3683
4175
  min-height: calc(100vh - var(--bar-h));
3684
4176
  }
4177
+ /* A tier is a page heading here, not the app's small-caps section label that h2 is elsewhere. */
3685
4178
  .api-section h2 {
3686
- font-size: 18px;
3687
- margin: 24px 0 4px;
4179
+ font-size: 24px;
4180
+ font-weight: 650;
4181
+ color: var(--fg);
4182
+ text-transform: none;
4183
+ letter-spacing: normal;
4184
+ margin: 28px 0 4px;
4185
+ }
4186
+ /* Where a jump from the rail or an address moves focus: a place to read from, not a control,
4187
+ so it takes no ring. */
4188
+ .api-section h2:focus,
4189
+ .api-res-head h3:focus {
4190
+ outline: none;
3688
4191
  }
3689
4192
  .api-blurb {
3690
- margin: 0;
4193
+ margin: 0 0 10px;
3691
4194
  font-size: 14px;
3692
- max-width: 62ch;
3693
4195
  }
3694
- /* A resource: its heading stays pinned under the room bar while its rows scroll by. */
4196
+ /* A resource: its heading over a rule, scrolling with its routes. */
3695
4197
  .api-res {
3696
4198
  margin-top: 16px;
3697
4199
  scroll-margin-top: var(--bar-h);
3698
4200
  }
4201
+ /* A resource's heading, set like the page's other headings, flush with the text: it only
4202
+ pins under the bar, its vertical padding the band the rows scroll beneath. */
3699
4203
  .api-res-head {
3700
- position: sticky;
3701
- top: var(--bar-h);
3702
- z-index: var(--z-content);
3703
4204
  display: flex;
3704
4205
  align-items: center;
3705
4206
  gap: 8px;
3706
- padding: 7px 6px;
4207
+ margin: 0;
4208
+ padding: 6px 0;
4209
+ border-bottom: 1px solid var(--line-soft);
3707
4210
  background: var(--bg);
3708
- border-radius: 6px;
3709
4211
  }
3710
4212
 
3711
4213
  .api-res-head h3 {
3712
4214
  margin: 0;
3713
- font-size: 14px;
4215
+ font-size: 20px;
4216
+ font-weight: 650;
4217
+ }
4218
+ /* A Getting started topic is a heading over prose, not a list's head: no rule, a step smaller. */
4219
+ .api-section[data-tier="overview"] .api-res-head {
4220
+ border-bottom: none;
4221
+ }
4222
+ .api-section[data-tier="overview"] .api-res-head h3 {
4223
+ font-size: 18px;
3714
4224
  }
3715
4225
  .api-rows {
3716
4226
  list-style: none;
@@ -3719,46 +4229,8 @@ button.api-filter-clear svg {
3719
4229
  }
3720
4230
  .api-rows > li {
3721
4231
  border-bottom: 1px solid var(--line);
3722
- }
3723
- /* One route per row: badge, path, and summary line, a native disclosure that opens in
3724
- place to the full documentation. */
3725
- .api-route-sum {
3726
- display: grid;
3727
- grid-template-columns: 62px minmax(0, max-content) minmax(0, 1fr) 20px;
3728
- gap: 14px;
3729
- align-items: center;
3730
- padding: 9px 6px;
3731
- border-radius: 6px;
3732
- list-style: none;
3733
- cursor: pointer;
3734
- }
3735
- .api-route-sum::-webkit-details-marker {
3736
- display: none;
3737
- }
3738
- .api-route-sum:hover {
3739
- background: var(--hover);
3740
- }
3741
- .api-route-line {
3742
- color: var(--muted);
3743
- font-size: 13.5px;
3744
- white-space: nowrap;
3745
- overflow: hidden;
3746
- text-overflow: ellipsis;
3747
- }
3748
- .api-route[open] .api-route-line {
3749
- visibility: hidden;
3750
- }
3751
- .api-chev {
3752
- width: 6px;
3753
- height: 6px;
3754
- border-right: 1.6px solid var(--muted);
3755
- border-bottom: 1.6px solid var(--muted);
3756
- justify-self: center;
3757
- transform: rotate(-45deg);
3758
- transition: transform 0.15s;
3759
- }
3760
- .api-route[open] .api-chev {
3761
- transform: rotate(45deg);
4232
+ /* A route reached from its address lands just under the bar, the rule above it hidden. */
4233
+ scroll-margin-top: calc(var(--bar-h) - 1px);
3762
4234
  }
3763
4235
  /* The verb as a tinted label: its own --method-* color as text over a wash of itself. */
3764
4236
  .api-method {
@@ -3782,6 +4254,13 @@ button.api-filter-clear svg {
3782
4254
  .api-method.m-DELETE {
3783
4255
  --m: var(--method-delete);
3784
4256
  }
4257
+ /* An object heads its resource as a row of its own, marked in the verb's column. */
4258
+ .api-method.m-OBJECT {
4259
+ text-transform: uppercase;
4260
+ color: var(--muted);
4261
+ background: var(--card);
4262
+ box-shadow: inset 0 0 0 1px var(--line-soft);
4263
+ }
3785
4264
  .api-path {
3786
4265
  font: 13.5px var(--mono);
3787
4266
  overflow-wrap: anywhere;
@@ -3792,30 +4271,128 @@ button.api-filter-clear svg {
3792
4271
  border-radius: 3px;
3793
4272
  padding: 0 2px;
3794
4273
  }
3795
- .api-route-detail {
3796
- padding: 2px 6px 18px 82px;
3797
- }
3798
4274
  .api-summary {
3799
- margin: 2px 0 0;
3800
- font-size: 14.5px;
4275
+ margin: 0 0 10px;
4276
+ font-size: 15px;
3801
4277
  }
3802
4278
  .api-desc {
3803
- margin: 6px 0 0;
4279
+ margin: 0 0 10px;
3804
4280
  font-size: 14px;
3805
4281
  }
3806
- /* A query parameter the route can't be called without. */
4282
+ /* A field or parameter the request can't do without. */
3807
4283
  .api-req {
3808
- margin-left: 4px;
3809
- font-size: 11px;
3810
- color: var(--muted);
3811
- border: 1px solid var(--line);
3812
- border-radius: 4px;
3813
- padding: 0 4px;
4284
+ font-size: 11.5px;
4285
+ font-weight: 600;
4286
+ color: var(--warn-fg);
3814
4287
  }
3815
4288
 
3816
4289
  .api-empty {
3817
4290
  margin: 20px 0;
3818
4291
  }
4292
+ /* An object, first under the resource whose routes return it: a row like a route's, that
4293
+ opens to its fields and example. */
4294
+ /* A route or an object, laid out in full as Stripe and Mux lay theirs: its name and tools,
4295
+ its method and path, then what it does beside its examples on a wide screen, under them
4296
+ on a narrow one. The examples scroll with the page: pinned, one taller than the window
4297
+ (the send view's) would never show its end. */
4298
+ /* Each route and object its own block, with room around it so the page reads as one at a time. */
4299
+ .api-op {
4300
+ padding: 36px 0 40px;
4301
+ }
4302
+ .api-op-head {
4303
+ display: flex;
4304
+ flex-wrap: wrap;
4305
+ align-items: baseline;
4306
+ justify-content: space-between;
4307
+ gap: 8px 16px;
4308
+ }
4309
+ .api-op-head h4 {
4310
+ margin: 0;
4311
+ font-size: 18px;
4312
+ font-weight: 650;
4313
+ }
4314
+ .api-op-head h4:focus {
4315
+ outline: none;
4316
+ }
4317
+ /* A heading is a link to its own address, in the heading's own ink, with a link icon in the
4318
+ margin to its left while it is hovered or focused, as GitHub's headings show theirs. */
4319
+ a.api-self {
4320
+ position: relative;
4321
+ color: inherit;
4322
+ text-decoration: none;
4323
+ }
4324
+ a.api-self svg {
4325
+ position: absolute;
4326
+ left: -20px;
4327
+ top: 50%;
4328
+ width: 14px;
4329
+ height: 14px;
4330
+ transform: translateY(-50%);
4331
+ color: var(--muted);
4332
+ opacity: 0;
4333
+ }
4334
+ a.api-self:hover svg,
4335
+ a.api-self:focus-visible svg {
4336
+ opacity: 1;
4337
+ }
4338
+ .api-op-call {
4339
+ display: flex;
4340
+ align-items: center;
4341
+ gap: 8px;
4342
+ margin: 6px 0 10px;
4343
+ }
4344
+ .api-op-call .api-method {
4345
+ padding: 0 6px;
4346
+ }
4347
+ /* Copy sits right after the path it copies, an icon alone, so the two read as one. */
4348
+ button.copy-btn.api-copy-path {
4349
+ padding: 3px 5px;
4350
+ }
4351
+ button.copy-btn.api-copy-path span:empty {
4352
+ display: none;
4353
+ }
4354
+ .api-op-body {
4355
+ display: grid;
4356
+ gap: 4px 28px;
4357
+ }
4358
+ .api-op-text,
4359
+ .api-op-code {
4360
+ min-width: 0;
4361
+ }
4362
+ @media (min-width: 1280px) {
4363
+ .api-op-body {
4364
+ max-width: none;
4365
+ /* The examples take what the window has beyond the prose's measure, never under 360px. */
4366
+ grid-template-columns: minmax(0, 720px) minmax(360px, 1fr);
4367
+ }
4368
+ .api-op-code {
4369
+ align-self: start;
4370
+ }
4371
+ .api-op-code > .block:first-child {
4372
+ margin-top: 0;
4373
+ }
4374
+ }
4375
+ /* The rail lists every route by name under its resource, so it reads as the page's contents. */
4376
+ .api-nav-ops {
4377
+ display: none;
4378
+ }
4379
+ .api-nav-ops.open,
4380
+ .api-nav.filtering .api-nav-ops {
4381
+ display: flex;
4382
+ flex-direction: column;
4383
+ }
4384
+ .api-nav a.api-nav-op {
4385
+ padding-left: 30px;
4386
+ font-size: 13px;
4387
+ }
4388
+ .api-nav-tier + .api-nav-ops a.api-nav-op {
4389
+ padding-left: 20px;
4390
+ }
4391
+ .api-object {
4392
+ border-bottom: 1px solid var(--line);
4393
+ /* Reached from a route's Returns, it lands just under the bar. */
4394
+ scroll-margin-top: calc(var(--bar-h) - 1px);
4395
+ }
3819
4396
  /* Reference room on mobile. The bar stays ONE row at --bar-h, the same as on desktop, so
3820
4397
  the in-page anchors (scroll-margin-top) and the scroll-spy line keep clearing it: the back
3821
4398
  link is a --bar-h square holding its arrow, the wordmark and the two tabs follow, and the
@@ -3930,11 +4507,13 @@ button.api-filter-clear svg {
3930
4507
  font-size: 13px;
3931
4508
  }
3932
4509
  /* The API rail is the tier chips alone on a phone: its resources need the column the
3933
- rail no longer has. A route row stacks its summary under its path. */
4510
+ rail no longer has. */
3934
4511
  .api-filter {
3935
4512
  max-width: none;
3936
4513
  }
3937
- .api-nav a.api-nav-res {
4514
+ .api-nav a.api-nav-res,
4515
+ .api-nav .api-nav-ops,
4516
+ .api-nav.filtering .api-nav-ops {
3938
4517
  display: none;
3939
4518
  }
3940
4519
  .api-nav a.api-nav-tier {
@@ -3943,25 +4522,6 @@ button.api-filter-clear svg {
3943
4522
  .api-nav a.api-nav-tier ~ a.api-nav-tier {
3944
4523
  margin-top: 0;
3945
4524
  }
3946
- .api-route-sum {
3947
- grid-template-columns: 62px minmax(0, 1fr) 20px;
3948
- gap: 4px 10px;
3949
- }
3950
- .api-route-line {
3951
- grid-column: 2;
3952
- grid-row: 2;
3953
- white-space: normal;
3954
- }
3955
- .api-chev {
3956
- grid-column: 3;
3957
- grid-row: 1;
3958
- }
3959
- .api-route[open] .api-route-line {
3960
- display: none;
3961
- }
3962
- .api-route-detail {
3963
- padding: 0 4px 14px;
3964
- }
3965
4525
  /* inline-flex centres the chip's text in its 32px; the gap stands in for the space
3966
4526
  between the label and its ↗ that flex drops. */
3967
4527
  .rail-link {
@@ -4209,7 +4769,8 @@ button.api-filter-clear svg {
4209
4769
  gap: 10px;
4210
4770
  }
4211
4771
 
4212
- /* Setup checklist — shared by the first-run dashboard and Getting started. */
4772
+ /* Setup steps: the first-run checklist, and the regular dashboard's Finish setting up card
4773
+ (one component, two layouts). The card is the compact one. */
4213
4774
  .setup-title {
4214
4775
  margin-top: 0;
4215
4776
  }
@@ -4282,6 +4843,20 @@ button.api-filter-clear svg {
4282
4843
  color: var(--muted);
4283
4844
  word-break: break-all;
4284
4845
  }
4846
+ .setup-card {
4847
+ margin-bottom: 20px;
4848
+ }
4849
+ .setup-card .setup-title {
4850
+ font-size: 16px;
4851
+ }
4852
+ .setup-card .setup-steps li {
4853
+ padding: 8px 2px;
4854
+ }
4855
+ .setup-card .setup-steps li::before {
4856
+ width: 22px;
4857
+ height: 22px;
4858
+ font-size: 12px;
4859
+ }
4285
4860
 
4286
4861
  /* ============================================================
4287
4862
  Settings page. Grouped by intent, made visible: each section
@@ -4365,12 +4940,22 @@ button.copied svg {
4365
4940
  font-weight: 600;
4366
4941
  color: var(--muted);
4367
4942
  }
4368
- .block pre {
4943
+ /* A code block (an example, a curl command, a template): a raised card with its label and Copy
4944
+ in a header inside it, so the code stands apart from the page in both themes. */
4945
+ .code-block {
4946
+ background: var(--card);
4947
+ border: 1px solid var(--line);
4948
+ border-radius: 8px;
4949
+ overflow: hidden;
4950
+ }
4951
+ .code-block > .block-head {
4952
+ margin: 0;
4953
+ padding: 4px 6px 4px 12px;
4954
+ border-bottom: 1px solid var(--line);
4955
+ }
4956
+ .code-block pre {
4369
4957
  margin: 0;
4370
4958
  overflow-x: auto;
4371
- background: var(--bg);
4372
- border: 1px solid var(--line);
4373
- border-radius: 6px;
4374
4959
  padding: 10px 12px;
4375
4960
  font: 12.5px / 1.5 var(--mono);
4376
4961
  }
@@ -4378,6 +4963,17 @@ button.copied svg {
4378
4963
  .pub-row button.copy-btn {
4379
4964
  flex: 0 0 auto;
4380
4965
  }
4966
+ /* In a sent post's link field, Copy is a segment of the field like Open: square, full height,
4967
+ and divided from the address, so its hover fills the segment rather than a chip inside it. */
4968
+ .rec-link button.copy-btn {
4969
+ padding: 0 11px;
4970
+ border-left: 1px solid var(--line);
4971
+ border-radius: 0;
4972
+ background: var(--card);
4973
+ }
4974
+ .rec-link button.copy-btn:hover {
4975
+ background: var(--hover);
4976
+ }
4381
4977
 
4382
4978
  /* --- groups: the sections kept together by whom they serve (DESIGN §3). A group has no
4383
4979
  heading, since each section's title already says what it is; a wider gap than the one
@@ -6135,7 +6731,7 @@ body.has-savebar .content {
6135
6731
  .subs-wrap {
6136
6732
  container-type: inline-size;
6137
6733
  }
6138
- @container (max-width: 759px) {
6734
+ @container (max-width: 609px) {
6139
6735
  .list-table.subs-table.stacks,
6140
6736
  .list-table.subs-table.stacks tbody,
6141
6737
  .list-table.subs-table.stacks tr {
@@ -6176,10 +6772,7 @@ body.has-savebar .content {
6176
6772
  .list-table.subs-table.stacks td.empty {
6177
6773
  display: none;
6178
6774
  }
6179
- /* Subscribers: status pill · how they joined · joined <date>. */
6180
- .subs-table td.how::before {
6181
- content: " · ";
6182
- }
6775
+ /* Subscribers: status pill · joined <date>. */
6183
6776
  .subs-table td.joined::before {
6184
6777
  content: " · joined ";
6185
6778
  }
@@ -6307,8 +6900,10 @@ body.has-savebar .content {
6307
6900
  font-weight: 600;
6308
6901
  margin: 14px 0 4px;
6309
6902
  }
6310
- /* The same amber box the Schedule dialog gives D1's daily-writes warning (DESIGN §4). */
6311
- .hint.daily-writes.imp-warn {
6903
+ /* On Workers Free's limits, the advice to move to Workers Paid on a list past what Free suits,
6904
+ on the Dashboard, the Subscribers screen, and an import's plan (DESIGN §4): the same amber
6905
+ box the Schedule dialog gives D1's daily-writes warning. */
6906
+ .workers-plan {
6312
6907
  color: var(--warn-fg);
6313
6908
  background: var(--warn-bg);
6314
6909
  border: 1px solid var(--warn-line);
@@ -6349,9 +6944,73 @@ body.has-savebar .content {
6349
6944
  gap: 14px;
6350
6945
  margin-top: 18px;
6351
6946
  }
6352
- /* View subscriber's title is the address, so a long one breaks rather than widens the dialog. */
6947
+ /* A subscriber's page is titled with the address, so a long one breaks rather than widens
6948
+ the head; the status pill and the flag sit beside it, as the roster draws them. */
6353
6949
  .sub-title {
6354
6950
  overflow-wrap: anywhere;
6951
+ margin: 0;
6952
+ }
6953
+ .sub-page-title {
6954
+ display: flex;
6955
+ align-items: center;
6956
+ gap: 12px;
6957
+ flex-wrap: wrap;
6958
+ min-width: 0;
6959
+ }
6960
+ .sub-page-title .sub-pills {
6961
+ white-space: nowrap;
6962
+ }
6963
+ /* Whether they receive posts: the page's first answer, under the head, in the record's
6964
+ meta-line place; muted, with a green dot only when they do, as a confirmed pill is. */
6965
+ .sub-receives {
6966
+ margin: -6px 0 14px;
6967
+ font-size: 13.5px;
6968
+ color: var(--muted);
6969
+ }
6970
+ .sub-receives.yes::before {
6971
+ content: "";
6972
+ display: inline-block;
6973
+ width: 8px;
6974
+ height: 8px;
6975
+ margin-right: 7px;
6976
+ border-radius: 50%;
6977
+ background: var(--ok-fg);
6978
+ }
6979
+ /* The import note: the card's own small print, outside a dialog's hint styles. */
6980
+ .sub-note {
6981
+ margin: 10px 0 0;
6982
+ font-size: 12.5px;
6983
+ color: var(--muted);
6984
+ }
6985
+ .sub-sec .sub-meaning {
6986
+ margin: 8px 0;
6987
+ font-size: 13.5px;
6988
+ }
6989
+ .sub-sec p:last-child {
6990
+ margin-bottom: 0;
6991
+ }
6992
+ .sub-sup-line,
6993
+ .sub-left-line {
6994
+ margin: 0;
6995
+ }
6996
+ /* Sends is a table, and a table is already a card, so its section is no card of its own:
6997
+ the label sits on the page above it, as the dashboard's sections do. */
6998
+ .sub-sends {
6999
+ margin-top: 22px;
7000
+ }
7001
+ .sub-sends-table .c-out {
7002
+ width: 160px;
7003
+ }
7004
+ .sub-sends-table .sub-send a {
7005
+ font-weight: 500;
7006
+ }
7007
+ .sub-more {
7008
+ margin-top: 12px;
7009
+ }
7010
+ /* The roster's address is its row's link, as a Sent row's subject is, in the text's color
7011
+ since the whole row is the click target. */
7012
+ .subs-table a.sub-email {
7013
+ color: inherit;
6355
7014
  }
6356
7015
  .imp-dl,
6357
7016
  .sub-dl {