@kurtbruns/kestrel 1.12.0 → 1.14.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 (45) hide show
  1. package/CHANGELOG.md +137 -1
  2. package/README.md +1 -1
  3. package/bin/kestrel.mjs +17 -2
  4. package/cli/api.mjs +462 -0
  5. package/cli/credentials.mjs +192 -0
  6. package/cli/dev-api.mjs +17 -11
  7. package/cli/login.mjs +155 -0
  8. package/cli/oauth.mjs +368 -0
  9. package/cli/openapi.mjs +569 -0
  10. package/cli/seed.mjs +1 -1
  11. package/dist/public/dashboard/app.js +350 -295
  12. package/dist/public/dashboard/app.js.map +4 -4
  13. package/dist/public/dashboard/index.html +2 -2
  14. package/dist/public/dashboard/styles.css +466 -153
  15. package/dist/worker/index.js +10634 -5773
  16. package/docs/README.md +3 -2
  17. package/docs/get-started/02-deploy.md +18 -21
  18. package/docs/get-started/03-access.md +10 -8
  19. package/docs/get-started/04-ses.md +2 -2
  20. package/docs/get-started/05-verify.md +4 -4
  21. package/docs/get-started/06-go-live.md +3 -2
  22. package/docs/guides/01-allow-claude.md +68 -0
  23. package/docs/guides/02-automate.md +88 -0
  24. package/docs/guides/09-as-your-list-grows.md +8 -4
  25. package/docs/publishing/01-your-list.md +38 -33
  26. package/docs/publishing/02-sending-rules.md +3 -3
  27. package/docs/publishing/03-name-your-publication.md +1 -1
  28. package/docs/publishing/04-write-a-post.md +15 -52
  29. package/docs/publishing/06-troubleshooting.md +30 -14
  30. package/docs/publishing/08-connect-claude.md +60 -0
  31. package/docs/publishing/09-share-your-subscribe-link.md +3 -3
  32. package/docs/publishing/10-check-your-setup.md +9 -16
  33. package/docs/publishing/11-email-template.md +3 -1
  34. package/docs/publishing/README.md +2 -2
  35. package/docs/reference/01-configuration.md +132 -85
  36. package/docs/reference/02-sending-domain-dns.md +2 -2
  37. package/migrations/0012_unsubscribe_cause.sql +139 -0
  38. package/migrations/0013_who_acted_client.sql +20 -0
  39. package/migrations/0014_send_acts.sql +25 -0
  40. package/package.json +1 -1
  41. package/template/README.md +2 -0
  42. package/template/claude/CLAUDE.md +18 -14
  43. package/template/claude/skills/migrate-list/SKILL.md +5 -4
  44. package/docs/guides/01-connect-claude.md +0 -98
  45. package/docs/publishing/07-working-with-claude.md +0 -30
@@ -88,6 +88,9 @@
88
88
  /* Code surface — inline `code` chip and the fenced-block band. A hair off the editor well
89
89
  (--syntax-bg), lighter than the neutral --chip, so code reads as code without a heavy box. */
90
90
  --syntax-code-bg: #f2f2f4;
91
+ /* A doc's inline `code`: a maroon ink on the --chip surface, so a name in a sentence reads
92
+ apart from the prose around it and from a link's blue. */
93
+ --code-inline-fg: #9b2c2c;
91
94
  /* HTTP method badges in the API reference (DESIGN §8). A self-contained palette like
92
95
  --syntax-*: verb colors are a naming convention, not feedback or lifecycle, so they
93
96
  borrow no semantic scale. The badge tints each over a wash of itself (.api-method). */
@@ -161,6 +164,7 @@
161
164
  --syntax-emphasis: #f472b6;
162
165
  --syntax-quote: #f0a86a;
163
166
  --syntax-code-bg: #26262b;
167
+ --code-inline-fg: #f07a7a;
164
168
  --method-get: #4ade80;
165
169
  --method-post: #60a5fa;
166
170
  --method-put: #fbbf24;
@@ -1854,8 +1858,10 @@ td a:hover {
1854
1858
  .dash-drafts {
1855
1859
  table-layout: fixed;
1856
1860
  }
1861
+ /* Wide enough for the longest "edited <date>", a two-digit day and hour included, with the
1862
+ cell's padding: the fixed layout clips anything wider rather than growing the column. */
1857
1863
  .dash-drafts td.edited {
1858
- width: 11.5em;
1864
+ width: 15em;
1859
1865
  white-space: nowrap;
1860
1866
  }
1861
1867
  @media (min-width: 721px) {
@@ -1867,13 +1873,15 @@ td a:hover {
1867
1873
  .list-table .c-act {
1868
1874
  width: 56px;
1869
1875
  }
1870
- /* Subscribers: how each joined ("Imported · Sendy, single opt-in"), which may wrap to two
1871
- lines; the table takes a wider floor so the email column keeps room beside it. */
1872
- .list-table .c-how {
1873
- width: 152px;
1874
- }
1876
+ /* Subscribers: a floor that keeps the email column about 250px beside its two others. With
1877
+ only Status and Joined beside an address, which rarely needs more than half the row, fixed
1878
+ widths left one wide gap before them; a share of the row each spreads the space evenly. */
1875
1879
  .list-table.subs-table {
1876
- min-width: 680px;
1880
+ min-width: 610px;
1881
+ }
1882
+ .list-table.subs-table .c-status,
1883
+ .list-table.subs-table .c-date {
1884
+ width: 24%;
1877
1885
  }
1878
1886
 
1879
1887
  .dash-section table {
@@ -1934,20 +1942,143 @@ th.num .th-sort {
1934
1942
  cursor: default;
1935
1943
  }
1936
1944
 
1937
- /* API reference: query-param table inside a route card */
1938
- .api-query {
1945
+ /* A route's or an object's sections (query, body, returns, refusals, fields) as Stripe's and
1946
+ Mux's references set theirs: a heading over a rule, then unboxed rows, each with the name,
1947
+ its type, and Required on one line and what it means under it, so a long name never
1948
+ squeezes its description. A name breaks only where its words join (the <wbr> the page puts
1949
+ after each _ . /). */
1950
+ .block.api-sec {
1951
+ margin-top: 22px;
1952
+ }
1953
+ .api-sec > .block-head {
1939
1954
  margin: 0;
1955
+ padding-bottom: 8px;
1956
+ border-bottom: 1px solid var(--line-soft);
1940
1957
  }
1941
- .api-query td {
1942
- padding: 5px 10px;
1958
+ .api-sec > .block-head .block-label {
1959
+ font-size: 15px;
1960
+ font-weight: 600;
1961
+ color: var(--fg);
1962
+ }
1963
+ .api-sec-meta {
1964
+ margin-left: 2px;
1965
+ font: 400 12px var(--mono);
1966
+ color: var(--muted);
1967
+ }
1968
+ /* Rules divide rows; the last row in a section has none under it. */
1969
+ .api-row,
1970
+ .api-lrow {
1971
+ padding: 11px 0;
1972
+ border-bottom: 1px solid var(--line);
1973
+ }
1974
+ .api-row:last-child,
1975
+ .api-lrow:last-child {
1976
+ border-bottom: 0;
1977
+ }
1978
+ .api-row-head {
1979
+ display: flex;
1980
+ align-items: baseline;
1981
+ flex-wrap: wrap;
1982
+ gap: 8px;
1983
+ }
1984
+ code.api-row-name {
1943
1985
  font-size: 13px;
1944
- vertical-align: top;
1986
+ font-weight: 600;
1987
+ overflow-wrap: break-word;
1945
1988
  }
1946
- .api-query td:first-child {
1947
- white-space: nowrap;
1948
- width: 1%;
1989
+ .api-row-type {
1990
+ font: 12px var(--mono);
1991
+ color: var(--muted);
1992
+ }
1993
+ /* A type that names an object links to it, and reads as a link. */
1994
+ .api-row-type a {
1995
+ color: var(--fg);
1996
+ text-decoration: underline;
1997
+ text-underline-offset: 2px;
1998
+ }
1999
+ .api-row-desc {
2000
+ margin-top: 3px;
2001
+ font-size: 13.5px;
2002
+ }
2003
+ /* The refusals and the statuses are a ledger: the status in a column of its own, tinted by
2004
+ its class, so a reader scans by status. */
2005
+ .api-lrow {
2006
+ display: grid;
2007
+ grid-template-columns: 44px minmax(0, 1fr);
2008
+ gap: 6px;
2009
+ }
2010
+ .api-st {
2011
+ font: 600 12.5px / 20px var(--mono);
2012
+ color: var(--muted);
2013
+ }
2014
+ .api-st-4xx {
2015
+ color: var(--warn-fg);
2016
+ }
2017
+ .api-st-5xx {
2018
+ color: var(--danger-fg);
2019
+ }
2020
+ .api-st-2xx {
2021
+ color: var(--ok-fg);
2022
+ }
2023
+ .api-with {
2024
+ margin-top: 2px;
2025
+ font-size: 12px;
2026
+ color: var(--muted);
2027
+ }
2028
+ /* A status's name is words, not a code. */
2029
+ .api-statuses code.api-row-name {
2030
+ font-family: inherit;
2031
+ }
2032
+ /* A value's short cells beside its name (a condition kind's severity and fields), each led by
2033
+ its column's name, and the label of a later long line ("Remedy:"). */
2034
+ .api-row-meta {
2035
+ font-size: 12.5px;
2036
+ color: var(--muted);
2037
+ }
2038
+ .api-row-key {
2039
+ font-size: 12px;
2040
+ font-weight: 600;
2041
+ color: var(--muted);
2042
+ }
2043
+ /* What every route shares, described once ahead of the routes: concept prose, an object's
2044
+ description, an enum's values under a field's line, and the refusal table's next moves. */
2045
+ /* The reference's paragraphs, each with space below it, as a doc page's are. */
2046
+ .api-prose {
2047
+ margin: 0 0 10px;
2048
+ font-size: 14px;
2049
+ }
2050
+ /* A list in the reference's prose (the status ranges under Requests), set like its paragraphs. */
2051
+ .prose-list {
2052
+ padding-left: 20px;
2053
+ }
2054
+ .prose-list li {
2055
+ margin: 6px 0;
2056
+ }
2057
+ .api-values {
2058
+ margin: 4px 0 0;
2059
+ padding-left: 16px;
2060
+ }
2061
+ .api-values li {
2062
+ margin: 2px 0;
2063
+ }
2064
+ /* The name of a group an enum's values fall into, over its bullets. */
2065
+ .api-values-group {
2066
+ margin: 8px 0 0;
2067
+ font-size: 12.5px;
2068
+ font-weight: 600;
2069
+ color: var(--muted);
2070
+ }
2071
+ .api-returns {
2072
+ margin: 4px 0 6px;
2073
+ font-size: 13.5px;
2074
+ }
2075
+ /* A refusal's next move, under what it means: quieter, the step a client takes from it. */
2076
+ .api-move-next {
2077
+ display: block;
2078
+ margin-top: 2px;
2079
+ font-size: 12.5px;
2080
+ color: var(--muted);
1949
2081
  }
1950
-
1951
2082
  /* editor header */
1952
2083
  .editor-head {
1953
2084
  display: flex;
@@ -2075,6 +2206,10 @@ body.web-mode .content {
2075
2206
  gap: 8px;
2076
2207
  flex-wrap: wrap;
2077
2208
  }
2209
+ /* Who acted on the send, a line under when it was sent (SPEC §11). */
2210
+ .rec-by {
2211
+ margin-top: 2px;
2212
+ }
2078
2213
  /* Names the living layer of the record: the outcome tiles below keep moving as receipts
2079
2214
  arrive, while the audience at fire and the published post are fixed (SPEC §8). */
2080
2215
  .rec-tiles-cap {
@@ -2242,7 +2377,7 @@ body.web-mode .content {
2242
2377
  font-size: 22px;
2243
2378
  letter-spacing: -0.01em;
2244
2379
  }
2245
- /* The derived-phase pill (progressing / retrying / backing-off / needs-attention /
2380
+ /* The derived-phase pill (progressing / retrying / backing_off / needs_attention /
2246
2381
  settling / …). Tones reuse the status + semantic palettes so a phase reads the same
2247
2382
  hue as its badge elsewhere. A plain label, so the default cursor (DESIGN §11). */
2248
2383
  .phase-pill {
@@ -2801,6 +2936,14 @@ body.web-mode .content {
2801
2936
  each state too, so the cue survives a reader who can't tell the dot's color. The
2802
2937
  "unsaved" cue reads the same amber as the shared save bar so both save models feel
2803
2938
  like one app. :empty (locked posts, which can't autosave) collapses it out. */
2939
+ /* A scheduled send never tested, said quietly beside the control that settles it (SPEC §8). */
2940
+ .test-hint {
2941
+ font-size: 13px;
2942
+ color: var(--muted);
2943
+ }
2944
+ .test-row {
2945
+ gap: 1rem;
2946
+ }
2804
2947
  .save-status {
2805
2948
  display: inline-flex;
2806
2949
  align-items: center;
@@ -3328,6 +3471,13 @@ textarea.is-invalid {
3328
3471
  font-size: 12.5px;
3329
3472
  color: var(--status-scheduled-fg);
3330
3473
  }
3474
+ /* Who scheduled the send and who last moved it (SPEC §11), under the time, in the banner's
3475
+ violet: a fact about the send, not a note asking for a look. */
3476
+ .sched-by {
3477
+ margin-top: 4px;
3478
+ font-size: 12.5px;
3479
+ color: var(--status-scheduled-fg);
3480
+ }
3331
3481
  /* The Email only box under a dialog's time: the label, then its helper beneath. */
3332
3482
  .listing-check {
3333
3483
  display: flex;
@@ -3782,52 +3932,28 @@ textarea.is-invalid {
3782
3932
  .doc code {
3783
3933
  font-family: var(--mono);
3784
3934
  font-size: 13px;
3785
- background: var(--bg);
3935
+ color: var(--code-inline-fg);
3936
+ background: var(--chip);
3786
3937
  border: 1px solid var(--line);
3787
3938
  border-radius: 4px;
3788
3939
  padding: 1px 5px;
3789
3940
  }
3790
- .doc pre {
3791
- position: relative;
3792
- background: var(--bg);
3793
- border: 1px solid var(--line);
3794
- border-radius: 8px;
3795
- padding: 14px 16px;
3796
- overflow-x: auto;
3797
- line-height: 1.5;
3941
+ /* Bold in a doc sits between semibold and bold, the weight of its headings: the browser's
3942
+ `bolder` makes it 700, which outweighs the headings (650) on the system font. */
3943
+ .doc strong {
3944
+ font-weight: 650;
3945
+ }
3946
+ /* Code in a bold lead-in keeps its own weight: the chip already sets it apart. */
3947
+ .doc strong code {
3948
+ font-weight: normal;
3798
3949
  }
3950
+ /* The inline-code chip above stays off the code in a block. */
3799
3951
  .doc pre code {
3952
+ color: inherit;
3800
3953
  background: none;
3801
3954
  border: 0;
3802
3955
  padding: 0;
3803
- }
3804
- /* Copy button on code blocks — revealed on hover (always shown on touch). */
3805
- .code-copy {
3806
- position: absolute;
3807
- top: 8px;
3808
- right: 8px;
3809
- font: 11px var(--mono);
3810
- padding: 3px 8px;
3811
- border: 1px solid var(--line);
3812
- border-radius: 5px;
3813
- background: var(--card);
3814
- color: var(--muted);
3815
- cursor: pointer;
3816
- opacity: 0;
3817
- transition: opacity 0.12s;
3818
- }
3819
- .doc pre:hover .code-copy,
3820
- .code-copy:focus-visible {
3821
- opacity: 1;
3822
- }
3823
- .code-copy:hover {
3824
- color: var(--fg);
3825
- border-color: var(--muted);
3826
- }
3827
- @media (hover: none) {
3828
- .code-copy {
3829
- opacity: 1;
3830
- }
3956
+ font-size: inherit;
3831
3957
  }
3832
3958
  /* Previous / Next pager at the foot of a doc — the sequential path, with titles. Two
3833
3959
  bordered cards; a missing neighbour keeps its side empty so the lone card stays put. */
@@ -4016,16 +4142,27 @@ button.api-filter-clear svg {
4016
4142
  color: var(--fg);
4017
4143
  background: var(--hover);
4018
4144
  }
4145
+ /* The reference reads as a document: a doc page's line height, not the browser's default. */
4019
4146
  .api-content {
4020
4147
  min-width: 0;
4021
- max-width: 820px;
4148
+ max-width: 1440px;
4149
+ line-height: 1.5;
4150
+ }
4151
+ /* One reading measure for the page, a doc page's (720px): the intro, the topics, and each
4152
+ route's and object's prose and tables keep it, and only a route's examples, beside its
4153
+ prose on a wide screen, reach past it. */
4154
+ .api-head,
4155
+ .api-blurb,
4156
+ .api-section[data-tier="overview"] .api-res,
4157
+ .api-op-body {
4158
+ max-width: 720px;
4022
4159
  }
4023
4160
  .api-head {
4024
4161
  margin: 0 0 12px;
4025
4162
  }
4026
4163
  .api-head h1 {
4027
4164
  margin: 0 0 4px;
4028
- font-size: 22px;
4165
+ font-size: 28px;
4029
4166
  }
4030
4167
  .api-section {
4031
4168
  scroll-margin-top: calc(var(--bar-h) + 2px);
@@ -4036,35 +4173,53 @@ button.api-filter-clear svg {
4036
4173
  .api-section.api-last {
4037
4174
  min-height: calc(100vh - var(--bar-h));
4038
4175
  }
4176
+ /* A tier is a page heading here, not the app's small-caps section label that h2 is elsewhere. */
4039
4177
  .api-section h2 {
4040
- font-size: 18px;
4041
- margin: 24px 0 4px;
4178
+ font-size: 24px;
4179
+ font-weight: 650;
4180
+ color: var(--fg);
4181
+ text-transform: none;
4182
+ letter-spacing: normal;
4183
+ margin: 28px 0 4px;
4184
+ }
4185
+ /* Where a jump from the rail or an address moves focus: a place to read from, not a control,
4186
+ so it takes no ring. */
4187
+ .api-section h2:focus,
4188
+ .api-res-head h3:focus {
4189
+ outline: none;
4042
4190
  }
4043
4191
  .api-blurb {
4044
- margin: 0;
4192
+ margin: 0 0 10px;
4045
4193
  font-size: 14px;
4046
- max-width: 62ch;
4047
4194
  }
4048
- /* A resource: its heading stays pinned under the room bar while its rows scroll by. */
4195
+ /* A resource: its heading over a rule, scrolling with its routes. */
4049
4196
  .api-res {
4050
4197
  margin-top: 16px;
4051
4198
  scroll-margin-top: var(--bar-h);
4052
4199
  }
4200
+ /* A resource's heading, set like the page's other headings, flush with the text: it only
4201
+ pins under the bar, its vertical padding the band the rows scroll beneath. */
4053
4202
  .api-res-head {
4054
- position: sticky;
4055
- top: var(--bar-h);
4056
- z-index: var(--z-content);
4057
4203
  display: flex;
4058
4204
  align-items: center;
4059
4205
  gap: 8px;
4060
- padding: 7px 6px;
4206
+ margin: 0;
4207
+ padding: 6px 0;
4208
+ border-bottom: 1px solid var(--line-soft);
4061
4209
  background: var(--bg);
4062
- border-radius: 6px;
4063
4210
  }
4064
4211
 
4065
4212
  .api-res-head h3 {
4066
4213
  margin: 0;
4067
- font-size: 14px;
4214
+ font-size: 20px;
4215
+ font-weight: 650;
4216
+ }
4217
+ /* A Getting started topic is a heading over prose, not a list's head: no rule, a step smaller. */
4218
+ .api-section[data-tier="overview"] .api-res-head {
4219
+ border-bottom: none;
4220
+ }
4221
+ .api-section[data-tier="overview"] .api-res-head h3 {
4222
+ font-size: 18px;
4068
4223
  }
4069
4224
  .api-rows {
4070
4225
  list-style: none;
@@ -4073,46 +4228,8 @@ button.api-filter-clear svg {
4073
4228
  }
4074
4229
  .api-rows > li {
4075
4230
  border-bottom: 1px solid var(--line);
4076
- }
4077
- /* One route per row: badge, path, and summary line, a native disclosure that opens in
4078
- place to the full documentation. */
4079
- .api-route-sum {
4080
- display: grid;
4081
- grid-template-columns: 62px minmax(0, max-content) minmax(0, 1fr) 20px;
4082
- gap: 14px;
4083
- align-items: center;
4084
- padding: 9px 6px;
4085
- border-radius: 6px;
4086
- list-style: none;
4087
- cursor: pointer;
4088
- }
4089
- .api-route-sum::-webkit-details-marker {
4090
- display: none;
4091
- }
4092
- .api-route-sum:hover {
4093
- background: var(--hover);
4094
- }
4095
- .api-route-line {
4096
- color: var(--muted);
4097
- font-size: 13.5px;
4098
- white-space: nowrap;
4099
- overflow: hidden;
4100
- text-overflow: ellipsis;
4101
- }
4102
- .api-route[open] .api-route-line {
4103
- visibility: hidden;
4104
- }
4105
- .api-chev {
4106
- width: 6px;
4107
- height: 6px;
4108
- border-right: 1.6px solid var(--muted);
4109
- border-bottom: 1.6px solid var(--muted);
4110
- justify-self: center;
4111
- transform: rotate(-45deg);
4112
- transition: transform 0.15s;
4113
- }
4114
- .api-route[open] .api-chev {
4115
- transform: rotate(45deg);
4231
+ /* A route reached from its address lands just under the bar, the rule above it hidden. */
4232
+ scroll-margin-top: calc(var(--bar-h) - 1px);
4116
4233
  }
4117
4234
  /* The verb as a tinted label: its own --method-* color as text over a wash of itself. */
4118
4235
  .api-method {
@@ -4136,6 +4253,13 @@ button.api-filter-clear svg {
4136
4253
  .api-method.m-DELETE {
4137
4254
  --m: var(--method-delete);
4138
4255
  }
4256
+ /* An object heads its resource as a row of its own, marked in the verb's column. */
4257
+ .api-method.m-OBJECT {
4258
+ text-transform: uppercase;
4259
+ color: var(--muted);
4260
+ background: var(--card);
4261
+ box-shadow: inset 0 0 0 1px var(--line-soft);
4262
+ }
4139
4263
  .api-path {
4140
4264
  font: 13.5px var(--mono);
4141
4265
  overflow-wrap: anywhere;
@@ -4146,30 +4270,126 @@ button.api-filter-clear svg {
4146
4270
  border-radius: 3px;
4147
4271
  padding: 0 2px;
4148
4272
  }
4149
- .api-route-detail {
4150
- padding: 2px 6px 18px 82px;
4151
- }
4152
4273
  .api-summary {
4153
- margin: 2px 0 0;
4154
- font-size: 14.5px;
4274
+ margin: 0 0 10px;
4275
+ font-size: 15px;
4155
4276
  }
4156
4277
  .api-desc {
4157
- margin: 6px 0 0;
4278
+ margin: 0 0 10px;
4158
4279
  font-size: 14px;
4159
4280
  }
4160
- /* A query parameter the route can't be called without. */
4281
+ /* A field or parameter the request can't do without. */
4161
4282
  .api-req {
4162
- margin-left: 4px;
4163
- font-size: 11px;
4164
- color: var(--muted);
4165
- border: 1px solid var(--line);
4166
- border-radius: 4px;
4167
- padding: 0 4px;
4283
+ font-size: 11.5px;
4284
+ font-weight: 600;
4285
+ color: var(--warn-fg);
4168
4286
  }
4169
4287
 
4170
4288
  .api-empty {
4171
4289
  margin: 20px 0;
4172
4290
  }
4291
+ /* An object, first under the resource whose routes return it: a row like a route's, that
4292
+ opens to its fields and example. */
4293
+ /* A route or an object, laid out in full as Stripe and Mux lay theirs: its name and tools,
4294
+ its method and path, then what it does beside its examples on a wide screen, under them
4295
+ on a narrow one. The examples scroll with the page: pinned, one taller than the window
4296
+ (the send view's) would never show its end. */
4297
+ /* Each route and object its own block, with room around it so the page reads as one at a time. */
4298
+ .api-op {
4299
+ padding: 36px 0 40px;
4300
+ }
4301
+ .api-op-head {
4302
+ display: flex;
4303
+ flex-wrap: wrap;
4304
+ align-items: baseline;
4305
+ justify-content: space-between;
4306
+ gap: 8px 16px;
4307
+ }
4308
+ .api-op-head h4 {
4309
+ margin: 0;
4310
+ font-size: 18px;
4311
+ font-weight: 650;
4312
+ }
4313
+ .api-op-head h4:focus {
4314
+ outline: none;
4315
+ }
4316
+ /* A heading is a link to its own address, in the heading's own ink, with a link icon in the
4317
+ margin to its left while it is hovered or focused, as GitHub's headings show theirs. */
4318
+ a.api-self {
4319
+ position: relative;
4320
+ color: inherit;
4321
+ text-decoration: none;
4322
+ }
4323
+ a.api-self svg {
4324
+ position: absolute;
4325
+ left: -20px;
4326
+ top: 50%;
4327
+ width: 14px;
4328
+ height: 14px;
4329
+ transform: translateY(-50%);
4330
+ color: var(--muted);
4331
+ opacity: 0;
4332
+ }
4333
+ a.api-self:hover svg,
4334
+ a.api-self:focus-visible svg {
4335
+ opacity: 1;
4336
+ }
4337
+ .api-op-call {
4338
+ display: flex;
4339
+ align-items: center;
4340
+ gap: 8px;
4341
+ margin: 6px 0 10px;
4342
+ }
4343
+ .api-op-call .api-method {
4344
+ padding: 0 6px;
4345
+ }
4346
+ /* Copy sits right after the path it copies, an icon alone, so the two read as one. */
4347
+ button.copy-btn.api-copy-path {
4348
+ padding: 3px 5px;
4349
+ }
4350
+
4351
+ .api-op-body {
4352
+ display: grid;
4353
+ gap: 4px 28px;
4354
+ }
4355
+ .api-op-text,
4356
+ .api-op-code {
4357
+ min-width: 0;
4358
+ }
4359
+ @media (min-width: 1280px) {
4360
+ .api-op-body {
4361
+ max-width: none;
4362
+ /* The examples take what the window has beyond the prose's measure, never under 360px. */
4363
+ grid-template-columns: minmax(0, 720px) minmax(360px, 1fr);
4364
+ }
4365
+ .api-op-code {
4366
+ align-self: start;
4367
+ }
4368
+ .api-op-code > .block:first-child {
4369
+ margin-top: 0;
4370
+ }
4371
+ }
4372
+ /* The rail lists every route by name under its resource, so it reads as the page's contents. */
4373
+ .api-nav-ops {
4374
+ display: none;
4375
+ }
4376
+ .api-nav-ops.open,
4377
+ .api-nav.filtering .api-nav-ops {
4378
+ display: flex;
4379
+ flex-direction: column;
4380
+ }
4381
+ .api-nav a.api-nav-op {
4382
+ padding-left: 30px;
4383
+ font-size: 13px;
4384
+ }
4385
+ .api-nav-tier + .api-nav-ops a.api-nav-op {
4386
+ padding-left: 20px;
4387
+ }
4388
+ .api-object {
4389
+ border-bottom: 1px solid var(--line);
4390
+ /* Reached from a route's Returns, it lands just under the bar. */
4391
+ scroll-margin-top: calc(var(--bar-h) - 1px);
4392
+ }
4173
4393
  /* Reference room on mobile. The bar stays ONE row at --bar-h, the same as on desktop, so
4174
4394
  the in-page anchors (scroll-margin-top) and the scroll-spy line keep clearing it: the back
4175
4395
  link is a --bar-h square holding its arrow, the wordmark and the two tabs follow, and the
@@ -4284,11 +4504,13 @@ button.api-filter-clear svg {
4284
4504
  font-size: 13px;
4285
4505
  }
4286
4506
  /* The API rail is the tier chips alone on a phone: its resources need the column the
4287
- rail no longer has. A route row stacks its summary under its path. */
4507
+ rail no longer has. */
4288
4508
  .api-filter {
4289
4509
  max-width: none;
4290
4510
  }
4291
- .api-nav a.api-nav-res {
4511
+ .api-nav a.api-nav-res,
4512
+ .api-nav .api-nav-ops,
4513
+ .api-nav.filtering .api-nav-ops {
4292
4514
  display: none;
4293
4515
  }
4294
4516
  .api-nav a.api-nav-tier {
@@ -4297,25 +4519,6 @@ button.api-filter-clear svg {
4297
4519
  .api-nav a.api-nav-tier ~ a.api-nav-tier {
4298
4520
  margin-top: 0;
4299
4521
  }
4300
- .api-route-sum {
4301
- grid-template-columns: 62px minmax(0, 1fr) 20px;
4302
- gap: 4px 10px;
4303
- }
4304
- .api-route-line {
4305
- grid-column: 2;
4306
- grid-row: 2;
4307
- white-space: normal;
4308
- }
4309
- .api-chev {
4310
- grid-column: 3;
4311
- grid-row: 1;
4312
- }
4313
- .api-route[open] .api-route-line {
4314
- display: none;
4315
- }
4316
- .api-route-detail {
4317
- padding: 0 4px 14px;
4318
- }
4319
4522
  /* inline-flex centres the chip's text in its 32px; the gap stands in for the space
4320
4523
  between the label and its ↗ that flex drops. */
4321
4524
  .rail-link {
@@ -4720,6 +4923,10 @@ button.copy-btn:hover {
4720
4923
  button.copy-btn:active {
4721
4924
  transform: none;
4722
4925
  }
4926
+ /* A Copy that is its glyph alone (no word) takes no gap for the word. */
4927
+ button.copy-btn span:empty {
4928
+ display: none;
4929
+ }
4723
4930
  button.copy-btn svg {
4724
4931
  width: 14px;
4725
4932
  height: 14px;
@@ -4734,12 +4941,55 @@ button.copied svg {
4734
4941
  font-weight: 600;
4735
4942
  color: var(--muted);
4736
4943
  }
4737
- .block pre {
4944
+ /* A code block (an example, a curl command, a template): a raised card with its label and Copy
4945
+ in a header inside it, so the code stands apart from the page in both themes. */
4946
+ .code-block {
4947
+ background: var(--card);
4948
+ border: 1px solid var(--line);
4949
+ border-radius: 8px;
4950
+ overflow: hidden;
4951
+ }
4952
+ .code-block > .block-head {
4738
4953
  margin: 0;
4739
- overflow-x: auto;
4740
- background: var(--bg);
4954
+ padding: 4px 6px 4px 12px;
4955
+ border-bottom: 1px solid var(--line);
4956
+ }
4957
+ /* A bare code block (a doc's): the same raised card with no label line, the code beside a
4958
+ Copy that is its glyph alone, level with the first line, so a one-line command reads as
4959
+ one quiet row. */
4960
+ .code-bare {
4961
+ display: flex;
4962
+ align-items: flex-start;
4963
+ gap: 8px;
4964
+ background: var(--card);
4741
4965
  border: 1px solid var(--line);
4742
- border-radius: 6px;
4966
+ border-radius: 8px;
4967
+ padding: 0 6px 0 0;
4968
+ }
4969
+ .code-bare pre {
4970
+ flex: 1;
4971
+ min-width: 0;
4972
+ margin: 0;
4973
+ overflow-x: auto;
4974
+ padding: 11px 0 11px 14px;
4975
+ font: 13px / 1.5 var(--mono);
4976
+ }
4977
+ .code-bare > button.copy-btn {
4978
+ flex: none;
4979
+ margin-top: 8px;
4980
+ padding: 5px;
4981
+ }
4982
+ .code-bare > button.copy-btn svg {
4983
+ width: 16px;
4984
+ height: 16px;
4985
+ }
4986
+ /* A doc's code block is spaced like a paragraph. */
4987
+ .doc .code-bare {
4988
+ margin: 14px 0;
4989
+ }
4990
+ .code-block pre {
4991
+ margin: 0;
4992
+ overflow-x: auto;
4743
4993
  padding: 10px 12px;
4744
4994
  font: 12.5px / 1.5 var(--mono);
4745
4995
  }
@@ -6515,7 +6765,7 @@ body.has-savebar .content {
6515
6765
  .subs-wrap {
6516
6766
  container-type: inline-size;
6517
6767
  }
6518
- @container (max-width: 759px) {
6768
+ @container (max-width: 609px) {
6519
6769
  .list-table.subs-table.stacks,
6520
6770
  .list-table.subs-table.stacks tbody,
6521
6771
  .list-table.subs-table.stacks tr {
@@ -6556,10 +6806,7 @@ body.has-savebar .content {
6556
6806
  .list-table.subs-table.stacks td.empty {
6557
6807
  display: none;
6558
6808
  }
6559
- /* Subscribers: status pill · how they joined · joined <date>. */
6560
- .subs-table td.how::before {
6561
- content: " · ";
6562
- }
6809
+ /* Subscribers: status pill · joined <date>. */
6563
6810
  .subs-table td.joined::before {
6564
6811
  content: " · joined ";
6565
6812
  }
@@ -6687,8 +6934,10 @@ body.has-savebar .content {
6687
6934
  font-weight: 600;
6688
6935
  margin: 14px 0 4px;
6689
6936
  }
6690
- /* The same amber box the Schedule dialog gives D1's daily-writes warning (DESIGN §4). */
6691
- .hint.daily-writes.imp-warn {
6937
+ /* On Workers Free's limits, the advice to move to Workers Paid on a list past what Free suits,
6938
+ on the Dashboard, the Subscribers screen, and an import's plan (DESIGN §4): the same amber
6939
+ box the Schedule dialog gives D1's daily-writes warning. */
6940
+ .workers-plan {
6692
6941
  color: var(--warn-fg);
6693
6942
  background: var(--warn-bg);
6694
6943
  border: 1px solid var(--warn-line);
@@ -6729,9 +6978,73 @@ body.has-savebar .content {
6729
6978
  gap: 14px;
6730
6979
  margin-top: 18px;
6731
6980
  }
6732
- /* View subscriber's title is the address, so a long one breaks rather than widens the dialog. */
6981
+ /* A subscriber's page is titled with the address, so a long one breaks rather than widens
6982
+ the head; the status pill and the flag sit beside it, as the roster draws them. */
6733
6983
  .sub-title {
6734
6984
  overflow-wrap: anywhere;
6985
+ margin: 0;
6986
+ }
6987
+ .sub-page-title {
6988
+ display: flex;
6989
+ align-items: center;
6990
+ gap: 12px;
6991
+ flex-wrap: wrap;
6992
+ min-width: 0;
6993
+ }
6994
+ .sub-page-title .sub-pills {
6995
+ white-space: nowrap;
6996
+ }
6997
+ /* Whether they receive posts: the page's first answer, under the head, in the record's
6998
+ meta-line place; muted, with a green dot only when they do, as a confirmed pill is. */
6999
+ .sub-receives {
7000
+ margin: -6px 0 14px;
7001
+ font-size: 13.5px;
7002
+ color: var(--muted);
7003
+ }
7004
+ .sub-receives.yes::before {
7005
+ content: "";
7006
+ display: inline-block;
7007
+ width: 8px;
7008
+ height: 8px;
7009
+ margin-right: 7px;
7010
+ border-radius: 50%;
7011
+ background: var(--ok-fg);
7012
+ }
7013
+ /* The import note: the card's own small print, outside a dialog's hint styles. */
7014
+ .sub-note {
7015
+ margin: 10px 0 0;
7016
+ font-size: 12.5px;
7017
+ color: var(--muted);
7018
+ }
7019
+ .sub-sec .sub-meaning {
7020
+ margin: 8px 0;
7021
+ font-size: 13.5px;
7022
+ }
7023
+ .sub-sec p:last-child {
7024
+ margin-bottom: 0;
7025
+ }
7026
+ .sub-sup-line,
7027
+ .sub-left-line {
7028
+ margin: 0;
7029
+ }
7030
+ /* Sends is a table, and a table is already a card, so its section is no card of its own:
7031
+ the label sits on the page above it, as the dashboard's sections do. */
7032
+ .sub-sends {
7033
+ margin-top: 22px;
7034
+ }
7035
+ .sub-sends-table .c-out {
7036
+ width: 160px;
7037
+ }
7038
+ .sub-sends-table .sub-send a {
7039
+ font-weight: 500;
7040
+ }
7041
+ .sub-more {
7042
+ margin-top: 12px;
7043
+ }
7044
+ /* The roster's address is its row's link, as a Sent row's subject is, in the text's color
7045
+ since the whole row is the click target. */
7046
+ .subs-table a.sub-email {
7047
+ color: inherit;
6735
7048
  }
6736
7049
  .imp-dl,
6737
7050
  .sub-dl {