caracms 0.0.0-stage → 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 (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -2
  3. package/bin/caracms.mjs +174 -0
  4. package/bin/gate.mjs +275 -0
  5. package/bin/init.mjs +712 -0
  6. package/bin/media.mjs +662 -0
  7. package/bin/site-runner.mjs +61 -0
  8. package/bin/templates/Page.astro.txt +31 -0
  9. package/bin/templates/TextBlock.astro.txt +13 -0
  10. package/bin/templates/caracms.config.ts.txt +22 -0
  11. package/bin/templates/catch-all.astro.txt +25 -0
  12. package/bin/templates/health.ts.txt +6 -0
  13. package/bin/templates/railway.ts.txt +147 -0
  14. package/package.json +66 -4
  15. package/skills/caracms-adopt/SKILL.md +508 -0
  16. package/skills/caracms-adopt/look-same.mjs +957 -0
  17. package/skills/caracms-blocks/SKILL.md +376 -0
  18. package/skills/caracms-content/SKILL.md +201 -0
  19. package/skills/caracms-tina/SKILL.md +427 -0
  20. package/skills/caracms-tina/field-kinds.md +88 -0
  21. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  22. package/src/block-runs.ts +84 -0
  23. package/src/cards.ts +158 -0
  24. package/src/cli/schemas.ts +122 -0
  25. package/src/cli/site.ts +66 -0
  26. package/src/cli/validate.ts +471 -0
  27. package/src/code-routes-disk.ts +67 -0
  28. package/src/code-routes.ts +134 -0
  29. package/src/columns.ts +39 -0
  30. package/src/components/Block.astro +46 -0
  31. package/src/components/BlockRunTree.astro +45 -0
  32. package/src/components/Blocks.astro +139 -0
  33. package/src/components/Buttons.astro +116 -0
  34. package/src/components/Fonts.astro +14 -0
  35. package/src/components/Form.astro +221 -0
  36. package/src/components/Head.astro +62 -0
  37. package/src/components/Link.astro +68 -0
  38. package/src/components/Lockup.astro +116 -0
  39. package/src/components/Media.astro +124 -0
  40. package/src/components/MediaPlayer.astro +102 -0
  41. package/src/components/RenderDocument.astro +64 -0
  42. package/src/components/RichText.astro +59 -0
  43. package/src/components/Text.astro +81 -0
  44. package/src/components/attributes.ts +33 -0
  45. package/src/components/descriptor.ts +45 -0
  46. package/src/components/form-client.ts +182 -0
  47. package/src/components/media-player.ts +46 -0
  48. package/src/components/media-props.ts +35 -0
  49. package/src/components/sitemap.xml.ts +36 -0
  50. package/src/config.ts +825 -0
  51. package/src/content-schema.ts +166 -0
  52. package/src/content.ts +422 -0
  53. package/src/document.ts +579 -0
  54. package/src/editor/README.md +405 -0
  55. package/src/editor/addresses.ts +61 -0
  56. package/src/editor/brand.ts +118 -0
  57. package/src/editor/client/block-list/BlockList.tsx +342 -0
  58. package/src/editor/client/block-list/Picker.tsx +105 -0
  59. package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
  60. package/src/editor/client/block-list/insert-points.ts +56 -0
  61. package/src/editor/client/block-list/link.ts +71 -0
  62. package/src/editor/client/block-list/reorder.ts +105 -0
  63. package/src/editor/client/block-list/summary.ts +118 -0
  64. package/src/editor/client/collections/CollectionGrid.tsx +143 -0
  65. package/src/editor/client/collections/CollectionTable.tsx +406 -0
  66. package/src/editor/client/collections/table.ts +270 -0
  67. package/src/editor/client/context.tsx +27 -0
  68. package/src/editor/client/documents/DocMenu.tsx +154 -0
  69. package/src/editor/client/documents/actions.ts +192 -0
  70. package/src/editor/client/documents/dialogs.tsx +687 -0
  71. package/src/editor/client/editor.ts +995 -0
  72. package/src/editor/client/fields/ButtonsField.tsx +87 -0
  73. package/src/editor/client/fields/DateField.tsx +200 -0
  74. package/src/editor/client/fields/FieldControl.tsx +61 -0
  75. package/src/editor/client/fields/FieldFrame.tsx +109 -0
  76. package/src/editor/client/fields/GroupField.tsx +113 -0
  77. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  78. package/src/editor/client/fields/LinkField.tsx +307 -0
  79. package/src/editor/client/fields/ListField.tsx +642 -0
  80. package/src/editor/client/fields/LockupField.tsx +365 -0
  81. package/src/editor/client/fields/MediaField.tsx +823 -0
  82. package/src/editor/client/fields/PageFields.tsx +82 -0
  83. package/src/editor/client/fields/ReferenceField.tsx +209 -0
  84. package/src/editor/client/fields/RichTextField.tsx +306 -0
  85. package/src/editor/client/fields/SelectField.tsx +63 -0
  86. package/src/editor/client/fields/SeoGroup.tsx +282 -0
  87. package/src/editor/client/fields/SimpleFields.tsx +89 -0
  88. package/src/editor/client/fields/TextField.tsx +51 -0
  89. package/src/editor/client/fields/date.ts +73 -0
  90. package/src/editor/client/fields/host.tsx +172 -0
  91. package/src/editor/client/fields/list-summary.ts +85 -0
  92. package/src/editor/client/fields/lockup-rules.ts +181 -0
  93. package/src/editor/client/fields/parts.tsx +458 -0
  94. package/src/editor/client/fields/rich-text-commands.ts +183 -0
  95. package/src/editor/client/fields/rich-text-doc.ts +156 -0
  96. package/src/editor/client/fields/rich-text-schema.ts +224 -0
  97. package/src/editor/client/fields/stale.ts +58 -0
  98. package/src/editor/client/inbox/InboxStage.tsx +272 -0
  99. package/src/editor/client/inbox/store.ts +311 -0
  100. package/src/editor/client/live/format.ts +77 -0
  101. package/src/editor/client/live/marks.tsx +90 -0
  102. package/src/editor/client/live/notices.tsx +213 -0
  103. package/src/editor/client/live/publish.tsx +617 -0
  104. package/src/editor/client/live/session.tsx +162 -0
  105. package/src/editor/client/main.tsx +12 -0
  106. package/src/editor/client/media/LibraryStage.tsx +75 -0
  107. package/src/editor/client/media/ReplaceDialog.tsx +355 -0
  108. package/src/editor/client/media/store.ts +618 -0
  109. package/src/editor/client/nav/DocRow.tsx +122 -0
  110. package/src/editor/client/nav/search.ts +71 -0
  111. package/src/editor/client/nav/tree.ts +93 -0
  112. package/src/editor/client/overlays/OverlayMount.tsx +26 -0
  113. package/src/editor/client/overlays/editor-marks.ts +159 -0
  114. package/src/editor/client/overlays/index.ts +50 -0
  115. package/src/editor/client/overlays/layer.ts +437 -0
  116. package/src/editor/client/overlays/overlay-css.ts +177 -0
  117. package/src/editor/client/overlays/rich-text-bar.ts +717 -0
  118. package/src/editor/client/pane/Pane.tsx +91 -0
  119. package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
  120. package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
  121. package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
  122. package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
  123. package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
  124. package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
  125. package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
  126. package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
  127. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
  128. package/src/editor/client/pane/parts.tsx +179 -0
  129. package/src/editor/client/pane/switcher.tsx +165 -0
  130. package/src/editor/client/preview/driver.ts +640 -0
  131. package/src/editor/client/preview/field-text.ts +71 -0
  132. package/src/editor/client/preview/follow-links.ts +234 -0
  133. package/src/editor/client/preview/layout.ts +37 -0
  134. package/src/editor/client/preview/link-target.ts +50 -0
  135. package/src/editor/client/preview/markers.ts +86 -0
  136. package/src/editor/client/preview/page-edit.ts +632 -0
  137. package/src/editor/client/preview/page-editor-registry.ts +63 -0
  138. package/src/editor/client/preview/page-editor.ts +235 -0
  139. package/src/editor/client/preview/render-sequencer.ts +113 -0
  140. package/src/editor/client/preview/selection.ts +116 -0
  141. package/src/editor/client/preview/warnings.ts +61 -0
  142. package/src/editor/client/shell/App.tsx +101 -0
  143. package/src/editor/client/shell/Bar.tsx +197 -0
  144. package/src/editor/client/shell/Brand.tsx +33 -0
  145. package/src/editor/client/shell/Icons.tsx +87 -0
  146. package/src/editor/client/shell/NarrowNote.tsx +47 -0
  147. package/src/editor/client/shell/Popovers.tsx +47 -0
  148. package/src/editor/client/shell/Stage.tsx +132 -0
  149. package/src/editor/client/shell/Toast.tsx +61 -0
  150. package/src/editor/client/state/document-store.ts +465 -0
  151. package/src/editor/client/state/keeper.ts +713 -0
  152. package/src/editor/client/state/live.ts +257 -0
  153. package/src/editor/client/state/schema.ts +42 -0
  154. package/src/editor/client/state/store.ts +39 -0
  155. package/src/editor/fonts/OFL-GeistMono.txt +93 -0
  156. package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
  157. package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
  158. package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
  159. package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
  160. package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
  161. package/src/editor/middleware.ts +62 -0
  162. package/src/editor/routes/_live.ts +26 -0
  163. package/src/editor/routes/admin.astro +80 -0
  164. package/src/editor/routes/create.ts +19 -0
  165. package/src/editor/routes/delete.ts +19 -0
  166. package/src/editor/routes/document.ts +39 -0
  167. package/src/editor/routes/documents.ts +30 -0
  168. package/src/editor/routes/github.ts +23 -0
  169. package/src/editor/routes/history.ts +9 -0
  170. package/src/editor/routes/lock.ts +9 -0
  171. package/src/editor/routes/login/assets.d.ts +10 -0
  172. package/src/editor/routes/login/login.ts +43 -0
  173. package/src/editor/routes/media.ts +16 -0
  174. package/src/editor/routes/move.ts +17 -0
  175. package/src/editor/routes/notice.ts +9 -0
  176. package/src/editor/routes/preview.astro +77 -0
  177. package/src/editor/routes/publish.ts +9 -0
  178. package/src/editor/routes/status.ts +9 -0
  179. package/src/editor/routes/submissions.ts +16 -0
  180. package/src/editor/routes/try-again.ts +9 -0
  181. package/src/editor/routes/write.ts +18 -0
  182. package/src/editor/server/admin-props.ts +151 -0
  183. package/src/editor/server/cards.ts +61 -0
  184. package/src/editor/server/create.ts +414 -0
  185. package/src/editor/server/drafts-store.ts +127 -0
  186. package/src/editor/server/media/alt.ts +107 -0
  187. package/src/editor/server/media/bucket.ts +295 -0
  188. package/src/editor/server/media/config.ts +78 -0
  189. package/src/editor/server/media/handler.ts +221 -0
  190. package/src/editor/server/media/library.ts +254 -0
  191. package/src/editor/server/media/memory.ts +184 -0
  192. package/src/editor/server/media/mux.ts +241 -0
  193. package/src/editor/server/media/sniff.ts +599 -0
  194. package/src/editor/server/media/upload.ts +175 -0
  195. package/src/editor/server/mode.ts +63 -0
  196. package/src/editor/server/move.ts +295 -0
  197. package/src/editor/server/render.tsx +9 -0
  198. package/src/editor/server/replace.ts +72 -0
  199. package/src/editor/server/settings.ts +35 -0
  200. package/src/editor/server/write.ts +366 -0
  201. package/src/editor/styles/editor.css +502 -0
  202. package/src/editor/styles/fonts.css +40 -0
  203. package/src/editor/styles/levels/README.md +4 -0
  204. package/src/editor/styles/levels/collections.css +99 -0
  205. package/src/editor/styles/levels/document-level.css +10 -0
  206. package/src/editor/styles/levels/documents.css +9 -0
  207. package/src/editor/styles/levels/fields.css +171 -0
  208. package/src/editor/styles/levels/files.css +95 -0
  209. package/src/editor/styles/levels/live.css +10 -0
  210. package/src/editor/styles/levels/m5a-documents.css +51 -0
  211. package/src/editor/styles/levels/m6.css +82 -0
  212. package/src/editor/styles/levels/media.css +175 -0
  213. package/src/editor/styles/levels/switcher.css +3 -0
  214. package/src/editor/styles/tokens.css +114 -0
  215. package/src/editor/tsconfig.json +16 -0
  216. package/src/editor/types.ts +881 -0
  217. package/src/editor/ui-state.ts +113 -0
  218. package/src/email.ts +67 -0
  219. package/src/fields.ts +1129 -0
  220. package/src/files-route.ts +201 -0
  221. package/src/files.ts +116 -0
  222. package/src/forms/email.ts +149 -0
  223. package/src/forms/endpoint.ts +347 -0
  224. package/src/forms/inbox.ts +273 -0
  225. package/src/forms/rate-limit.ts +130 -0
  226. package/src/forms/route.ts +24 -0
  227. package/src/forms/rules.ts +278 -0
  228. package/src/forms/store.ts +486 -0
  229. package/src/head.ts +125 -0
  230. package/src/index.ts +434 -0
  231. package/src/json-source.ts +116 -0
  232. package/src/links.ts +121 -0
  233. package/src/live/README.md +43 -0
  234. package/src/live/auth/allow-rules.ts +70 -0
  235. package/src/live/auth/allow.ts +128 -0
  236. package/src/live/auth/auth.ts +155 -0
  237. package/src/live/auth/email.ts +84 -0
  238. package/src/live/auth/guard.ts +111 -0
  239. package/src/live/auth/index.ts +68 -0
  240. package/src/live/auth/links.ts +101 -0
  241. package/src/live/auth/login.ts +245 -0
  242. package/src/live/auth/middleware.ts +53 -0
  243. package/src/live/auth/request.ts +56 -0
  244. package/src/live/auth/routes.ts +9 -0
  245. package/src/live/auth/session.ts +181 -0
  246. package/src/live/auth/views.ts +355 -0
  247. package/src/live/autosaves.ts +306 -0
  248. package/src/live/backend.ts +652 -0
  249. package/src/live/branches.ts +19 -0
  250. package/src/live/drafts-store.ts +591 -0
  251. package/src/live/editor.ts +465 -0
  252. package/src/live/git.ts +1021 -0
  253. package/src/live/github.ts +271 -0
  254. package/src/live/locals.ts +24 -0
  255. package/src/live/locks.ts +273 -0
  256. package/src/live/merge.ts +197 -0
  257. package/src/live/messages.ts +151 -0
  258. package/src/live/mode.ts +210 -0
  259. package/src/live/redis.ts +533 -0
  260. package/src/live/runtime.ts +164 -0
  261. package/src/live/status.ts +109 -0
  262. package/src/live/sweep.ts +228 -0
  263. package/src/live/timings.ts +47 -0
  264. package/src/live/webhook.ts +39 -0
  265. package/src/lockup-css.ts +58 -0
  266. package/src/measure.ts +145 -0
  267. package/src/media-value.ts +239 -0
  268. package/src/media.ts +255 -0
  269. package/src/read.ts +256 -0
  270. package/src/redirect-middleware.ts +49 -0
  271. package/src/redirects.ts +76 -0
  272. package/src/relink.ts +293 -0
  273. package/src/reserved.ts +24 -0
  274. package/src/rich-text.ts +690 -0
  275. package/src/schema-key.ts +63 -0
  276. package/src/seo.ts +124 -0
  277. package/src/serialize.ts +697 -0
  278. package/src/skipped.ts +35 -0
  279. package/src/slug.ts +121 -0
  280. package/src/text-styles.ts +62 -0
  281. package/src/virtual.d.ts +28 -0
@@ -0,0 +1,355 @@
1
+ /**
2
+ * The sign-in pages' HTML (M4 Phase 3; M2 mockup `notes/mockups/m2-editor.html` →
3
+ * `signinHTML` and `narrowHTML`, whose markup, classes and copy these follow; M2
4
+ * Decision 17's quoted copy is the template's and is kept word for word).
5
+ *
6
+ * Plain server-rendered HTML with **no editor bundle**: one stylesheet (the editor's
7
+ * tokens plus the mockup's sign-in rules below) and, on the pages that need it, one
8
+ * fixed script (`SIGNIN_SCRIPT`) for the confirm and backup presses and the narrow
9
+ * note's Copy. Both are inline and allowed by their hashes in the page's CSP.
10
+ * **Requesting a link is a plain form POST** and needs no script.
11
+ *
12
+ * Branded with the site's slots (name, accent with its computed ink and edge, logo,
13
+ * icon), and laid out down to 320 wide.
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+ import { DEFAULT_NEXT, LOGIN_PATH, loginHref } from './session.ts';
17
+
18
+ // ── Escaping ──────────────────────────────────────────────────────────────────
19
+
20
+ export class SafeHtml {
21
+ readonly value: string;
22
+ constructor(value: string) {
23
+ this.value = value;
24
+ }
25
+ toString() {
26
+ return this.value;
27
+ }
28
+ }
29
+ const escapeHtml =(s: string) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!);
30
+ const part = (v: unknown): string => (v instanceof SafeHtml ? v.value : Array.isArray(v) ? v.map(part).join('') : v === null || v === undefined || v === false ? '' : escapeHtml(String(v)));
31
+ export function html(strings: TemplateStringsArray, ...values: unknown[]): SafeHtml {
32
+ let out = strings[0]!;
33
+ values.forEach((v, i) => (out += part(v) + strings[i + 1]!));
34
+ return new SafeHtml(out);
35
+ }
36
+
37
+ // ── Icons (the editor's sprite paths, inline) ──────────────────────────────────
38
+
39
+ const ICONS: Record<string, string> = {
40
+ check: '<path d="m3 8.5 3.2 3L13 4.5"/>',
41
+ warn: '<path d="M8 2 14.8 13.6H1.2z"/><path d="M8 6.3v3.4M8 11.6v.1"/>',
42
+ ext: '<path d="M9 2.5h4.5V7M13.5 2.5 7.5 8.5M11.5 9.5v4h-9v-9h4"/>',
43
+ copy: '<rect x="5.5" y="5.5" width="8" height="8" rx="1.5"/><path d="M10.5 5.5v-2a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2"/>',
44
+ };
45
+ const ic = (name: string, size = '') => new SafeHtml(`<svg class="i${size ? ` ${size}` : ''}" viewBox="0 0 16 16" aria-hidden="true">${ICONS[name]}</svg>`);
46
+
47
+ // ── The stylesheet ─────────────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * The sign-in rules: the editor's base and components the pages use (editor.css),
51
+ * then the mockup's sign-in and narrow-note sections, its `@container app` queries
52
+ * as media queries (the page is the whole window here).
53
+ */
54
+ export const LOGIN_CSS = `
55
+ *,*::before,*::after{box-sizing:border-box}
56
+ html,body{margin:0;min-height:100%}
57
+ body{font:var(--weight-regular) var(--text-base)/var(--leading) var(--font-ui);color:var(--text);background:var(--surface-sunken);-webkit-font-smoothing:antialiased}
58
+ button,input{font:inherit;color:inherit;letter-spacing:inherit}
59
+ button{cursor:pointer}
60
+ p{margin:0}
61
+ :focus-visible{outline:2px solid var(--focus);outline-offset:2px}
62
+ .i{width:16px;height:16px;flex:none;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
63
+ .i-sm{width:14px;height:14px}.i-xs{width:12px;height:12px}
64
+ .field-label{font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--text)}
65
+ .input{display:block;width:100%;min-height:var(--control-h);padding:6px 10px;border:1px solid var(--border-strong);border-radius:var(--radius-md);background:var(--surface);font-size:var(--text-base);line-height:1.4;transition:border-color var(--dur-fast),box-shadow var(--dur-fast)}
66
+ .input:hover:not(:disabled){border-color:var(--border-hover)}
67
+ .input:focus{outline:none;border-color:var(--select);box-shadow:0 0 0 3px var(--select-halo)}
68
+ .field-err{display:flex;align-items:flex-start;gap:var(--space-1-5);margin-top:var(--space-1-5);font-size:var(--text-sm);color:var(--status-danger);font-weight:var(--weight-medium)}
69
+ .field-err .i{margin-top:2px}
70
+ .linkbtn{display:inline-flex;align-items:center;gap:var(--space-1);padding:0;border:0;background:none;color:var(--select-strong);font-weight:var(--weight-medium);text-decoration:underline;text-decoration-color:color-mix(in srgb,currentColor 40%,transparent);text-underline-offset:3px}
71
+ .linkbtn:hover{text-decoration-color:currentColor}
72
+ .linkbtn.subtle{color:var(--text-muted);text-decoration:none}
73
+ .linkbtn.subtle:hover{color:var(--text)}
74
+ .btn{display:inline-flex;align-items:center;justify-content:center;gap:var(--space-1-5);height:var(--control-h);padding:0 var(--space-3);border:1px solid var(--border-strong);border-radius:var(--radius-md);background:var(--surface);color:var(--text);font-size:var(--text-base);font-weight:var(--weight-semibold);white-space:nowrap;box-shadow:var(--shadow-control);text-decoration:none;transition:background var(--dur-fast),border-color var(--dur-fast),filter var(--dur-fast)}
75
+ .btn:hover:not(:disabled){background:var(--surface-hover);border-color:var(--border-hover)}
76
+ .btn.sm{height:var(--control-h-sm);padding:0 var(--space-2-5);font-size:var(--text-sm)}
77
+ .btn.block{width:100%}
78
+ .btn.accent{background:var(--accent);border-color:var(--accent-edge);color:var(--accent-ink)}
79
+ .btn.accent:hover:not(:disabled){background:var(--accent);border-color:var(--accent-edge);filter:brightness(.93)}
80
+ .btn:disabled{background:var(--surface-sunken);border-color:var(--border);color:var(--text-subtle);box-shadow:none;cursor:not-allowed;filter:none}
81
+ .si-logo{width:28px;height:28px;flex:none}
82
+ .si-logo img{display:block;width:100%;height:100%;object-fit:contain}
83
+ .logo-mono{display:grid;place-items:center;width:100%;height:100%;border-radius:var(--radius-md);background:var(--accent);color:var(--accent-ink);box-shadow:inset 0 0 0 1px var(--accent-edge);font:var(--weight-bold) var(--text-lg)/1 var(--font-ui)}
84
+ .si{min-height:100vh;display:flex;flex-direction:column}
85
+ .si-bar{display:flex;align-items:center;justify-content:space-between;gap:var(--space-3);height:56px;padding:0 var(--space-5);background:var(--surface);border-bottom:1px solid var(--border)}
86
+ .si-brand{display:flex;align-items:center;gap:var(--space-2-5);min-width:0;font-weight:var(--weight-semibold);font-size:var(--text-md)}
87
+ .si-brand .nm{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
88
+ .si-brand .sub{color:var(--text-muted);font-weight:var(--weight-regular);white-space:nowrap}
89
+ .si-home{display:inline-flex;align-items:center;gap:var(--space-1);color:var(--text-muted);font-size:var(--text-sm);text-decoration:none;white-space:nowrap}
90
+ .si-home:hover{color:var(--text)}
91
+ .si-main{flex:1;display:flex;flex-direction:column;align-items:center;padding:clamp(28px,9vh,96px) var(--space-4) var(--space-10)}
92
+ .si-card{width:100%;max-width:400px;padding:var(--space-8);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-xl);box-shadow:var(--shadow-control)}
93
+ .si-card h1{margin:0 0 var(--space-2);font-size:var(--text-2xl);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-tight);line-height:1.2;overflow-wrap:anywhere}
94
+ .si-card .sub{color:var(--text-muted);margin-bottom:var(--space-5)}
95
+ .si-card p{color:var(--text-muted)}
96
+ .si-card p strong{color:var(--text);font-weight:var(--weight-semibold);overflow-wrap:anywhere}
97
+ .si-card form{margin:0}
98
+ .si-card .field{margin:0 0 var(--space-4)}
99
+ .si-card .field label{display:block;margin-bottom:var(--space-1-5)}
100
+ .si-card .input{min-height:42px;font-size:var(--text-md)}
101
+ .si-card .input[aria-invalid="true"]{border-color:var(--status-danger);box-shadow:0 0 0 3px var(--status-danger-halo)}
102
+ .si-card .btn{height:42px;font-size:var(--text-md)}
103
+ .si-card .fine{margin-top:var(--space-4);font-size:var(--text-sm);color:var(--text-muted)}
104
+ .si-card .who{margin:var(--space-2) 0 var(--space-4);padding:var(--space-3);border-radius:var(--radius-md);background:var(--surface-sunken);color:var(--text-muted);overflow-wrap:anywhere}
105
+ .si-card .row-acts{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-3);margin-top:var(--space-5)}
106
+ .si-card .row-acts form{display:contents}
107
+ .si-card .notice{display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-5);padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);font-size:var(--text-sm);font-weight:var(--weight-medium)}
108
+ .si-card .notice[hidden]{display:none}
109
+ .si-card .notice.info{background:var(--surface-sunken);border:1px solid var(--border);color:var(--text)}
110
+ .si-card .notice.ok{background:var(--status-live-wash);border:1px solid var(--status-live-border);color:var(--status-live)}
111
+ .si-card .notice.fail{background:var(--status-danger-wash);border:1px solid var(--status-danger-border);color:var(--status-danger)}
112
+ .si-card .dead-act{margin-top:var(--space-5)}
113
+ .si-alt{margin-top:var(--space-4);font-size:var(--text-sm)}
114
+ .si-alt .linkbtn{color:var(--text-muted)}
115
+ .si-alt .linkbtn:hover{color:var(--text)}
116
+ .noscript{margin-bottom:var(--space-4);font-size:var(--text-sm);color:var(--status-danger)}
117
+ @media (max-width:440px){
118
+ .si-bar{padding:0 var(--space-4)}
119
+ .si-brand .sub{display:none}
120
+ .si-card{padding:var(--space-6) var(--space-5)}
121
+ .si-card h1{font-size:var(--text-xl)}
122
+ }
123
+ .nw{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:var(--space-8) var(--space-5)}
124
+ .nw-in{width:100%;max-width:420px}
125
+ .nw-brand{display:flex;align-items:center;gap:var(--space-2-5);margin-bottom:var(--space-8);font-weight:var(--weight-semibold);font-size:var(--text-md)}
126
+ .nw h1{margin:0 0 var(--space-3);font-size:var(--text-2xl);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-tight);line-height:1.2}
127
+ .nw p{color:var(--text-muted);font-size:var(--text-md)}
128
+ .nw p+p{margin-top:var(--space-3)}
129
+ .nw .addr{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);margin-top:var(--space-5);padding:var(--space-2) var(--space-2) var(--space-2) var(--space-3);border:1px solid var(--border-strong);border-radius:var(--radius-md);background:var(--surface);font-family:var(--font-mono);font-size:var(--text-sm);overflow-wrap:anywhere}
130
+ .nw .ok{display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-5);padding:var(--space-2) var(--space-3);border:1px solid var(--status-live-border);border-radius:var(--radius-md);background:var(--status-live-wash);color:var(--status-live);font-size:var(--text-sm);font-weight:var(--weight-medium);overflow-wrap:anywhere}
131
+ .nw .fine{margin-top:var(--space-6);font-size:var(--text-sm)}
132
+ .nw form{margin:0}
133
+ `;
134
+
135
+ // ── The script ────────────────────────────────────────────────────────────────
136
+
137
+ export const SIGNIN_PATH = `${LOGIN_PATH}/signin`;
138
+ export const SIGNED_IN_PATH = `${LOGIN_PATH}/signed-in`;
139
+
140
+ /**
141
+ * The presses (the template's SIGNIN_SCRIPT, minus Auth.js): one fixed block, no
142
+ * interpolation, so its hash is fixed. It reads the kind and `next` from the form's
143
+ * `data-signin` / `data-next` and the link token from the page's own URL, never the
144
+ * markup. It POSTs JSON to /admin/login/signin and goes where the answer says, or to
145
+ * the signed-in note when the window is under 1024 wide (M2 Decision 8). A dead link
146
+ * reloads the confirm page, whose read-only check then shows the dead-link page; the
147
+ * backup form shows the wrong-password or too-many message. A second press is ignored.
148
+ * It also does the narrow note's Copy.
149
+ */
150
+ export const SIGNIN_SCRIPT = `
151
+ (() => {
152
+ const copy = document.querySelector("[data-copy]");
153
+ if (copy) copy.addEventListener("click", () => {
154
+ const text = location.protocol + "//" + copy.getAttribute("data-copy");
155
+ if (navigator.clipboard) navigator.clipboard.writeText(text).then(() => { const l = copy.querySelector("span"); if (l) l.textContent = "Copied"; });
156
+ });
157
+ const form = document.querySelector("form[data-signin]");
158
+ if (!form) return;
159
+ const kind = form.getAttribute("data-signin");
160
+ const next = form.getAttribute("data-next") || "/admin";
161
+ const button = form.querySelector("button[type=submit]");
162
+ const error = document.getElementById("signin-error");
163
+ const idle = button ? button.textContent : "";
164
+ const messages = {
165
+ wrong: "That username and password didn't work.",
166
+ limited: "Too many attempts. Try again in 15 minutes.",
167
+ failed: "Couldn't sign in. Try again."
168
+ };
169
+ let busy = false;
170
+ const setBusy = (on) => {
171
+ busy = on;
172
+ if (!button) return;
173
+ button.disabled = on;
174
+ button.setAttribute("aria-busy", on ? "true" : "false");
175
+ button.textContent = on ? "Signing in\\u2026" : idle;
176
+ };
177
+ const fail = (reason) => {
178
+ if (kind === "link" && reason === "dead-link") { location.reload(); return; }
179
+ setBusy(false);
180
+ if (error) { error.textContent = messages[reason] || messages.failed; error.hidden = false; }
181
+ };
182
+ form.addEventListener("submit", async (event) => {
183
+ event.preventDefault();
184
+ if (busy) return;
185
+ setBusy(true);
186
+ if (error) error.hidden = true;
187
+ const body = { kind: kind, next: next };
188
+ if (kind === "link") body.token = new URLSearchParams(location.search).get("token") || "";
189
+ else { body.username = form.elements.namedItem("username").value; body.password = form.elements.namedItem("password").value; }
190
+ try {
191
+ const res = await fetch("${SIGNIN_PATH}", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json", "Accept": "application/json" }, body: JSON.stringify(body) });
192
+ const data = await res.json().catch(() => null);
193
+ if (res.ok && data && typeof data.location === "string" && data.location.charAt(0) === "/" && data.location.charAt(1) !== "/") {
194
+ const narrow = window.matchMedia("(max-width: 1023px)").matches;
195
+ location.assign(narrow ? "${SIGNED_IN_PATH}?next=" + encodeURIComponent(data.location) : data.location);
196
+ return;
197
+ }
198
+ fail(data && data.reason);
199
+ } catch (e) {
200
+ fail("failed");
201
+ }
202
+ });
203
+ })();
204
+ `;
205
+
206
+ const sha256b64 = (text: string) => createHash('sha256').update(text, 'utf8').digest('base64');
207
+
208
+ // ── The layout ────────────────────────────────────────────────────────────────
209
+
210
+ export interface Brand {
211
+ name: string;
212
+ accent: string;
213
+ accentInk: string;
214
+ accentEdge: string;
215
+ logo?: string;
216
+ icon: string;
217
+ }
218
+
219
+ export interface PageAssets {
220
+ /** The editor's tokens (styles/tokens.css), as text. */
221
+ tokensCss: string;
222
+ /** `@font-face` rules for the chrome's face, with their URLs, or ''. */
223
+ fontsCss: string;
224
+ }
225
+
226
+ export interface RenderedPage {
227
+ html: string;
228
+ csp: string;
229
+ }
230
+
231
+ /** The images a page may load: its own origin, plus the logo's and icon's if elsewhere. */
232
+ function imageSources(brand: Brand): string[] {
233
+ const out = new Set(["'self'", 'data:']);
234
+ for (const url of [brand.logo, brand.icon]) {
235
+ if (!url || url.startsWith('/') || url.startsWith('data:')) continue;
236
+ try {
237
+ const { protocol, origin } = new URL(url);
238
+ if (protocol === 'https:' || protocol === 'http:') out.add(origin);
239
+ } catch {
240
+ /* not a URL: never loaded */
241
+ }
242
+ }
243
+ return [...out];
244
+ }
245
+
246
+ function brandMark(brand: Brand): SafeHtml {
247
+ if (brand.logo) return html`<img src="${brand.logo}" alt="">`;
248
+ const initial = [...brand.name.trim()][0]?.toLocaleUpperCase() ?? '';
249
+ return html`<span class="logo-mono" aria-hidden="true">${initial}</span>`;
250
+ }
251
+
252
+ /**
253
+ * The whole page. `quiet` drops the bar's link back to the public site, so nothing
254
+ * competes with the one button (the confirm and switch views, as in the mockup).
255
+ */
256
+ export function layout(input: { title: string; brand: Brand; assets: PageAssets; body: SafeHtml; script?: boolean; quiet?: boolean; home?: string; bare?: boolean }): RenderedPage {
257
+ const { brand, assets } = input;
258
+ const accent = `:root{--accent:${brand.accent};--accent-ink:${brand.accentInk};--accent-edge:${brand.accentEdge}}`;
259
+ const css = `${assets.fontsCss}\n${assets.tokensCss}\n${LOGIN_CSS}\n${accent}`;
260
+ const bar = input.bare
261
+ ? ''
262
+ : html`<header class="si-bar"><span class="si-brand"><span class="si-logo">${brandMark(brand)}</span><span class="nm">${brand.name}</span><span class="sub">· Sign in</span></span>${
263
+ input.quiet || !input.home ? '' : html`<a class="si-home" href="/">${input.home}${ic('ext', 'i-xs')}</a>`
264
+ }</header>`;
265
+ const main = input.bare ? input.body : html`<div class="si">${bar}<main class="si-main">${input.body}</main></div>`;
266
+ const page = html`<!doctype html>
267
+ <html lang="en">
268
+ <head>
269
+ <meta charset="utf-8">
270
+ <meta name="viewport" content="width=device-width, initial-scale=1">
271
+ <meta name="robots" content="noindex, nofollow">
272
+ <title>${input.title} · ${brand.name}</title>
273
+ <link rel="icon" href="${brand.icon}">
274
+ <style>${new SafeHtml(css)}</style>
275
+ </head>
276
+ <body>
277
+ ${main}
278
+ ${input.script ? html`<script>${new SafeHtml(SIGNIN_SCRIPT)}</script>` : ''}
279
+ </body>
280
+ </html>
281
+ `;
282
+ const csp = [
283
+ "default-src 'none'",
284
+ `style-src 'sha256-${sha256b64(css)}'`,
285
+ `script-src ${input.script ? `'sha256-${sha256b64(SIGNIN_SCRIPT)}'` : "'none'"}`,
286
+ "connect-src 'self'",
287
+ `img-src ${imageSources(brand).join(' ')}`,
288
+ "font-src 'self'",
289
+ "form-action 'self'",
290
+ "base-uri 'none'",
291
+ "frame-ancestors 'none'",
292
+ ].join('; ');
293
+ return { html: page.value, csp };
294
+ }
295
+
296
+ // ── The views ─────────────────────────────────────────────────────────────────
297
+
298
+ const NOSCRIPT = html`<noscript><p class="noscript">Signing in needs JavaScript. Turn it on and reload this page.</p></noscript>`;
299
+ const FINE = html`<p class="fine">Not you? Close this tab.</p>`;
300
+
301
+ const backupLink = (next: string) => html`<p class="si-alt"><a class="linkbtn" href="${loginHref({ backup: '1' }, next)}">Use the backup account</a></p>`;
302
+
303
+ /** The email form: plain, signed out, or with the malformed-address error. */
304
+ export function emailView(input: { next: string; value?: string; invalid?: string; signedOut?: boolean; backupEnabled: boolean }): SafeHtml {
305
+ const bad = !!input.invalid;
306
+ return html`<section class="si-card">${input.signedOut ? html`<p class="notice info" role="status">${ic('check', 'i-sm')}You're signed out.</p>` : ''}<h1>Sign in</h1><p class="sub">We'll email you a sign-in link.</p><form method="post" action="${LOGIN_PATH}" novalidate><input type="hidden" name="next" value="${input.next}"><div class="field"><label class="field-label" for="si-email">Email address</label><input class="input" id="si-email" name="email" type="email" autocomplete="email" inputmode="email" spellcheck="false" autofocus value="${input.value ?? ''}"${new SafeHtml(bad ? ' aria-invalid="true" aria-describedby="si-err"' : '')}>${
307
+ bad ? html`<p class="field-err" id="si-err">${ic('warn', 'i-sm')}<span>${input.invalid}</span></p>` : ''
308
+ }</div><button class="btn accent block" type="submit">Send sign-in link</button></form></section>${input.backupEnabled ? backupLink(input.next) : ''}`;
309
+ }
310
+
311
+ /**
312
+ * **"Check your email": the same page on every path** (allowed, unknown,
313
+ * rate-limited, send failed). It repeats the address typed, which the person already
314
+ * knows, and says nothing about it.
315
+ */
316
+ export function sentView(input: { email: string; next: string; again: boolean; backupEnabled: boolean }): SafeHtml {
317
+ return html`<section class="si-card">${input.again ? html`<p class="notice ok" role="status">${ic('check', 'i-sm')}Sent again.</p>` : ''}<h1>Check your email</h1><p>If <strong>${input.email}</strong> can sign in, a link is on its way. It expires in 15 minutes.</p><p class="fine">Not there? Check spam, or send again.</p><div class="row-acts"><form method="post" action="${LOGIN_PATH}"><input type="hidden" name="email" value="${input.email}"><input type="hidden" name="next" value="${input.next}"><input type="hidden" name="again" value="1"><button class="btn" type="submit">Send again</button></form><a class="linkbtn" href="${loginHref({}, input.next)}">Use a different address</a></div></section>${input.backupEnabled ? backupLink(input.next) : ''}`;
318
+ }
319
+
320
+ /** The backup form. Posting it without script only shows it again. */
321
+ export function backupView(input: { next: string }): SafeHtml {
322
+ return html`<section class="si-card"><p class="notice fail" id="signin-error" role="alert" hidden></p><h1>Backup account</h1><p class="sub">Details are in the shared password manager.</p>${NOSCRIPT}<form method="post" action="${loginHref({ backup: '1' }, input.next)}" data-signin="backup" data-next="${input.next}"><div class="field"><label class="field-label" for="si-user">Username</label><input class="input" id="si-user" name="username" autocomplete="username" autocapitalize="off" spellcheck="false" autofocus required></div><div class="field"><label class="field-label" for="si-pass">Password</label><input class="input" id="si-pass" name="password" type="password" autocomplete="current-password" required></div><button class="btn accent block" type="submit">Sign in</button></form></section><p class="si-alt"><a class="linkbtn" href="${loginHref({}, input.next)}">Use email sign-in instead</a></p>`;
323
+ }
324
+
325
+ /**
326
+ * "Sign in as …", or **"Switch to …?"** when this browser is signed in as someone else.
327
+ * The form has no action: without script it posts back to this URL, which only
328
+ * redirects to the GET. Nothing is spent until the press.
329
+ */
330
+ export function confirmView(input: { email: string; next: string; siteName: string; current: string | null }): SafeHtml {
331
+ const site = `the ${input.siteName} website`;
332
+ const head = input.current
333
+ ? html`<h1>Switch to ${input.email}?</h1><p class="sub">Signed in here as <strong>${input.current}</strong>. Signing in replaces that.</p>`
334
+ : html`<h1>Sign in to ${site}</h1>`;
335
+ return html`<section class="si-card"><p class="notice fail" id="signin-error" role="alert" hidden></p>${head}${NOSCRIPT}<form method="post" data-signin="link" data-next="${input.next}"><p class="who">Sign in as <strong>${input.email}</strong></p><button class="btn accent block" type="submit">Sign in</button></form>${FINE}</section>`;
336
+ }
337
+
338
+ /** **Expired, used, unknown and missing tokens: one page**, with "Send a new link". */
339
+ export function deadLinkView(): SafeHtml {
340
+ return html`<section class="si-card"><h1>This link has expired or was already used</h1><p>Ask for a new one.</p><a class="btn accent block dead-act" href="${LOGIN_PATH}">Send a new link</a></section>`;
341
+ }
342
+
343
+ /**
344
+ * After signing in on a phone (M2 Decision 8, the mockup's "Below 1024, just signed in
345
+ * on a phone"): signed in, open the editor on a larger screen. With Sign out.
346
+ */
347
+ export function signedInNarrowView(input: { brand: Brand; who: string; host: string }): SafeHtml {
348
+ const address = `${input.host}${DEFAULT_NEXT}`;
349
+ return html`<div class="nw"><div class="nw-in"><div class="nw-brand"><span class="si-logo">${brandMark(input.brand)}</span>${input.brand.name}</div><p class="ok">${ic('check', 'i-sm')}You're signed in as ${input.who}.</p><h1>Open the editor on a bigger screen</h1><p>The editor needs a window at least 1,024 pixels wide.</p><p>On a laptop or desktop, go to:</p><div class="addr"><span>${address}</span><button type="button" class="btn sm" data-copy="${address}">${ic('copy', 'i-sm')}<span>Copy</span></button></div><form class="fine" method="post" action="${LOGIN_PATH}/signout"><button type="submit" class="linkbtn subtle">Sign out</button></form></div></div>`;
350
+ }
351
+
352
+ /** Anything else: a heading, a sentence and the way back. */
353
+ export function messageView(heading: string, text: string): SafeHtml {
354
+ return html`<section class="si-card"><h1>${heading}</h1><p class="sub">${text}</p><p class="si-alt"><a class="linkbtn" href="${LOGIN_PATH}">Back to sign in</a></p></section>`;
355
+ }