@remit/ui 0.0.1

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 (188) hide show
  1. package/package.json +50 -0
  2. package/src/components/address-display.render.test.ts +93 -0
  3. package/src/components/address-display.stories.tsx +55 -0
  4. package/src/components/address-display.tsx +132 -0
  5. package/src/components/address-tag.render.test.ts +39 -0
  6. package/src/components/address-tag.stories.tsx +41 -0
  7. package/src/components/address-tag.tsx +38 -0
  8. package/src/components/app-password-hint.render.test.ts +23 -0
  9. package/src/components/app-password-hint.stories.tsx +19 -0
  10. package/src/components/app-password-hint.tsx +24 -0
  11. package/src/components/app-shell-slotted.render.test.ts +128 -0
  12. package/src/components/app-shell-slotted.tsx +267 -0
  13. package/src/components/app-shell-types.ts +328 -0
  14. package/src/components/app-shell.render.test.ts +415 -0
  15. package/src/components/app-shell.tsx +277 -0
  16. package/src/components/auth-card.render.test.ts +24 -0
  17. package/src/components/auth-card.stories.tsx +50 -0
  18. package/src/components/auth-card.tsx +32 -0
  19. package/src/components/auth-footer.render.test.ts +17 -0
  20. package/src/components/auth-footer.stories.tsx +23 -0
  21. package/src/components/auth-footer.tsx +14 -0
  22. package/src/components/auth-hero.render.test.ts +31 -0
  23. package/src/components/auth-hero.stories.tsx +19 -0
  24. package/src/components/auth-hero.tsx +41 -0
  25. package/src/components/auto-moved-badge.render.test.ts +42 -0
  26. package/src/components/auto-moved-badge.stories.tsx +48 -0
  27. package/src/components/auto-moved-badge.tsx +57 -0
  28. package/src/components/avatar.tsx +60 -0
  29. package/src/components/badge.tsx +49 -0
  30. package/src/components/banner.render.test.ts +58 -0
  31. package/src/components/banner.stories.tsx +92 -0
  32. package/src/components/banner.tsx +81 -0
  33. package/src/components/bottom-sheet.render.test.ts +48 -0
  34. package/src/components/bottom-sheet.stories.tsx +71 -0
  35. package/src/components/bottom-sheet.tsx +146 -0
  36. package/src/components/brief-section.render.test.ts +98 -0
  37. package/src/components/brief-section.stories.tsx +68 -0
  38. package/src/components/brief-section.tsx +102 -0
  39. package/src/components/brief-sections.render.test.ts +72 -0
  40. package/src/components/brief-sections.stories.tsx +178 -0
  41. package/src/components/brief-sections.tsx +201 -0
  42. package/src/components/button.tsx +48 -0
  43. package/src/components/card.tsx +48 -0
  44. package/src/components/category-badge.render.test.ts +40 -0
  45. package/src/components/category-badge.stories.tsx +37 -0
  46. package/src/components/category-badge.tsx +71 -0
  47. package/src/components/checkbox.render.test.ts +65 -0
  48. package/src/components/checkbox.stories.tsx +77 -0
  49. package/src/components/checkbox.tsx +78 -0
  50. package/src/components/compose-action-bar.render.test.ts +47 -0
  51. package/src/components/compose-action-bar.stories.tsx +35 -0
  52. package/src/components/compose-action-bar.tsx +95 -0
  53. package/src/components/compose-form-shell.render.test.ts +54 -0
  54. package/src/components/compose-form-shell.stories.tsx +96 -0
  55. package/src/components/compose-form-shell.tsx +49 -0
  56. package/src/components/danger-zone-section.tsx +36 -0
  57. package/src/components/dialog.render.test.ts +55 -0
  58. package/src/components/dialog.tsx +104 -0
  59. package/src/components/email-frame-css.ts +137 -0
  60. package/src/components/field-label.render.test.ts +16 -0
  61. package/src/components/field-label.tsx +19 -0
  62. package/src/components/filter-sheet.render.test.ts +76 -0
  63. package/src/components/filter-sheet.stories.tsx +146 -0
  64. package/src/components/filter-sheet.tsx +316 -0
  65. package/src/components/folder-role.tsx +66 -0
  66. package/src/components/input.tsx +44 -0
  67. package/src/components/intelligence-panel.stories.tsx +149 -0
  68. package/src/components/intelligence-panel.tsx +546 -0
  69. package/src/components/isolated-email-frame.render.test.ts +147 -0
  70. package/src/components/isolated-email-frame.stories.tsx +192 -0
  71. package/src/components/isolated-email-frame.tsx +265 -0
  72. package/src/components/kbd.tsx +17 -0
  73. package/src/components/keyboard-hint-bar.tsx +58 -0
  74. package/src/components/list-item.tsx +39 -0
  75. package/src/components/mail-action-toolbar.render.test.ts +43 -0
  76. package/src/components/mail-action-toolbar.tsx +180 -0
  77. package/src/components/mail-header.render.test.ts +69 -0
  78. package/src/components/mail-header.stories.tsx +233 -0
  79. package/src/components/mail-header.tsx +123 -0
  80. package/src/components/message-body-view.render.test.ts +59 -0
  81. package/src/components/message-body-view.stories.tsx +146 -0
  82. package/src/components/message-body-view.tsx +150 -0
  83. package/src/components/message-header.render.test.ts +58 -0
  84. package/src/components/message-header.stories.tsx +79 -0
  85. package/src/components/message-header.tsx +59 -0
  86. package/src/components/message-list-pane.render.test.ts +136 -0
  87. package/src/components/message-list-pane.stories.tsx +246 -0
  88. package/src/components/message-list-pane.tsx +248 -0
  89. package/src/components/message-list-state.render.test.ts +72 -0
  90. package/src/components/message-list-state.tsx +97 -0
  91. package/src/components/message-row.render.test.ts +48 -0
  92. package/src/components/message-row.stories.tsx +121 -0
  93. package/src/components/message-row.tsx +231 -0
  94. package/src/components/mobile-message-action-bar.render.test.ts +87 -0
  95. package/src/components/mobile-message-action-bar.stories.tsx +57 -0
  96. package/src/components/mobile-message-action-bar.tsx +175 -0
  97. package/src/components/mobile-reading-pane.render.test.ts +102 -0
  98. package/src/components/mobile-reading-pane.tsx +188 -0
  99. package/src/components/mobile-search-view.stories.tsx +247 -0
  100. package/src/components/mobile-search-view.tsx +100 -0
  101. package/src/components/move-mailbox-picker.render.test.ts +113 -0
  102. package/src/components/move-mailbox-picker.stories.tsx +76 -0
  103. package/src/components/move-mailbox-picker.tsx +324 -0
  104. package/src/components/nav-sidebar.render.test.ts +351 -0
  105. package/src/components/nav-sidebar.stories.tsx +216 -0
  106. package/src/components/nav-sidebar.tsx +625 -0
  107. package/src/components/outbox-row.render.test.ts +61 -0
  108. package/src/components/outbox-row.stories.tsx +53 -0
  109. package/src/components/outbox-row.tsx +120 -0
  110. package/src/components/outbox-status-badge.tsx +60 -0
  111. package/src/components/pane-layout.render.test.ts +105 -0
  112. package/src/components/popover-menu.render.test.ts +32 -0
  113. package/src/components/popover-menu.stories.tsx +57 -0
  114. package/src/components/popover-menu.tsx +110 -0
  115. package/src/components/primitives.stories.tsx +173 -0
  116. package/src/components/pull-to-refresh.render.test.ts +46 -0
  117. package/src/components/pull-to-refresh.stories.tsx +71 -0
  118. package/src/components/pull-to-refresh.tsx +54 -0
  119. package/src/components/quoted-text.render.test.ts +39 -0
  120. package/src/components/quoted-text.stories.tsx +67 -0
  121. package/src/components/quoted-text.tsx +67 -0
  122. package/src/components/reading-pane-empty.tsx +44 -0
  123. package/src/components/reading-pane.render.test.ts +148 -0
  124. package/src/components/reading-pane.stories.tsx +194 -0
  125. package/src/components/reading-pane.tsx +351 -0
  126. package/src/components/rescue-banner.render.test.ts +32 -0
  127. package/src/components/rescue-banner.stories.tsx +31 -0
  128. package/src/components/rescue-banner.tsx +50 -0
  129. package/src/components/rescue-candidate-row.render.test.ts +68 -0
  130. package/src/components/rescue-candidate-row.stories.tsx +108 -0
  131. package/src/components/rescue-candidate-row.tsx +94 -0
  132. package/src/components/rescue-from-spam-flow.render.test.ts +100 -0
  133. package/src/components/rescue-from-spam-flow.stories.tsx +92 -0
  134. package/src/components/rescue-from-spam-flow.tsx +342 -0
  135. package/src/components/resizable.tsx +55 -0
  136. package/src/components/role-appointment-list.render.test.ts +97 -0
  137. package/src/components/role-appointment-list.stories.tsx +138 -0
  138. package/src/components/role-appointment-list.tsx +227 -0
  139. package/src/components/row-actions.render.test.ts +83 -0
  140. package/src/components/row-actions.stories.tsx +87 -0
  141. package/src/components/row-actions.tsx +139 -0
  142. package/src/components/search-bar.render.test.ts +55 -0
  143. package/src/components/search-bar.stories.tsx +46 -0
  144. package/src/components/search-bar.tsx +118 -0
  145. package/src/components/search-result-row.tsx +139 -0
  146. package/src/components/search-results.render.test.ts +89 -0
  147. package/src/components/search-results.stories.tsx +126 -0
  148. package/src/components/search-results.tsx +215 -0
  149. package/src/components/search-token-chip.render.test.ts +42 -0
  150. package/src/components/search-token-chip.stories.tsx +42 -0
  151. package/src/components/search-token-chip.tsx +66 -0
  152. package/src/components/security-select.render.test.ts +33 -0
  153. package/src/components/security-select.tsx +53 -0
  154. package/src/components/segmented-control.tsx +70 -0
  155. package/src/components/select.tsx +33 -0
  156. package/src/components/selection-top-bar.render.test.ts +67 -0
  157. package/src/components/selection-top-bar.stories.tsx +39 -0
  158. package/src/components/selection-top-bar.tsx +95 -0
  159. package/src/components/sender-group-switch.tsx +63 -0
  160. package/src/components/sender-trust-indicator.render.test.ts +67 -0
  161. package/src/components/sender-trust-indicator.stories.tsx +58 -0
  162. package/src/components/sender-trust-indicator.tsx +87 -0
  163. package/src/components/settings-primitives.render.test.ts +160 -0
  164. package/src/components/settings-primitives.stories.tsx +80 -0
  165. package/src/components/settings-screen.tsx +354 -0
  166. package/src/components/swipeable-row.render.test.ts +106 -0
  167. package/src/components/swipeable-row.stories.tsx +117 -0
  168. package/src/components/swipeable-row.tsx +268 -0
  169. package/src/components/touch-list-body.render.test.ts +66 -0
  170. package/src/components/touch-list-body.stories.tsx +76 -0
  171. package/src/components/touch-list.tsx +87 -0
  172. package/src/components/wizard.render.test.ts +49 -0
  173. package/src/components/wizard.stories.tsx +113 -0
  174. package/src/components/wizard.tsx +321 -0
  175. package/src/filter-presets.test.ts +93 -0
  176. package/src/filter-presets.ts +101 -0
  177. package/src/index.ts +378 -0
  178. package/src/lib/cid-resolver.ts +35 -0
  179. package/src/lib/cn.ts +7 -0
  180. package/src/lib/email-layout-clamp.test.ts +109 -0
  181. package/src/lib/email-layout-clamp.ts +64 -0
  182. package/src/lib/email-render-treatment.test.ts +82 -0
  183. package/src/lib/email-render-treatment.ts +53 -0
  184. package/src/lib/email-sanitizer.test.ts +362 -0
  185. package/src/lib/email-sanitizer.ts +298 -0
  186. package/src/lib/row-keyboard.test.ts +41 -0
  187. package/src/lib/row-keyboard.ts +20 -0
  188. package/src/tokens.css +217 -0
@@ -0,0 +1,298 @@
1
+ import DOMPurify from "dompurify";
2
+ import type { CidResolver } from "./cid-resolver.js";
3
+ import { generateLayoutClampCSS } from "./email-layout-clamp.js";
4
+
5
+ export type { CidResolver } from "./cid-resolver.js";
6
+ export { buildCidResolver } from "./cid-resolver.js";
7
+ export { generateLayoutClampCSS } from "./email-layout-clamp.js";
8
+
9
+ export interface SanitizeOptions {
10
+ allowExternalImages?: boolean;
11
+ /**
12
+ * Resolve a `cid:CONTENT_ID` reference to a fetchable URL. The renderer
13
+ * looks up the body part with a matching `contentId` and returns its
14
+ * CloudFront `contentUrl` (#224 PR 2). When the resolver returns
15
+ * `undefined`, the original `cid:` URL is left in place so the failure
16
+ * is visible (broken image icon) — never silently substitute a blocked-
17
+ * placeholder, that would mask a real backend mismatch.
18
+ */
19
+ resolveCid?: CidResolver;
20
+ }
21
+
22
+ /**
23
+ * Output of the sanitize pipeline. `hasAuthorBackground` tells the renderer
24
+ * whether the email author specified its own background — either via an
25
+ * inline `style="background…"`, a legacy `bgcolor="…"` attribute, or inside
26
+ * a `<style>` block. When true, the renderer pins the subtree to light-mode
27
+ * so author colors survive (newsletter / designed mail). When false, the
28
+ * subtree inherits the app theme so plain mail blends with dark chrome
29
+ * instead of becoming a bright slab (#375).
30
+ */
31
+ export interface SanitizedEmail {
32
+ html: string;
33
+ hasAuthorBackground: boolean;
34
+ }
35
+
36
+ /**
37
+ * CSS values that represent "no background" — finding `background[-color]:`
38
+ * set to one of these in a `<style>` block does NOT constitute an author
39
+ * background. Values are matched case-insensitively.
40
+ */
41
+ const NO_BACKGROUND_VALUES = /^(none|transparent|inherit|initial|unset)\s*$/i;
42
+
43
+ /**
44
+ * Extract all `<style>…</style>` block contents from the raw HTML string.
45
+ */
46
+ const extractStyleBlocks = (html: string): string[] => {
47
+ const blocks: string[] = [];
48
+ const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
49
+ let match: RegExpExecArray | null;
50
+ match = re.exec(html);
51
+ while (match !== null) {
52
+ blocks.push(match[1]);
53
+ match = re.exec(html);
54
+ }
55
+ return blocks;
56
+ };
57
+
58
+ /**
59
+ * Return true if the CSS text contains a `background` or `background-color`
60
+ * declaration with a value that is not one of the "no-op" keywords
61
+ * (`none`, `transparent`, `inherit`, `initial`, `unset`). A light
62
+ * declaration scan — no full CSS parser needed.
63
+ *
64
+ * Note: this intentionally matches any selector (including class selectors
65
+ * like `.foo { background: red }`). Determining whether a given rule is
66
+ * "applied" to any element requires DOM access and a full cascade, which is
67
+ * beyond the scope of a pre-sanitization heuristic. The main fixes this
68
+ * closes are reset stylesheets (`background: none / transparent`) and
69
+ * `background: inherit` resets that were spuriously triggering the framed
70
+ * treatment (#483). A class rule with a real color value is treated as an
71
+ * author background on the conservative side.
72
+ */
73
+ const styleBlockHasBackground = (css: string): boolean => {
74
+ // Match `background:` or `background-color:` followed by its value
75
+ // (everything up to the next `;`, `}`, or end of string).
76
+ const re = /background(?:-color)?\s*:\s*([^;}\n]+)/gi;
77
+ let match: RegExpExecArray | null;
78
+ match = re.exec(css);
79
+ while (match !== null) {
80
+ const value = match[1].trim();
81
+ if (!NO_BACKGROUND_VALUES.test(value)) {
82
+ return true;
83
+ }
84
+ match = re.exec(css);
85
+ }
86
+ return false;
87
+ };
88
+
89
+ /**
90
+ * Detect whether the input HTML contains an author-specified background.
91
+ * Runs over the raw HTML string before sanitization — a real CSS parser is
92
+ * overkill here because the false-positive surface is "the literal word
93
+ * 'background' inside a `<style>` block or a `style=` attribute", and that's
94
+ * exactly the content we'd parse anyway. Match on:
95
+ * - any `style="…background…"` (or `style='…'`)
96
+ * - any `bgcolor=` attribute
97
+ * - any `<style>` block that contains a `background[-color]:` declaration
98
+ * with a non-trivial value (not `none`, `transparent`, `inherit`,
99
+ * `initial`, or `unset`) — tightened from the bare substring match that
100
+ * was over-matching reset stylesheets and `background: none` rules (#483)
101
+ *
102
+ * Author *text* colour alone (no background) does not trigger — that's the
103
+ * point: a plain `<p style="color:#666">…</p>` mail still blends with the
104
+ * app theme.
105
+ */
106
+ export const detectAuthorBackground = (html: string): boolean => {
107
+ if (/\bstyle\s*=\s*["'][^"']*background/i.test(html)) return true;
108
+ if (/\bbgcolor\s*=/i.test(html)) return true;
109
+ for (const block of extractStyleBlocks(html)) {
110
+ if (styleBlockHasBackground(block)) return true;
111
+ }
112
+ return false;
113
+ };
114
+
115
+ const FORBIDDEN_TAGS = [
116
+ "script",
117
+ "iframe",
118
+ "object",
119
+ "embed",
120
+ "form",
121
+ "input",
122
+ "button",
123
+ "textarea",
124
+ "select",
125
+ "meta",
126
+ "link",
127
+ "base",
128
+ ];
129
+
130
+ const FORBIDDEN_ATTR = [
131
+ // Event handlers
132
+ "onerror",
133
+ "onload",
134
+ "onclick",
135
+ "onmouseover",
136
+ "onmouseout",
137
+ "onfocus",
138
+ "onblur",
139
+ "onsubmit",
140
+ "onkeydown",
141
+ "onkeyup",
142
+ // Dangerous attributes
143
+ "formaction",
144
+ "xlink:href",
145
+ "data-bind",
146
+ ];
147
+
148
+ // Placeholder for blocked images
149
+ const PLACEHOLDER_IMAGE =
150
+ "data:image/svg+xml," +
151
+ encodeURIComponent(`
152
+ <svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
153
+ <rect fill="#f0f0f0" width="100" height="100"/>
154
+ <text x="50" y="50" text-anchor="middle" dy=".3em" fill="#999" font-size="12">
155
+ Image blocked
156
+ </text>
157
+ </svg>
158
+ `);
159
+
160
+ /**
161
+ * Strip CSS constructs that double as injection vectors from an inline
162
+ * `style="..."` value:
163
+ * - `url(...)` — author can reference remote backgrounds, which leaks the
164
+ * read event the same way an `<img>` would. Privacy.
165
+ * - `expression(...)` — legacy IE CSS-as-JS escape hatch. XSS.
166
+ * - `-moz-binding: ...` — legacy Firefox CSS-to-XBL escape hatch. XSS.
167
+ *
168
+ * Author colors/backgrounds pass through unchanged — the email body is
169
+ * rendered as a light-mode island (see `IsolatedEmailFrame`'s
170
+ * `color-scheme: light`), so author CSS can stand on its own.
171
+ */
172
+ export const sanitizeInlineStyle = (style: string): string => {
173
+ return style
174
+ .replace(/url\s*\([^)]*\)/gi, "none")
175
+ .replace(/expression\s*\([^)]*\)/gi, "")
176
+ .replace(/-moz-binding\s*:[^;]*/gi, "");
177
+ };
178
+
179
+ /**
180
+ * Same security scrub as `sanitizeInlineStyle`, plus `@import` neutering for
181
+ * `<style>` block contents — `@import` can pull in remote stylesheets and
182
+ * leaks the read event.
183
+ *
184
+ * Crucially, this does NOT wrap the author's CSS in a media query: the email
185
+ * subtree is pinned to light-mode at the wrapper level, so author CSS that
186
+ * targets `body { background: white; color: black; }` keeps applying.
187
+ */
188
+ export const sanitizeStyleElementCss = (css: string): string => {
189
+ return css
190
+ .replace(/@import[^;]*;/gi, "/* @import blocked */")
191
+ .replace(/url\s*\([^)]*\)/gi, "none")
192
+ .replace(/expression\s*\([^)]*\)/gi, "")
193
+ .replace(/-moz-binding\s*:[^;]*/gi, "");
194
+ };
195
+
196
+ // ============================================
197
+ // Main Sanitizer
198
+ // ============================================
199
+
200
+ export const createEmailSanitizer = (options: SanitizeOptions = {}) => {
201
+ const purify = DOMPurify();
202
+
203
+ const config = {
204
+ FORBID_TAGS: FORBIDDEN_TAGS,
205
+ FORBID_ATTR: FORBIDDEN_ATTR,
206
+ ALLOW_DATA_ATTR: false,
207
+ ALLOW_UNKNOWN_PROTOCOLS: false,
208
+ };
209
+
210
+ // Hook: Process elements after attribute sanitization
211
+ purify.addHook("afterSanitizeAttributes", (node) => {
212
+ // Handle images
213
+ if (node.tagName === "IMG") {
214
+ const src = node.getAttribute("src") || "";
215
+
216
+ if (src.startsWith("data:")) {
217
+ // Allow data URIs (inline images)
218
+ return;
219
+ }
220
+
221
+ if (src.startsWith("cid:")) {
222
+ // `cid:` references inline body parts. Look up the matching
223
+ // body part by Content-ID and rewrite to the CloudFront
224
+ // `contentUrl` (#224 PR 2). The Content-ID in the URL may
225
+ // or may not be wrapped in angle brackets — strip them so
226
+ // the comparison is consistent across mail clients.
227
+ const raw = src.slice(4);
228
+ const stripped = raw.replace(/^<|>$/g, "");
229
+ const resolved = options.resolveCid?.(stripped);
230
+ if (resolved) {
231
+ node.setAttribute("src", resolved);
232
+ node.classList.add("inline-content");
233
+ }
234
+ return;
235
+ }
236
+
237
+ if (!options.allowExternalImages) {
238
+ // Store original src for "load images" feature
239
+ node.setAttribute("data-blocked-src", src);
240
+ node.setAttribute("src", PLACEHOLDER_IMAGE);
241
+ node.setAttribute("alt", "[Blocked image]");
242
+ node.classList.add("blocked-image");
243
+ }
244
+ }
245
+
246
+ // Handle links
247
+ if (node.tagName === "A") {
248
+ const href = node.getAttribute("href") || "";
249
+
250
+ // Block javascript: and data: URLs
251
+ if (/^(javascript|data):/i.test(href)) {
252
+ node.removeAttribute("href");
253
+ return;
254
+ }
255
+
256
+ // Make external links safe
257
+ node.setAttribute("target", "_blank");
258
+ node.setAttribute("rel", "noopener noreferrer nofollow");
259
+
260
+ // Add visual indicator for external links
261
+ node.classList.add("external-link");
262
+ }
263
+
264
+ // Sanitize inline styles — security only, colors pass through.
265
+ if (node.hasAttribute("style")) {
266
+ const style = node.getAttribute("style") || "";
267
+ node.setAttribute("style", sanitizeInlineStyle(style));
268
+ }
269
+ });
270
+
271
+ // Hook: Process style elements
272
+ purify.addHook("uponSanitizeElement", (node, data) => {
273
+ if (data.tagName === "style") {
274
+ const css = node.textContent || "";
275
+ node.textContent = sanitizeStyleElementCss(css);
276
+ }
277
+ });
278
+
279
+ return (html: string): SanitizedEmail => {
280
+ // Detect BEFORE sanitization: DOMPurify keeps `style=` / `bgcolor=` /
281
+ // author `<style>` content for us (we deliberately preserve them in
282
+ // #375), but running the detector on the raw input is simpler and
283
+ // not sensitive to any future hook rewrites.
284
+ const hasAuthorBackground = detectAuthorBackground(html);
285
+
286
+ const sanitized = purify.sanitize(html, config);
287
+
288
+ // Layout clamp always applies — it keeps wide author markup (fixed-width
289
+ // tables, oversized images, long URLs) from breaking out of the column
290
+ // and unlocking horizontal page scroll on mobile (#374). Independent of
291
+ // any color concerns because it touches layout only.
292
+ const layoutCss = generateLayoutClampCSS();
293
+ return {
294
+ html: `<style>${layoutCss}</style>${sanitized}`,
295
+ hasAuthorBackground,
296
+ };
297
+ };
298
+ };
@@ -0,0 +1,41 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { isSelfRowActivation } from "./row-keyboard.js";
4
+
5
+ // Two distinct nodes standing in for the row (currentTarget) and a nested
6
+ // control such as the trailing star / Clear button (a descendant target).
7
+ const row = { id: "row" } as unknown as EventTarget;
8
+ const innerButton = { id: "star" } as unknown as EventTarget;
9
+
10
+ describe("isSelfRowActivation (#1232 keyboard twin-action guard)", () => {
11
+ it("activates on Enter/Space that originate on the row itself", () => {
12
+ for (const key of ["Enter", " "]) {
13
+ assert.equal(
14
+ isSelfRowActivation({ key, target: row, currentTarget: row }),
15
+ true,
16
+ );
17
+ }
18
+ });
19
+
20
+ it("ignores Enter/Space bubbled up from a nested control (no twin action)", () => {
21
+ for (const key of ["Enter", " "]) {
22
+ assert.equal(
23
+ isSelfRowActivation({
24
+ key,
25
+ target: innerButton,
26
+ currentTarget: row,
27
+ }),
28
+ false,
29
+ );
30
+ }
31
+ });
32
+
33
+ it("ignores other keys even on the row itself", () => {
34
+ for (const key of ["Tab", "a", "ArrowDown", "Escape"]) {
35
+ assert.equal(
36
+ isSelfRowActivation({ key, target: row, currentTarget: row }),
37
+ false,
38
+ );
39
+ }
40
+ });
41
+ });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A `role="button"` row that holds its own nested controls (a star toggle, a
3
+ * Clear button) can't be a native `<button>` — button-in-button is invalid HTML
4
+ * (#1232). The row wires Enter/Space by hand, but a keydown on a focused inner
5
+ * button bubbles up to the row's `onKeyDown`. Without a guard the row would
6
+ * activate too, firing two actions from one keypress (a native `<button>`
7
+ * ancestor never did this).
8
+ *
9
+ * Act only on keys that originate on the row itself (`target === currentTarget`),
10
+ * so a key bubbled from a descendant control is ignored. This is the row-level
11
+ * twin of the child button's `stopPropagation()` on click.
12
+ */
13
+ export function isSelfRowActivation(e: {
14
+ key: string;
15
+ target: EventTarget | null;
16
+ currentTarget: EventTarget | null;
17
+ }): boolean {
18
+ if (e.target !== e.currentTarget) return false;
19
+ return e.key === "Enter" || e.key === " ";
20
+ }
package/src/tokens.css ADDED
@@ -0,0 +1,217 @@
1
+ @import "tailwindcss";
2
+
3
+ /* Self-hosted variable fonts (no CDN). Swap the active UI font in ONE
4
+ line below: change --font-ui to one of the three @theme aliases. */
5
+ @import "@fontsource-variable/geist";
6
+ @import "@fontsource-variable/hanken-grotesk";
7
+ @import "@fontsource-variable/inter";
8
+
9
+ /* Tailwind v4 dark variant: dark: utilities apply when any ancestor
10
+ carries the .dark class. The workbench theme decorator toggles
11
+ .dark on a wrapper; an app would toggle it on <html>. */
12
+ @custom-variant dark (&:is(.dark *));
13
+
14
+ /* @theme inline aliases Tailwind utilities (bg-surface, text-fg, etc.)
15
+ to the semantic CSS variables declared on :root / .dark below. The
16
+ :root / .dark blocks MUST come after this @theme block — otherwise
17
+ Tailwind hoists them and the dark scope collapses. */
18
+ @theme inline {
19
+ /* font candidates — A/B by repointing --font-ui */
20
+ --font-geist: "Geist Variable", system-ui, sans-serif;
21
+ --font-hanken: "Hanken Grotesk Variable", system-ui, sans-serif;
22
+ --font-inter: "Inter Variable", system-ui, sans-serif;
23
+
24
+ /* the single active-font token — change this one line to swap */
25
+ --font-ui: var(--font-geist);
26
+ --font-sans: var(--font-ui);
27
+
28
+ /* surfaces & text */
29
+ --color-canvas: var(--canvas);
30
+ --color-surface: var(--surface);
31
+ --color-surface-raised: var(--surface-raised);
32
+ --color-surface-sunken: var(--surface-sunken);
33
+ --color-fg: var(--fg);
34
+ --color-fg-muted: var(--fg-muted);
35
+ --color-fg-subtle: var(--fg-subtle);
36
+
37
+ /* lines */
38
+ --color-line: var(--line);
39
+ --color-line-strong: var(--line-strong);
40
+
41
+ /* brand accent (phosphor green): primary actions, links, unread */
42
+ --color-accent: var(--accent);
43
+ --color-accent-hover: var(--accent-hover);
44
+ --color-accent-fg: var(--accent-fg);
45
+ --color-accent-soft: var(--accent-soft);
46
+
47
+ /* secondary accent (cyan): selection states & informational chips */
48
+ --color-accent-2: var(--accent-2);
49
+ --color-accent-2-soft: var(--accent-2-soft);
50
+
51
+ /* status */
52
+ --color-positive: var(--positive);
53
+ --color-warning: var(--warning);
54
+ --color-danger: var(--danger);
55
+ --color-danger-soft: var(--danger-soft);
56
+
57
+ /* focus ring */
58
+ --color-ring: var(--ring);
59
+
60
+ /* radii — slight rounding only: hairline borders + subtle radius */
61
+ --radius-xs: 2px;
62
+ --radius-sm: 3px;
63
+ --radius-md: 5px;
64
+ --radius-lg: 7px;
65
+ --radius-xl: 10px;
66
+
67
+ /* vertical rhythm: 4px base unit, modular 1:2:3:5 scale.
68
+ Allowed vertical spacing steps: 4 / 8 / 12 / 20px (32px = 8 units
69
+ as the one outlier for big section breaks). In Tailwind terms:
70
+ py-1 / py-2 / py-3 / py-5 (py-8). Row heights land on the 4px
71
+ grid: compact row 32px, pane header 40px, section/expando row 48px,
72
+ comfortable message row 72px (section:message = 2:3). */
73
+
74
+ /* shared pane-header datum: every pane's first header row (mail list,
75
+ reading pane, intelligence, settings nav/content/help) is exactly
76
+ this tall so one continuous hairline runs across all panes.
77
+ 40px = 10 grid units. */
78
+ --spacing-pane-header: 2.5rem;
79
+
80
+ /* section-row: the fixed height for section headers and expandos in
81
+ the inbox list. 48px = 2/3 of the 72px comfortable message row,
82
+ establishing the 2:3 rhythm. 12 grid units. */
83
+ --spacing-section-row: 3rem;
84
+
85
+ /* shared horizontal inset for dense list rows (mail list, brief,
86
+ senders table): content is inset (12px = 3 units), hover/active
87
+ highlight bleeds to the full pane width. */
88
+ --spacing-row-inset: 0.75rem;
89
+
90
+ /* type scale (1.20 minor third off 14px base) */
91
+ --text-2xs: 0.6875rem;
92
+ --text-xs: 0.75rem;
93
+ --text-sm: 0.8125rem;
94
+ --text-base: 0.875rem;
95
+ --text-md: 0.9375rem;
96
+ --text-lg: 1.0625rem;
97
+ --text-xl: 1.3125rem;
98
+ --text-2xl: 1.625rem;
99
+
100
+ /* spacing rhythm leans on Tailwind defaults; density is "calm" */
101
+ }
102
+
103
+ /* ------------------------------------------------------------------ */
104
+ /* "Hipster modern retro" (2026), balanced for tired eyes. */
105
+ /* Solarized-inspired tinted neutrals with deliberately better text */
106
+ /* contrast than actual Solarized: warm cream paper in light, deep */
107
+ /* slate-teal in dark, cream-on-teal text pairing, phosphor-green */
108
+ /* accent, cyan secondary for selection/info. Danger stays the ONLY */
109
+ /* warm color in the UI so phishing banners are the loudest thing on */
110
+ /* screen. No pure white, no pure black anywhere. */
111
+ /* ------------------------------------------------------------------ */
112
+
113
+ /* ---------- LIGHT: warm paper ---------- */
114
+ :root {
115
+ /* warm cream, base3 direction but less yellow */
116
+ --canvas: oklch(0.96 0.015 90);
117
+ --surface: oklch(0.975 0.012 90);
118
+ --surface-raised: oklch(0.985 0.009 90);
119
+ --surface-sunken: oklch(0.935 0.018 90);
120
+
121
+ /* cool dark ink, never black (~12:1 on canvas) */
122
+ --fg: oklch(0.3 0.025 235);
123
+ --fg-muted: oklch(0.45 0.022 235);
124
+ --fg-subtle: oklch(0.55 0.02 235);
125
+
126
+ /* warm-tinted hairlines */
127
+ --line: oklch(0.89 0.015 90);
128
+ --line-strong: oklch(0.82 0.018 90);
129
+
130
+ /* desaturated-neon phosphor green, darkened for cream */
131
+ --accent: oklch(0.55 0.14 150);
132
+ --accent-hover: oklch(0.49 0.14 150);
133
+ --accent-fg: oklch(0.975 0.012 90);
134
+ --accent-soft: oklch(0.91 0.05 150);
135
+
136
+ /* slightly saturated cyan: selection & informational chips */
137
+ --accent-2: oklch(0.55 0.1 205);
138
+ --accent-2-soft: oklch(0.91 0.045 205);
139
+
140
+ /* positive converges toward the green family */
141
+ --positive: oklch(0.55 0.12 155);
142
+ /* muted amber, used sparingly */
143
+ --warning: oklch(0.65 0.13 80);
144
+ /* muted coral — the only warm color in the UI */
145
+ --danger: oklch(0.56 0.16 25);
146
+ --danger-soft: oklch(0.93 0.04 25);
147
+
148
+ --ring: oklch(0.55 0.14 150);
149
+ }
150
+
151
+ /* ---------- DARK: deep slate-teal, lifted off black ---------- */
152
+ .dark {
153
+ /* base03 direction */
154
+ --canvas: oklch(0.22 0.025 220);
155
+ --surface: oklch(0.25 0.025 220);
156
+ --surface-raised: oklch(0.28 0.025 220);
157
+ --surface-sunken: oklch(0.19 0.025 220);
158
+
159
+ /* warm off-white: cream text on teal slate (~12:1 on canvas) */
160
+ --fg: oklch(0.88 0.02 90);
161
+ --fg-muted: oklch(0.72 0.02 95);
162
+ --fg-subtle: oklch(0.58 0.022 110);
163
+
164
+ --line: oklch(0.3 0.025 220);
165
+ --line-strong: oklch(0.38 0.025 220);
166
+
167
+ /* phosphor green, brighter on slate */
168
+ --accent: oklch(0.78 0.16 150);
169
+ --accent-hover: oklch(0.83 0.15 150);
170
+ --accent-fg: oklch(0.2 0.04 150);
171
+ --accent-soft: oklch(0.32 0.06 150);
172
+
173
+ --accent-2: oklch(0.75 0.1 200);
174
+ --accent-2-soft: oklch(0.32 0.05 205);
175
+
176
+ --positive: oklch(0.74 0.13 155);
177
+ --warning: oklch(0.75 0.12 80);
178
+ --danger: oklch(0.68 0.15 25);
179
+ --danger-soft: oklch(0.32 0.07 25);
180
+
181
+ --ring: oklch(0.78 0.16 150);
182
+ }
183
+
184
+ html {
185
+ color-scheme: light;
186
+ }
187
+
188
+ html.dark,
189
+ .dark {
190
+ color-scheme: dark;
191
+ }
192
+
193
+ body {
194
+ margin: 0;
195
+ background-color: var(--color-canvas);
196
+ color: var(--color-fg);
197
+ font-family: var(--font-ui);
198
+ -webkit-font-smoothing: antialiased;
199
+ text-rendering: optimizeLegibility;
200
+ }
201
+
202
+ /* thin, unobtrusive scrollbars — calm density */
203
+ * {
204
+ scrollbar-width: thin;
205
+ scrollbar-color: var(--color-line-strong) transparent;
206
+ }
207
+ *::-webkit-scrollbar {
208
+ width: 8px;
209
+ height: 8px;
210
+ }
211
+ *::-webkit-scrollbar-thumb {
212
+ background-color: var(--color-line-strong);
213
+ border-radius: 9999px;
214
+ }
215
+ *::-webkit-scrollbar-track {
216
+ background: transparent;
217
+ }