react-email 4.0.0-alpha.6 → 4.0.0-alpha.8

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 (110) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/cli/index.js +18 -13
  3. package/dist/cli/index.mjs +26 -21
  4. package/dist/preview/.next/BUILD_ID +1 -1
  5. package/dist/preview/.next/app-build-manifest.json +14 -13
  6. package/dist/preview/.next/build-manifest.json +3 -3
  7. package/dist/preview/.next/cache/.rscinfo +1 -1
  8. package/dist/preview/.next/cache/webpack/client-production/0.pack +0 -0
  9. package/dist/preview/.next/cache/webpack/client-production/index.pack +0 -0
  10. package/dist/preview/.next/cache/webpack/edge-server-production/index.pack +0 -0
  11. package/dist/preview/.next/cache/webpack/server-production/0.pack +0 -0
  12. package/dist/preview/.next/cache/webpack/server-production/index.pack +0 -0
  13. package/dist/preview/.next/next-minimal-server.js.nft.json +1 -1
  14. package/dist/preview/.next/next-server.js.nft.json +1 -1
  15. package/dist/preview/.next/prerender-manifest.json +3 -3
  16. package/dist/preview/.next/required-server-files.json +3 -3
  17. package/dist/preview/.next/server/app/_not-found/page.js +1 -1
  18. package/dist/preview/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  19. package/dist/preview/.next/server/app/favicon.ico/route.js +1 -1
  20. package/dist/preview/.next/server/app/page.js +1 -1
  21. package/dist/preview/.next/server/app/page.js.nft.json +1 -1
  22. package/dist/preview/.next/server/app/page_client-reference-manifest.js +1 -1
  23. package/dist/preview/.next/server/app/preview/[...slug]/page.js +133 -25
  24. package/dist/preview/.next/server/app/preview/[...slug]/page.js.nft.json +1 -1
  25. package/dist/preview/.next/server/app/preview/[...slug]/page_client-reference-manifest.js +1 -1
  26. package/dist/preview/.next/server/chunks/42.js +1 -0
  27. package/dist/preview/.next/server/chunks/600.js +3 -3
  28. package/dist/preview/.next/server/chunks/{171.js → 816.js} +6 -6
  29. package/dist/preview/.next/server/middleware-build-manifest.js +1 -1
  30. package/dist/preview/.next/server/next-font-manifest.js +1 -1
  31. package/dist/preview/.next/server/next-font-manifest.json +1 -1
  32. package/dist/preview/.next/server/pages/500.html +1 -1
  33. package/dist/preview/.next/server/pages-manifest.json +1 -1
  34. package/dist/preview/.next/server/server-reference-manifest.js +1 -1
  35. package/dist/preview/.next/server/server-reference-manifest.json +1 -1
  36. package/dist/preview/.next/static/chunks/287-7864b805e6bdc854.js +1 -0
  37. package/dist/preview/.next/static/chunks/412-31817e53b50a3e73.js +1 -0
  38. package/dist/preview/.next/static/chunks/683-b769e5d91bdf9a82.js +1 -0
  39. package/dist/preview/.next/static/chunks/880-9c0b721328117b8b.js +1 -0
  40. package/dist/preview/.next/static/chunks/app/layout-7dee682873546401.js +1 -0
  41. package/dist/preview/.next/static/chunks/app/page-9ea0bd45cd6294b0.js +1 -0
  42. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-a610d641c64448cc.js +1 -0
  43. package/dist/preview/.next/static/chunks/{main-app-c2e686acf8d370d7.js → main-app-256b213b179a95cc.js} +1 -1
  44. package/dist/preview/.next/static/css/e68ebc9bb8f7b3f4.css +3 -0
  45. package/dist/preview/.next/trace +26 -26
  46. package/dist/preview/.next/types/app/layout.ts +1 -1
  47. package/dist/preview/.next/types/app/page.ts +84 -0
  48. package/dist/preview/.next/types/app/preview/[...slug]/page.ts +1 -1
  49. package/package.json +1 -1
  50. package/src/actions/email-validation/check-compatibility.ts +16 -5
  51. package/src/actions/email-validation/check-images.spec.tsx +13 -11
  52. package/src/actions/email-validation/check-images.ts +6 -0
  53. package/src/actions/email-validation/check-links.spec.tsx +23 -11
  54. package/src/actions/email-validation/check-links.ts +6 -0
  55. package/src/actions/email-validation/get-code-location-from-ast-element.ts +18 -0
  56. package/src/actions/render-email-by-path.tsx +2 -2
  57. package/src/app/env.ts +3 -0
  58. package/src/app/preview/[...slug]/page.tsx +24 -11
  59. package/src/app/preview/[...slug]/preview.tsx +15 -12
  60. package/src/components/code-container.tsx +90 -71
  61. package/src/components/code.tsx +106 -42
  62. package/src/components/icons/icon-info.tsx +18 -0
  63. package/src/components/icons/icon-reload.tsx +13 -14
  64. package/src/components/logo.tsx +3 -2
  65. package/src/components/resizable-wrapper.tsx +1 -4
  66. package/src/components/sidebar/file-tree-directory-children.tsx +13 -2
  67. package/src/components/sidebar/file-tree-directory.tsx +26 -18
  68. package/src/components/sidebar/file-tree.tsx +2 -2
  69. package/src/components/sidebar/sidebar.tsx +16 -19
  70. package/src/components/toolbar/code-preview-line-link.tsx +40 -0
  71. package/src/components/toolbar/compatibility.tsx +113 -0
  72. package/src/components/toolbar/linter.tsx +69 -111
  73. package/src/components/toolbar/results.tsx +5 -2
  74. package/src/components/toolbar/spam-assassin.tsx +31 -20
  75. package/src/components/toolbar/toolbar-button.tsx +4 -2
  76. package/src/components/toolbar/use-cached-state.ts +2 -2
  77. package/src/components/toolbar.tsx +152 -30
  78. package/src/components/tooltip-content.tsx +1 -1
  79. package/src/components/topbar/view-size-controls.tsx +1 -2
  80. package/src/components/topbar.tsx +1 -20
  81. package/src/contexts/fragment-identifier.tsx +46 -0
  82. package/src/hooks/use-fragment-identifier.ts +14 -0
  83. package/src/utils/__snapshots__/get-email-component.spec.ts.snap +1 -1
  84. package/src/utils/caniemail/ast/__snapshots__/get-object-variables.spec.ts.snap +74 -0
  85. package/src/utils/caniemail/ast/__snapshots__/get-used-style-properties.spec.ts.snap +24 -0
  86. package/src/utils/caniemail/ast/get-object-variables.spec.ts +19 -0
  87. package/src/utils/caniemail/ast/get-used-style-properties.spec.ts +23 -0
  88. package/src/utils/caniemail/get-css-property-with-value.ts +2 -2
  89. package/src/utils/caniemail/tailwind/get-tailwind-config.ts +0 -2
  90. package/src/utils/get-email-component.ts +1 -1
  91. package/src/utils/get-line-and-column-from-offset.spec.ts +11 -0
  92. package/src/utils/get-line-and-column-from-offset.ts +11 -0
  93. package/src/utils/index.ts +1 -0
  94. package/src/utils/linting.ts +5 -30
  95. package/src/utils/load-stream.ts +15 -0
  96. package/src/utils/sanitize.ts +6 -0
  97. package/dist/preview/.next/server/chunks/833.js +0 -1
  98. package/dist/preview/.next/static/chunks/416-56f79fc7e689f06f.js +0 -1
  99. package/dist/preview/.next/static/chunks/683-8bbfd191e5105f01.js +0 -1
  100. package/dist/preview/.next/static/chunks/87-38e35f08507de015.js +0 -1
  101. package/dist/preview/.next/static/chunks/app/layout-a6640e62690d8fd6.js +0 -1
  102. package/dist/preview/.next/static/chunks/app/page-ba68f50b287e7478.js +0 -1
  103. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-4a5b026ab543e27f.js +0 -1
  104. package/dist/preview/.next/static/css/d7df9cfc3e182163.css +0 -3
  105. package/src/actions/email-validation/get-line-and-column-from-index.spec.ts +0 -22
  106. package/src/actions/email-validation/get-line-and-column-from-index.ts +0 -43
  107. package/src/components/icons/icon-scanner.tsx +0 -19
  108. package/src/components/icons/icon-scissors.tsx +0 -19
  109. /package/dist/preview/.next/static/{gFk9UfWL8joM4iD7-wlKF → SoPVDfPAp9R983pBBriVn}/_buildManifest.js +0 -0
  110. /package/dist/preview/.next/static/{gFk9UfWL8joM4iD7-wlKF → SoPVDfPAp9R983pBBriVn}/_ssgManifest.js +0 -0
@@ -0,0 +1,113 @@
1
+ import { useRef, useState } from 'react';
2
+ import { toast } from 'sonner';
3
+ import { nicenames } from '../../actions/email-validation/caniemail-data';
4
+ import {
5
+ type CompatibilityCheckingResult,
6
+ checkCompatibility,
7
+ } from '../../actions/email-validation/check-compatibility';
8
+ import { sanitize } from '../../utils';
9
+ import { loadStream } from '../../utils/load-stream';
10
+ import { IconWarning } from '../icons/icon-warning';
11
+ import { CodePreviewLineLink } from './code-preview-line-link';
12
+ import { Results } from './results';
13
+
14
+ export const useCompatibility = ({
15
+ reactMarkup,
16
+ emailPath,
17
+
18
+ initialResults,
19
+ }: {
20
+ reactMarkup: string;
21
+ emailPath: string;
22
+
23
+ initialResults?: CompatibilityCheckingResult[];
24
+ }) => {
25
+ const [results, setResults] = useState(initialResults);
26
+
27
+ const [loading, setLoading] = useState(false);
28
+ const isLoadingRef = useRef(false);
29
+
30
+ const load = async () => {
31
+ if (isLoadingRef.current) return;
32
+ isLoadingRef.current = true;
33
+ setLoading(true);
34
+
35
+ setResults([]);
36
+ let rawResults: CompatibilityCheckingResult[] = [];
37
+
38
+ try {
39
+ const stream = await checkCompatibility(reactMarkup, emailPath);
40
+ for await (const result of loadStream(stream)) {
41
+ if (result.status !== 'error') continue;
42
+ setResults((current) => {
43
+ if (!current) {
44
+ return [result];
45
+ }
46
+ rawResults = [...current, result];
47
+ return rawResults;
48
+ });
49
+ }
50
+ } catch (exception) {
51
+ console.error(exception);
52
+ toast.error(JSON.stringify(exception));
53
+ } finally {
54
+ setLoading(false);
55
+ isLoadingRef.current = false;
56
+ }
57
+
58
+ return rawResults;
59
+ };
60
+
61
+ return [results, { loading, load }] as const;
62
+ };
63
+
64
+ interface CompatibilityProps {
65
+ results: CompatibilityCheckingResult[] | undefined;
66
+ }
67
+
68
+ export const Compatibility = ({ results }: CompatibilityProps) => {
69
+ return (
70
+ <Results>
71
+ {results?.map((result, i) => {
72
+ const statsReportedNotWorking = Object.entries(
73
+ result.statsPerEmailClient,
74
+ ).filter(([, stats]) => stats.status === 'error');
75
+ const unsupportedClientsString = statsReportedNotWorking
76
+ .map(([emailClient]) => nicenames.family[emailClient])
77
+ .join(', ');
78
+
79
+ return (
80
+ <Results.Row key={i}>
81
+ <Results.Column>
82
+ <span className="flex text-red-400 uppercase gap-2 items-center">
83
+ <IconWarning />
84
+ {sanitize(result.entry.title)}
85
+ </span>
86
+ </Results.Column>
87
+ <Results.Column>
88
+ {statsReportedNotWorking.length > 0
89
+ ? `Not supported in ${unsupportedClientsString}`
90
+ : null}
91
+
92
+ <a
93
+ href={result.entry.url}
94
+ className="underline ml-2 decoration-slate-9 decoration-1 hover:decoration-slate-11 transition-colors hover:text-slate-12"
95
+ rel="noreferrer"
96
+ target="_blank"
97
+ >
98
+ More ↗
99
+ </a>
100
+ </Results.Column>
101
+ <Results.Column className="font-mono text-slate-11 text-right">
102
+ <CodePreviewLineLink
103
+ line={result.location.start.line}
104
+ column={result.location.start.column}
105
+ type="react"
106
+ />
107
+ </Results.Column>
108
+ </Results.Row>
109
+ );
110
+ })}
111
+ </Results>
112
+ );
113
+ };
@@ -1,12 +1,11 @@
1
1
  import prettyBytes from 'pretty-bytes';
2
- import { useRef, useState } from 'react';
3
- import { nicenames } from '../../actions/email-validation/caniemail-data';
4
- import type { CompatibilityCheckingResult } from '../../actions/email-validation/check-compatibility';
2
+ import { Children, useRef, useState } from 'react';
5
3
  import type { ImageCheckingResult } from '../../actions/email-validation/check-images';
6
4
  import type { LinkCheckingResult } from '../../actions/email-validation/check-links';
7
- import { cn } from '../../utils';
5
+ import { cn, sanitize } from '../../utils';
8
6
  import { getLintingSources, loadLintingRowsFrom } from '../../utils/linting';
9
7
  import { IconWarning } from '../icons/icon-warning';
8
+ import { CodePreviewLineLink } from './code-preview-line-link';
10
9
  import { Results } from './results';
11
10
 
12
11
  export type LintingRow =
@@ -17,10 +16,6 @@ export type LintingRow =
17
16
  | {
18
17
  source: 'link';
19
18
  result: LinkCheckingResult;
20
- }
21
- | {
22
- source: 'compatibility';
23
- result: CompatibilityCheckingResult;
24
19
  };
25
20
 
26
21
  interface LinterProps {
@@ -29,14 +24,10 @@ interface LinterProps {
29
24
 
30
25
  export const useLinter = ({
31
26
  markup,
32
- reactMarkup,
33
- emailPath,
34
27
 
35
28
  initialRows,
36
29
  }: {
37
- reactMarkup: string;
38
30
  markup: string;
39
- emailPath: string;
40
31
 
41
32
  initialRows?: LintingRow[];
42
33
  }) => {
@@ -44,8 +35,6 @@ export const useLinter = ({
44
35
 
45
36
  const sources = getLintingSources(
46
37
  markup,
47
- reactMarkup,
48
- emailPath,
49
38
  'location' in global
50
39
  ? `${global.location.protocol}//${global.location.host}`
51
40
  : '',
@@ -105,80 +94,44 @@ export const Linter = ({ rows }: LinterProps) => {
105
94
  )!;
106
95
  return (
107
96
  <Result status={row.result.status} key={i}>
108
- <Result.Name>{failingCheck.type}</Result.Name>
97
+ <Result.Name>{sanitize(failingCheck.type)}</Result.Name>
109
98
  <Result.Description>
110
99
  {failingCheck.type === 'security'
111
- ? 'Insecure URL, use HTTPS insted of HTTP'
100
+ ? 'Insecure URL, use HTTPS instead of HTTP'
112
101
  : null}
113
102
  {failingCheck.type === 'fetch_attempt' &&
114
103
  failingCheck.metadata.fetchStatusCode &&
115
104
  failingCheck.metadata.fetchStatusCode >= 300 &&
116
- failingCheck.metadata.fetchStatusCode < 400
117
- ? 'There was a redirect, the content may have been moved'
118
- : null}
105
+ failingCheck.metadata.fetchStatusCode < 400 ? (
106
+ <>
107
+ <strong>{failingCheck.metadata.fetchStatusCode}</strong>:
108
+ There was a redirect, the content may have been moved
109
+ </>
110
+ ) : null}
119
111
  {failingCheck.type === 'fetch_attempt' &&
120
112
  failingCheck.metadata.fetchStatusCode &&
121
- failingCheck.metadata.fetchStatusCode >= 400
122
- ? 'The link is broken'
123
- : null}
113
+ failingCheck.metadata.fetchStatusCode >= 400 ? (
114
+ <>
115
+ <strong>{failingCheck.metadata.fetchStatusCode}</strong>:
116
+ The link is broken
117
+ </>
118
+ ) : null}
124
119
  {failingCheck.type === 'syntax'
125
120
  ? 'The link is broken due to invalid syntax'
126
121
  : null}
127
122
 
128
- <span className="font-mono float-right text-ellipsis overflow-hidden text-nowrap max-w-[30ch]">
123
+ <span className="ml-2 text-ellipsis overflow-hidden text-nowrap max-w-[30ch]">
129
124
  {row.result.link}
130
125
  </span>
131
126
  </Result.Description>
132
127
  <Result.Metadata>
133
- {failingCheck.type === 'fetch_attempt'
134
- ? failingCheck.metadata.fetchStatusCode
135
- : ''}
136
- </Result.Metadata>
137
- </Result>
138
- );
139
- }
140
-
141
- if (row.source === 'compatibility') {
142
- const statsReportedNotWorking = Object.entries(
143
- row.result.statsPerEmailClient,
144
- ).filter(([, stats]) => stats.status === 'error');
145
- const statsReportedPartiallyWorking = Object.entries(
146
- row.result.statsPerEmailClient,
147
- ).filter(([, stats]) => stats.status === 'warning');
148
-
149
- const unsupportedClientsString = statsReportedNotWorking
150
- .map(([emailClient]) => nicenames.family[emailClient])
151
- .join(', ');
152
- const partiallySupportedClientsString = statsReportedPartiallyWorking
153
- .map(([emailClient]) => nicenames.family[emailClient])
154
- .join(', ');
155
-
156
- return (
157
- <Result status={row.result.status} key={i}>
158
- <Result.Name>{row.result.entry.title}</Result.Name>
159
- <Result.Description>
160
- {statsReportedNotWorking.length > 0
161
- ? `Not supported in ${unsupportedClientsString}`
162
- : null}
163
- {statsReportedPartiallyWorking.length > 0 &&
164
- statsReportedNotWorking.length > 0
165
- ? '. '
166
- : null}
167
- {statsReportedPartiallyWorking.length > 0
168
- ? `Partially supported in ${partiallySupportedClientsString}`
169
- : null}
170
- </Result.Description>
171
- <Result.Metadata>
172
- {row.result.location.start.line.toString().padStart(2, '0')}:
173
- {row.result.location.start.column.toString().padStart(2, '0')}
174
- <a
175
- href={row.result.entry.url}
176
- className="underline ml-2"
177
- rel="noreferrer"
178
- target="_blank"
179
- >
180
- See more info
181
- </a>
128
+ {[
129
+ <CodePreviewLineLink
130
+ line={row.result.codeLocation.line}
131
+ column={row.result.codeLocation.column}
132
+ type="html"
133
+ />,
134
+ ]}
182
135
  </Result.Metadata>
183
136
  </Result>
184
137
  );
@@ -188,24 +141,43 @@ export const Linter = ({ rows }: LinterProps) => {
188
141
  const failingCheck = row.result.checks.find(
189
142
  (check) => check.passed === false,
190
143
  )!;
144
+ const metadata: React.ReactNode[] = [];
145
+ for (const check of row.result.checks) {
146
+ if (check.type === 'image_size' && check.metadata.byteCount) {
147
+ metadata.push(prettyBytes(check.metadata.byteCount));
148
+ }
149
+ }
150
+ metadata.push(
151
+ <CodePreviewLineLink
152
+ line={row.result.codeLocation.line}
153
+ column={row.result.codeLocation.column}
154
+ type="html"
155
+ />,
156
+ );
191
157
  return (
192
158
  <Result status={row.result.status} key={i}>
193
- <Result.Name>{failingCheck.type}</Result.Name>
159
+ <Result.Name>{sanitize(failingCheck.type)}</Result.Name>
194
160
  <Result.Description>
195
161
  {failingCheck.type === 'security'
196
- ? 'Insecure URL, use HTTPS insted of HTTP'
162
+ ? 'Insecure URL, use HTTPS instead of HTTP'
197
163
  : null}
198
164
  {failingCheck.type === 'fetch_attempt' &&
199
165
  failingCheck.metadata.fetchStatusCode &&
200
166
  failingCheck.metadata.fetchStatusCode >= 300 &&
201
- failingCheck.metadata.fetchStatusCode < 400
202
- ? 'There was a redirect, the image may have been moved'
203
- : null}
167
+ failingCheck.metadata.fetchStatusCode < 400 ? (
168
+ <>
169
+ <strong>{failingCheck.metadata.fetchStatusCode}</strong>:
170
+ There was a redirect, the image may have been moved
171
+ </>
172
+ ) : null}
204
173
  {failingCheck.type === 'fetch_attempt' &&
205
174
  failingCheck.metadata.fetchStatusCode &&
206
- failingCheck.metadata.fetchStatusCode >= 400
207
- ? 'The image is broken'
208
- : null}
175
+ failingCheck.metadata.fetchStatusCode >= 400 ? (
176
+ <>
177
+ <strong>{failingCheck.metadata.fetchStatusCode}</strong>:
178
+ The image is broken
179
+ </>
180
+ ) : null}
209
181
  {failingCheck.type === 'syntax'
210
182
  ? 'The image is broken due to an invalid source'
211
183
  : null}
@@ -219,32 +191,11 @@ export const Linter = ({ rows }: LinterProps) => {
219
191
  ? 'This image is too large, keep it under 1mb'
220
192
  : null}
221
193
 
222
- <span className="font-mono float-right text-ellipsis overflow-hidden text-nowrap max-w-[30ch]">
194
+ <span className="ml-2 text-ellipsis overflow-hidden text-nowrap max-w-[30ch]">
223
195
  {row.result.source}
224
196
  </span>
225
197
  </Result.Description>
226
- <Result.Metadata>
227
- {row.result.checks
228
- .map((check) => {
229
- if (
230
- check.type === 'fetch_attempt' &&
231
- check.metadata.fetchStatusCode
232
- ) {
233
- return check.metadata.fetchStatusCode;
234
- }
235
-
236
- if (
237
- check.type === 'image_size' &&
238
- check.metadata.byteCount
239
- ) {
240
- return prettyBytes(check.metadata.byteCount);
241
- }
242
-
243
- return undefined;
244
- })
245
- .filter(Boolean)
246
- .join('—')}
247
- </Result.Metadata>
198
+ <Result.Metadata>{metadata}</Result.Metadata>
248
199
  </Result>
249
200
  );
250
201
  }
@@ -277,9 +228,9 @@ Result.Name = ({
277
228
  }: React.ComponentProps<typeof Results.Column>) => {
278
229
  return (
279
230
  <Results.Column {...props}>
280
- <span className="flex uppercase gap-1 items-center group-data-[status=error]/result:text-red-400 group-data-[status=warning]/result:text-orange-300">
231
+ <span className="flex uppercase gap-2 items-center group-data-[status=error]/result:text-red-400 group-data-[status=warning]/result:text-orange-300">
281
232
  <IconWarning />
282
- {children}
233
+ {typeof children === 'string' ? sanitize(children) : children}
283
234
  </span>
284
235
  </Results.Column>
285
236
  );
@@ -293,18 +244,25 @@ Result.Description = ({
293
244
  return <Results.Column {...props}>{children}</Results.Column>;
294
245
  };
295
246
 
296
- Result.Metadata = ({
297
- children,
298
- className,
299
- ...props
300
- }: React.ComponentProps<typeof Results.Column>) => {
247
+ interface MetadatProps extends React.ComponentProps<typeof Results.Column> {
248
+ children: React.ReactNode[];
249
+ }
250
+
251
+ Result.Metadata = ({ children, className, ...props }: MetadatProps) => {
301
252
  return (
302
253
  <Results.Column
303
254
  align="right"
304
255
  {...props}
305
256
  className={cn('font-mono text-slate-11', className)}
306
257
  >
307
- {children}
258
+ {Children.map(children, (child, index) => {
259
+ return (
260
+ <>
261
+ {index > 0 ? ' · ' : null}
262
+ {child}
263
+ </>
264
+ );
265
+ })}
308
266
  </Results.Column>
309
267
  );
310
268
  };
@@ -8,7 +8,7 @@ export const Results = ({
8
8
  return (
9
9
  <table
10
10
  className={cn(
11
- 'group relative w-full border-collapse text-left text-slate-10 text-xs',
11
+ 'group relative w-full border-collapse text-left text-slate-10 text-sm',
12
12
  className,
13
13
  )}
14
14
  >
@@ -41,7 +41,10 @@ Results.Column = ({
41
41
  ...props
42
42
  }: React.ComponentProps<'td'>) => {
43
43
  return (
44
- <td className={cn('py-1 align-bottom font-medium', className)} {...props}>
44
+ <td
45
+ className={cn('py-1.5 align-bottom font-regular', className)}
46
+ {...props}
47
+ >
45
48
  {children}
46
49
  </td>
47
50
  );
@@ -1,6 +1,6 @@
1
1
  import { useRef, useState } from 'react';
2
2
  import { toast } from 'sonner';
3
- import { cn } from '../../utils';
3
+ import { cn, sanitize } from '../../utils';
4
4
  import { IconWarning } from '../icons/icon-warning';
5
5
  import { Results } from './results';
6
6
 
@@ -83,44 +83,55 @@ export const SpamAssassin = ({ result }: SpamAssassinProps) => {
83
83
  <>
84
84
  {result ? (
85
85
  <Results>
86
- <Results.Row className="sticky border-b-2 top-0 bg-black">
86
+ <Results.Row className="sticky border-b top-0 bg-black">
87
87
  <Results.Column className="uppercase">
88
- <span className="flex gap-1 items-center">
88
+ <span className="flex gap-2 items-center">
89
89
  <IconWarning
90
90
  className={cn(
91
- result.points > 1.5 ? 'text-yellow-200' : null,
92
- result.points > 3 ? 'text-orange-300' : null,
91
+ result.points === 0 ? 'text-green-400' : null,
92
+ result.points > 0 && result.points <= 1.5 ? null : null,
93
+ result.points > 1.5 ? 'text-yellow-100' : null,
94
+ result.points > 3 ? 'text-orange-400' : null,
93
95
  result.points >= 5 ? 'text-red-400' : null,
94
96
  )}
95
97
  />
96
98
  Score
97
99
  </span>
98
100
  </Results.Column>
99
- <Results.Column>Lower scores are better</Results.Column>
100
- <Results.Column
101
- className={cn(
102
- 'text-right text-2xl tracking-tighter font-mono',
103
- result.points > 1.5 ? 'text-yellow-200' : null,
104
- result.points > 3 ? 'text-orange-300' : null,
105
- result.points >= 5 ? 'text-red-400' : null,
106
- )}
107
- >
108
- {result.points.toFixed(1)}
101
+ <Results.Column>
102
+ {result.points === 0
103
+ ? 'Congratulations! Your email is clean of abuse indicators.'
104
+ : 'Higher scores are better'}
105
+ </Results.Column>
106
+ <Results.Column className="text-right tracking-tighter font-bold">
107
+ <span
108
+ className={cn(
109
+ 'text-3xl',
110
+ result.points === 0 ? 'text-green-400' : null,
111
+ result.points > 0 && result.points <= 1.5 ? null : null,
112
+ result.points > 1.5 ? 'text-yellow-200' : null,
113
+ result.points > 3 ? 'text-orange-400' : null,
114
+ result.points >= 5 ? 'text-red-400' : null,
115
+ )}
116
+ >
117
+ {(10 - result.points).toFixed(1)}
118
+ </span>{' '}
119
+ <span className="text-lg">/ 10</span>
109
120
  </Results.Column>
110
121
  </Results.Row>
111
122
  {toSorted(result.checks, (a, b) => b.points - a.points).map(
112
123
  (check) => (
113
124
  <Results.Row key={check.name}>
114
125
  <Results.Column className="uppercase">
115
- <span className="flex gap-1 items-center">
126
+ <span className="flex gap-2 items-center">
116
127
  <IconWarning
117
128
  className={cn(
118
129
  check.points > 1 ? 'text-yellow-200' : null,
119
- check.points > 2 ? 'text-orange-300' : null,
130
+ check.points > 2 ? 'text-orange-400' : null,
120
131
  check.points > 3 ? 'text-red-400' : null,
121
132
  )}
122
133
  />
123
- {check.name}
134
+ {sanitize(check.name)}
124
135
  </span>
125
136
  </Results.Column>
126
137
  <Results.Column>{check.description}</Results.Column>
@@ -128,11 +139,11 @@ export const SpamAssassin = ({ result }: SpamAssassinProps) => {
128
139
  className={cn(
129
140
  'text-right font-mono tracking-tighter',
130
141
  check.points > 1 ? 'text-yellow-200' : null,
131
- check.points > 2 ? 'text-orange-300' : null,
142
+ check.points > 2 ? 'text-orange-400' : null,
132
143
  check.points > 3 ? 'text-red-400' : null,
133
144
  )}
134
145
  >
135
- {check.points.toFixed(1)}
146
+ -{check.points.toFixed(1)}
136
147
  </Results.Column>
137
148
  </Results.Row>
138
149
  ),
@@ -6,6 +6,7 @@ interface ToolbarButtonProps extends React.ComponentProps<'button'> {
6
6
  children: React.ReactNode;
7
7
  active?: boolean;
8
8
  tooltip?: React.ReactNode;
9
+ delayDuration?: number;
9
10
  }
10
11
 
11
12
  export const ToolbarButton = ({
@@ -13,17 +14,18 @@ export const ToolbarButton = ({
13
14
  className,
14
15
  active,
15
16
  tooltip,
17
+ delayDuration = 500,
16
18
  ...props
17
19
  }: ToolbarButtonProps) => {
18
20
  return (
19
21
  <Tooltip.Provider>
20
- <Tooltip>
22
+ <Tooltip delayDuration={delayDuration}>
21
23
  <Tooltip.Trigger asChild>
22
24
  <button
23
25
  type="button"
24
26
  {...props}
25
27
  className={cn(
26
- 'h-full w-fit font-medium flex text-sm text-slate-10 items-center align-middle justify-center px-1 py-2 gap-1 relative',
28
+ 'h-full w-fit font-regular flex text-sm text-slate-10 items-center align-middle justify-center px-1 gap-2 relative',
27
29
  'hover:text-slate-12 transition-colors',
28
30
  active && 'data-[state=active]:text-cyan-11',
29
31
  className,
@@ -4,7 +4,7 @@ export const useCachedState = <T>(key: string) => {
4
4
  let value: T | undefined = undefined;
5
5
  if ('localStorage' in global) {
6
6
  const storedValue = global.localStorage.getItem(key);
7
- if (storedValue !== null) {
7
+ if (storedValue !== null && storedValue !== 'undefined') {
8
8
  try {
9
9
  value = JSON.parse(storedValue) as T;
10
10
  } catch (exception) {
@@ -12,7 +12,7 @@ export const useCachedState = <T>(key: string) => {
12
12
  'Failed to load stored value for',
13
13
  key,
14
14
  'with value',
15
- value,
15
+ storedValue,
16
16
  );
17
17
  }
18
18
  }