@atelic-action/ui 0.2.0 → 0.4.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.
- package/README.md +67 -6
- package/package.json +4 -1
- package/src/components/NotFound.tsx +87 -0
- package/src/components/index.ts +1 -0
- package/src/email/index.ts +5 -0
- package/src/email/scoreboard.tsx +137 -27
- package/src/email/text.ts +44 -4
- package/src/routing/index.ts +31 -0
- package/src/styles/components.css +20 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @atelic-action/ui
|
|
2
2
|
|
|
3
|
-
Shared UI for the Atelic templates: the site chrome (header, menu, footer, sticky CTA bar, and credit band), the scroll spy hook, and the one base stylesheet. The marketing and artifact templates install it instead of carrying their own copies, so a chrome fix lands once and every site picks it up with `bun update`.
|
|
3
|
+
Shared UI for the Atelic templates: the site chrome (header, menu, footer, sticky CTA bar, and credit band), the not found page and the routing that keeps it alive, the scroll spy hook, and the one base stylesheet. The marketing and artifact templates install it instead of carrying their own copies, so a chrome fix lands once and every site picks it up with `bun update`.
|
|
4
4
|
|
|
5
5
|
The package ships source, not a build. Its TSX and CSS arrive as written and compile inside each site's own Vite.
|
|
6
6
|
|
|
@@ -30,22 +30,26 @@ Import the stylesheets in this order, from the root route or the site's base she
|
|
|
30
30
|
1. The site's fonts (`fonts.css`)
|
|
31
31
|
2. `@atelic-action/ui/styles/base.css`
|
|
32
32
|
3. `@atelic-action/ui/styles/chrome.css`
|
|
33
|
-
4.
|
|
34
|
-
5. The site's
|
|
33
|
+
4. `@atelic-action/ui/styles/components.css`
|
|
34
|
+
5. The site's own CSS
|
|
35
|
+
6. The site's `theme.css`, last
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
The package sheets sit inside `@layer atelic-ui`, so any rule a site writes outside a layer wins over them whatever its specificity. The package reads the theme tokens (`--ink`, `--surface`, `--primary`, `--nav-height`, and the rest) and defines none, so `theme.css` stays the one file a site edits to rebrand. The chrome's buttons wear the site's own `.btn` classes.
|
|
37
38
|
|
|
38
39
|
## What Is Inside
|
|
39
40
|
|
|
40
41
|
| Import | Exports |
|
|
41
42
|
|---|---|
|
|
42
43
|
| `@atelic-action/ui/chrome` | `SiteHeader`, `SiteMenu`, `Footer`, `CreditBar`, `StickyCTABar`, `BrandLockup`, `SkipLink`, and their prop types |
|
|
44
|
+
| `@atelic-action/ui/components` | `NotFound` and its prop types (see [The Not Found Page](#the-not-found-page)) |
|
|
43
45
|
| `@atelic-action/ui/email` | The email components, the theme provider, the plain text helpers, and their prop types (see [Email](#email)) |
|
|
44
46
|
| `@atelic-action/ui/email/render` | `renderEmail` and `renderFailureEmail`, the only entry that imports `react-dom/server` |
|
|
45
47
|
| `@atelic-action/ui/tokens` | `Palette`, `atelicPalette`, `Fonts`, `atelicFonts`, `toThemeCSS`, `themeTokenMap` |
|
|
46
48
|
| `@atelic-action/ui/hooks` | `useScrollSpy` and its `PageStop` type |
|
|
49
|
+
| `@atelic-action/ui/routing` | `staticNotFoundRouting`, the router options behind the not found page |
|
|
47
50
|
| `@atelic-action/ui/styles/base.css` | Resets, the `.mkt` canvas, typography, and layout helpers |
|
|
48
51
|
| `@atelic-action/ui/styles/chrome.css` | Styles for everything under `chrome` |
|
|
52
|
+
| `@atelic-action/ui/styles/components.css` | Layout defaults for everything under `components` |
|
|
49
53
|
|
|
50
54
|
Every component renders from props alone. None reads a config file or a router, so a site maps its own config onto the props in its shell:
|
|
51
55
|
|
|
@@ -69,6 +73,37 @@ import { Footer, SiteHeader, SkipLink, StickyCTABar } from "@atelic-action/ui/ch
|
|
|
69
73
|
|
|
70
74
|
The menu is a native `<dialog>` opened with `showModal()`, so Escape, focus containment, and focus return come from the browser.
|
|
71
75
|
|
|
76
|
+
## The Not Found Page
|
|
77
|
+
|
|
78
|
+
`NotFound` is the page an unknown path renders: a headline, a row of popular pages, and the closing call to action. It renders the body only, so a site wraps it in its own shell, and it wears the site's own classes (`.page-hero`, `.eyebrow`, `.lead`, `.final-cta`, `.btn`).
|
|
79
|
+
|
|
80
|
+
On a statically prerendered TanStack Start site the markup is the easy part. The host serves the `/404` prerender for every miss, and the page survives hydration only with three pieces in the site:
|
|
81
|
+
|
|
82
|
+
1. **A catch all route**, `src/routes/$.tsx`, rendering the site's page (noindexed). Never a dedicated `/404` route: an unknown path then matches only the root, TanStack's hydrate throws, and the page goes blank.
|
|
83
|
+
2. **The router options:** `createRouter({ routeTree, ...staticNotFoundRouting(NotFoundPage) })`. A miss hydrates through its pending state first, so pending has to render the same page or React reports a mismatch.
|
|
84
|
+
3. **The build:** a `{ path: "/404", prerender: { enabled: true }, sitemap: { exclude: true } }` entry in the Vite `pages` list, and a copy of `dist/client/404/index.html` to `dist/client/404.html`.
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
// src/shared/components/NotFoundPage.tsx
|
|
88
|
+
import { NotFound } from "@atelic-action/ui/components";
|
|
89
|
+
|
|
90
|
+
export function NotFoundPage() {
|
|
91
|
+
return (
|
|
92
|
+
<SiteShell site={site}>
|
|
93
|
+
<NotFound
|
|
94
|
+
title="This page wandered off."
|
|
95
|
+
lead="The link may be old, or the page may have moved."
|
|
96
|
+
links={site.nav.filter((item) => item.to !== "/").map(({ label, to }) => ({ label, href: to }))}
|
|
97
|
+
closing={{ eyebrow: "Back on Track", title: "Let's get you where you were headed." }}
|
|
98
|
+
primaryCTA={site.cta}
|
|
99
|
+
/>
|
|
100
|
+
</SiteShell>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`staticNotFoundRouting` is for prerendered sites only. On a live app the pending state is a real loading moment and the page would flash through every slow load, so an app sets `defaultNotFoundComponent` alone. A browser test is the only proof any of this works; template-marketing's `e2e/not-found.spec.ts` is the one to copy.
|
|
106
|
+
|
|
72
107
|
## Email
|
|
73
108
|
|
|
74
109
|
`@atelic-action/ui/email` is the runner email design as React components, ported one to one from the jq library the runners compose their mail from (homebase `runners/lib/email.jq`). The components are born inline: every layout is a table, every style is an inline style object, and there is no CSS file and no `className`, because Gmail strips a style block and ignores media queries on some accounts. The same components therefore mount on a web page as happily as they render into a mail client.
|
|
@@ -110,6 +145,31 @@ const html = renderEmail({
|
|
|
110
145
|
});
|
|
111
146
|
```
|
|
112
147
|
|
|
148
|
+
### Mobile First
|
|
149
|
+
|
|
150
|
+
Runner emails are mobile first. A layout that squashes on a phone is fixed here, in the components every runner shares, never in a report page. A fixed column table is for numbers alone and never carries a name; names go in a `RecordStack`.
|
|
151
|
+
|
|
152
|
+
`RecordStack` takes `records`, each a `RecordStackItem` (`title`, `url`, `meta`, `note`), and renders one table row per record: the title on its own line at 15px, bold and wrapping freely, linked when `url` is set; a mono meta line beneath it with the items joined by middle dots (empty items drop); an optional note beneath that; a hairline between records and none after the last. No cell carries a width, so a name of any length wraps at 320 pixels instead of squashing. `recordStackText(records)` is its plain text twin: each title on its own line under a two space indent, the meta and the note wrapped under a four space indent, a blank line between records, and no line past `textWidth` (68 columns, indent included).
|
|
153
|
+
|
|
154
|
+
`StatStrip` lays its stats out as inline block cells with an 88 pixel floor inside one centered cell, so six or seven stats flow onto a second row on a phone rather than shrinking. Each stat takes an optional `delta` (`"+3 · +12%"`), set small and muted under its label.
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
const records = [
|
|
158
|
+
{ title: "Pinewood Cabinetry", url: "https://example.test", meta: ["Lead", "Longmont", "fit 14"], note: "Answered the audit inside a day." },
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
<Card>
|
|
162
|
+
<Row last={false}>
|
|
163
|
+
<StatStrip stats={[{ n: 6, label: "Lead", delta: "+3 · +12%" }, { n: 2, label: "MQL" }]} />
|
|
164
|
+
</Row>
|
|
165
|
+
<Row last>
|
|
166
|
+
<RecordStack records={records} />
|
|
167
|
+
</Row>
|
|
168
|
+
</Card>;
|
|
169
|
+
|
|
170
|
+
const text = recordStackText(records);
|
|
171
|
+
```
|
|
172
|
+
|
|
113
173
|
`renderEmail` builds the document shell itself and puts only the rows through React, because React emits no doctype, React 19 hoists and reorders head tags, and it would escape the `>` in `details>summary`. `renderFailureEmail` is the same shell around `FailurePage`. Both live at `@atelic-action/ui/email/render`, apart from the components, so a site that mounts a component on a page never pulls React's server renderer into its browser bundle.
|
|
114
174
|
|
|
115
175
|
### The Mapping
|
|
@@ -143,7 +203,8 @@ Where the jq takes a pre rendered html string (`$rows`, `$body_html`, `$cells_ht
|
|
|
143
203
|
| `sub_eyebrow` | `SubEyebrow` | `text` |
|
|
144
204
|
| `badge` | `Badge` | `letter` |
|
|
145
205
|
| `day_strip` | `DayStrip` | `days`, `last` |
|
|
146
|
-
| `stat_strip` | `StatStrip` | `stats` |
|
|
206
|
+
| `stat_strip` | `StatStrip` | `stats`, each with an optional `delta` (the component wraps where the jq does not) |
|
|
207
|
+
| none | `RecordStack` | `records` (born here on 2026-09-24, with no jq counterpart) |
|
|
147
208
|
| `records` | `Records` | `columns`, `rows`; a cell's `html` is a `ReactNode` |
|
|
148
209
|
| `masthead` | `Masthead` | `title`, `wordmark` (defaults to `atelic`) |
|
|
149
210
|
| `title_card` | `TitleCard` | `eyebrowText`, `headlineLines`, `lede`, `stats` (the rows under the lede, in place of the jq's `$stats_html`) |
|
|
@@ -151,7 +212,7 @@ Where the jq takes a pre rendered html string (`$rows`, `$body_html`, `$cells_ht
|
|
|
151
212
|
| `page` | `renderEmail` | `title`, `preheader`, `children`, `palette`, `fonts` |
|
|
152
213
|
| `failure_page` | `renderFailureEmail`, or `FailurePage` as body rows | `runnerTitle`, `eyebrowText`, `reason`, `logTail` |
|
|
153
214
|
|
|
154
|
-
The plain text alternative part ports as plain functions with no React anywhere in them: `spaces`, `rpad`, `lpad`, `wrap`, `textRule`, `textSection`, `textRead`, `textBar`, `textTarget`, `textTableGrid`, `textTable`, plus `asciiUpcase` and `asciiDowncase`. Every width counts Unicode codepoints, the way jq's `length` does.
|
|
215
|
+
The plain text alternative part ports as plain functions with no React anywhere in them: `spaces`, `rpad`, `lpad`, `wrap`, `textRule`, `textSection`, `textRead`, `textBar`, `textTarget`, `textTableGrid`, `textTable`, `recordStackText`, `textWidth`, plus `asciiUpcase` and `asciiDowncase`. Every width counts Unicode codepoints, the way jq's `length` does.
|
|
155
216
|
|
|
156
217
|
`tst/email/expected/` holds frozen goldens generated from the jq library, and the component tests compare the rendered DOM against them. See that folder's README before touching one.
|
|
157
218
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atelic-action/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Shared UI for the Atelic templates: site chrome, base styles, and the component library they install",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -19,12 +19,15 @@
|
|
|
19
19
|
],
|
|
20
20
|
"exports": {
|
|
21
21
|
"./chrome": "./src/chrome/index.ts",
|
|
22
|
+
"./components": "./src/components/index.ts",
|
|
22
23
|
"./email": "./src/email/index.ts",
|
|
23
24
|
"./email/render": "./src/email/render.tsx",
|
|
24
25
|
"./hooks": "./src/hooks/index.ts",
|
|
26
|
+
"./routing": "./src/routing/index.ts",
|
|
25
27
|
"./tokens": "./src/tokens/index.ts",
|
|
26
28
|
"./styles/base.css": "./src/styles/base.css",
|
|
27
29
|
"./styles/chrome.css": "./src/styles/chrome.css",
|
|
30
|
+
"./styles/components.css": "./src/styles/components.css",
|
|
28
31
|
"./package.json": "./package.json"
|
|
29
32
|
},
|
|
30
33
|
"scripts": {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { newTabProps } from "../lib/newTabProps";
|
|
3
|
+
import type { CallToAction, NavLink } from "../types";
|
|
4
|
+
|
|
5
|
+
export interface NotFoundProps {
|
|
6
|
+
/** The kicker above the headline. Defaults to "404". */
|
|
7
|
+
eyebrow?: string;
|
|
8
|
+
title: ReactNode;
|
|
9
|
+
lead?: ReactNode;
|
|
10
|
+
/** A short list of pages worth landing on, usually the nav without Home. */
|
|
11
|
+
links?: NavLink[];
|
|
12
|
+
/** The heading and accessible name of the links row. Defaults to "Popular Pages". */
|
|
13
|
+
linksLabel?: string;
|
|
14
|
+
/** The closing section's kicker and headline. */
|
|
15
|
+
closing: { eyebrow?: string; title: ReactNode };
|
|
16
|
+
/** The site's primary conversion action, rendered first. */
|
|
17
|
+
primaryCTA: CallToAction;
|
|
18
|
+
/** The ghost action beside it. Defaults to "Back to Home" at "/". */
|
|
19
|
+
secondary?: NavLink;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The page a missing path renders: a headline, a row of popular pages, and
|
|
24
|
+
* the closing call to action. It renders the page body only; a site wraps it
|
|
25
|
+
* in its own shell, as it does every page. The markup wears the site's own
|
|
26
|
+
* classes (.page-hero, .eyebrow, .lead, .final-cta, .btn), so a site that
|
|
27
|
+
* styles them gets its own look, and components.css covers the layout where
|
|
28
|
+
* it does not.
|
|
29
|
+
*
|
|
30
|
+
* Keeping it alive through hydration takes routing as well as markup; see
|
|
31
|
+
* staticNotFoundRouting in the routing entry.
|
|
32
|
+
*/
|
|
33
|
+
export function NotFound({
|
|
34
|
+
eyebrow = "404",
|
|
35
|
+
title,
|
|
36
|
+
lead,
|
|
37
|
+
links = [],
|
|
38
|
+
linksLabel = "Popular Pages",
|
|
39
|
+
closing,
|
|
40
|
+
primaryCTA,
|
|
41
|
+
secondary = { label: "Back to Home", href: "/" },
|
|
42
|
+
}: NotFoundProps) {
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
<section className="page-hero not-found-hero">
|
|
46
|
+
<div className="wrap">
|
|
47
|
+
<span className="eyebrow">{eyebrow}</span>
|
|
48
|
+
<h1>{title}</h1>
|
|
49
|
+
{lead && <p className="lead">{lead}</p>}
|
|
50
|
+
</div>
|
|
51
|
+
</section>
|
|
52
|
+
{links.length > 0 && (
|
|
53
|
+
<section className="section not-found-links">
|
|
54
|
+
<div className="wrap center">
|
|
55
|
+
<span className="eyebrow center">{linksLabel}</span>
|
|
56
|
+
<nav aria-label={linksLabel} className="cta-row center">
|
|
57
|
+
{links.map((link) => (
|
|
58
|
+
<a key={link.href} className="btn btn-ghost" href={link.href}>
|
|
59
|
+
{link.label}
|
|
60
|
+
</a>
|
|
61
|
+
))}
|
|
62
|
+
</nav>
|
|
63
|
+
</div>
|
|
64
|
+
</section>
|
|
65
|
+
)}
|
|
66
|
+
<section className="section final-cta surface-alt not-found-closing">
|
|
67
|
+
<div className="wrap center">
|
|
68
|
+
{closing.eyebrow && <span className="eyebrow center">{closing.eyebrow}</span>}
|
|
69
|
+
<h2>{closing.title}</h2>
|
|
70
|
+
<div className="cta-row center">
|
|
71
|
+
<a
|
|
72
|
+
className="btn btn-primary btn-lg"
|
|
73
|
+
href={primaryCTA.href}
|
|
74
|
+
{...newTabProps(primaryCTA.href, primaryCTA.external)}
|
|
75
|
+
>
|
|
76
|
+
{primaryCTA.label}
|
|
77
|
+
<span className="arrow">{"→"}</span>
|
|
78
|
+
</a>
|
|
79
|
+
<a className="btn btn-ghost btn-lg" href={secondary.href}>
|
|
80
|
+
{secondary.label}
|
|
81
|
+
</a>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</section>
|
|
85
|
+
</>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { NotFound, type NotFoundProps } from "./NotFound";
|
package/src/email/index.ts
CHANGED
|
@@ -56,6 +56,8 @@ export {
|
|
|
56
56
|
type GroupRowProps,
|
|
57
57
|
ReadBlock,
|
|
58
58
|
type ReadBlockProps,
|
|
59
|
+
RecordStack,
|
|
60
|
+
type RecordStackProps,
|
|
59
61
|
Records,
|
|
60
62
|
type RecordsCell,
|
|
61
63
|
type RecordsColumn,
|
|
@@ -77,6 +79,8 @@ export {
|
|
|
77
79
|
asciiDowncase,
|
|
78
80
|
asciiUpcase,
|
|
79
81
|
lpad,
|
|
82
|
+
type RecordStackItem,
|
|
83
|
+
recordStackText,
|
|
80
84
|
rpad,
|
|
81
85
|
spaces,
|
|
82
86
|
type TextTarget,
|
|
@@ -87,6 +91,7 @@ export {
|
|
|
87
91
|
textTable,
|
|
88
92
|
textTableGrid,
|
|
89
93
|
textTarget,
|
|
94
|
+
textWidth,
|
|
90
95
|
wrap,
|
|
91
96
|
} from "./text";
|
|
92
97
|
export {
|
package/src/email/scoreboard.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Fragment, type ReactNode } from "react";
|
|
2
|
+
import type { RecordStackItem } from "./text";
|
|
2
3
|
import { eyebrowStyle, tableReset, useEmailTheme } from "./theme";
|
|
3
4
|
|
|
4
5
|
/*
|
|
@@ -323,50 +324,159 @@ export function DayStrip({ days, last }: DayStripProps) {
|
|
|
323
324
|
);
|
|
324
325
|
}
|
|
325
326
|
|
|
326
|
-
export type StatStripEntry = {
|
|
327
|
+
export type StatStripEntry = {
|
|
328
|
+
n: string | number;
|
|
329
|
+
label: string;
|
|
330
|
+
/** A change beside the number, small and muted under the label: "+3 · +12%". */
|
|
331
|
+
delta?: string;
|
|
332
|
+
};
|
|
327
333
|
export type StatStripProps = { stats: StatStripEntry[] };
|
|
328
334
|
|
|
329
|
-
/**
|
|
335
|
+
/**
|
|
336
|
+
* A strip of big numbers over eyebrow labels. Each stat is an inline block
|
|
337
|
+
* cell with a floor on its width inside one centered cell, so six or seven
|
|
338
|
+
* stats flow onto a second row on a phone instead of shrinking to nothing.
|
|
339
|
+
*/
|
|
330
340
|
export function StatStrip({ stats }: StatStripProps) {
|
|
331
341
|
const { palette, fonts } = useEmailTheme();
|
|
332
|
-
const
|
|
342
|
+
const share = `${Math.floor(100 / Math.max(stats.length, 1))}%`;
|
|
333
343
|
return (
|
|
334
344
|
<table {...tableReset} width="100%">
|
|
335
345
|
<tbody>
|
|
336
346
|
<tr>
|
|
337
|
-
{
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
style={{
|
|
343
|
-
padding: "12px 6px 10px",
|
|
344
|
-
borderTop: `2px solid ${palette.ink}`,
|
|
345
|
-
textAlign: "center",
|
|
346
|
-
verticalAlign: "top",
|
|
347
|
-
}}
|
|
348
|
-
>
|
|
349
|
-
<span
|
|
347
|
+
<td align="center" style={{ textAlign: "center" }}>
|
|
348
|
+
{stats.map((entry, i) => (
|
|
349
|
+
<div
|
|
350
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a stat's position is its identity
|
|
351
|
+
key={i}
|
|
350
352
|
style={{
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
353
|
+
display: "inline-block",
|
|
354
|
+
width: share,
|
|
355
|
+
minWidth: "88px",
|
|
356
|
+
boxSizing: "border-box",
|
|
357
|
+
verticalAlign: "top",
|
|
358
|
+
padding: "12px 6px 10px",
|
|
359
|
+
borderTop: `2px solid ${palette.ink}`,
|
|
360
|
+
textAlign: "center",
|
|
356
361
|
}}
|
|
357
362
|
>
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
363
|
+
<span
|
|
364
|
+
style={{
|
|
365
|
+
fontFamily: fonts.sans,
|
|
366
|
+
fontSize: "22px",
|
|
367
|
+
fontWeight: "600",
|
|
368
|
+
letterSpacing: "-0.02em",
|
|
369
|
+
color: palette.ink,
|
|
370
|
+
}}
|
|
371
|
+
>
|
|
372
|
+
{String(entry.n)}
|
|
373
|
+
</span>
|
|
374
|
+
<br />
|
|
375
|
+
<span style={{ ...eyebrowStyle(fonts), color: palette.faint }}>{entry.label}</span>
|
|
376
|
+
{entry.delta ? (
|
|
377
|
+
<>
|
|
378
|
+
<br />
|
|
379
|
+
<span
|
|
380
|
+
style={{ fontFamily: fonts.mono, fontSize: "11px", color: palette.faint }}
|
|
381
|
+
>
|
|
382
|
+
{entry.delta}
|
|
383
|
+
</span>
|
|
384
|
+
</>
|
|
385
|
+
) : null}
|
|
386
|
+
</div>
|
|
387
|
+
))}
|
|
388
|
+
</td>
|
|
364
389
|
</tr>
|
|
365
390
|
</tbody>
|
|
366
391
|
</table>
|
|
367
392
|
);
|
|
368
393
|
}
|
|
369
394
|
|
|
395
|
+
export type { RecordStackItem };
|
|
396
|
+
export type RecordStackProps = { records: RecordStackItem[] };
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* One record per row, stacked: the title on its own line, a mono meta line
|
|
400
|
+
* under it, an optional note under that. No column cells and no fixed widths,
|
|
401
|
+
* so a name of any length wraps instead of squashing on a phone. A fixed
|
|
402
|
+
* column table is for numbers alone; names go here.
|
|
403
|
+
*/
|
|
404
|
+
export function RecordStack({ records }: RecordStackProps) {
|
|
405
|
+
const { palette, fonts } = useEmailTheme();
|
|
406
|
+
return (
|
|
407
|
+
<table {...tableReset} width="100%" style={{ fontFamily: fonts.sans, color: palette.ink }}>
|
|
408
|
+
<tbody>
|
|
409
|
+
{records.map((record, i) => {
|
|
410
|
+
const last = i === records.length - 1;
|
|
411
|
+
const meta = record.meta.filter((m) => m !== "");
|
|
412
|
+
return (
|
|
413
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a record's position is its identity
|
|
414
|
+
<tr key={i}>
|
|
415
|
+
<td
|
|
416
|
+
style={{
|
|
417
|
+
padding: `${i === 0 ? "0" : "12px"} 0 ${last ? "0" : "12px"}`,
|
|
418
|
+
verticalAlign: "top",
|
|
419
|
+
...(last ? {} : { borderBottom: `1px solid ${palette.line}` }),
|
|
420
|
+
}}
|
|
421
|
+
>
|
|
422
|
+
<div
|
|
423
|
+
style={{
|
|
424
|
+
fontSize: "15px",
|
|
425
|
+
fontWeight: "600",
|
|
426
|
+
lineHeight: "1.35",
|
|
427
|
+
color: palette.ink,
|
|
428
|
+
wordBreak: "break-word",
|
|
429
|
+
}}
|
|
430
|
+
>
|
|
431
|
+
{record.url ? (
|
|
432
|
+
<a
|
|
433
|
+
href={record.url}
|
|
434
|
+
style={{
|
|
435
|
+
color: palette.ink,
|
|
436
|
+
textDecoration: "none",
|
|
437
|
+
borderBottom: `1px solid ${palette.accent}`,
|
|
438
|
+
}}
|
|
439
|
+
>
|
|
440
|
+
{record.title}
|
|
441
|
+
</a>
|
|
442
|
+
) : (
|
|
443
|
+
record.title
|
|
444
|
+
)}
|
|
445
|
+
</div>
|
|
446
|
+
{meta.length > 0 ? (
|
|
447
|
+
<div
|
|
448
|
+
style={{
|
|
449
|
+
fontFamily: fonts.mono,
|
|
450
|
+
fontSize: "12px",
|
|
451
|
+
lineHeight: "1.5",
|
|
452
|
+
color: palette.faint,
|
|
453
|
+
marginTop: "4px",
|
|
454
|
+
}}
|
|
455
|
+
>
|
|
456
|
+
{meta.join(" · ")}
|
|
457
|
+
</div>
|
|
458
|
+
) : null}
|
|
459
|
+
{record.note ? (
|
|
460
|
+
<div
|
|
461
|
+
style={{
|
|
462
|
+
fontSize: "13px",
|
|
463
|
+
lineHeight: "1.5",
|
|
464
|
+
color: palette.dim,
|
|
465
|
+
marginTop: "6px",
|
|
466
|
+
}}
|
|
467
|
+
>
|
|
468
|
+
{record.note}
|
|
469
|
+
</div>
|
|
470
|
+
) : null}
|
|
471
|
+
</td>
|
|
472
|
+
</tr>
|
|
473
|
+
);
|
|
474
|
+
})}
|
|
475
|
+
</tbody>
|
|
476
|
+
</table>
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
370
480
|
export type RecordsColumn = {
|
|
371
481
|
label: string;
|
|
372
482
|
/** Aligns the column right and keeps its cells on one line. */
|
package/src/email/text.ts
CHANGED
|
@@ -37,16 +37,25 @@ export function lpad(value: string, width: number): string {
|
|
|
37
37
|
return spaces(width - codepoints(value)) + value;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/**
|
|
41
|
-
export
|
|
40
|
+
/** The widest a plain text line runs, indent included: two spaces and 66 columns of prose. */
|
|
41
|
+
export const textWidth = 68;
|
|
42
|
+
|
|
43
|
+
/** Prose broken into lines of at most `columns` codepoints, each under `indent`. */
|
|
44
|
+
function wrapIndented(text: string, indent: number): string {
|
|
45
|
+
const columns = textWidth - indent;
|
|
42
46
|
const lines = [""];
|
|
43
47
|
for (const word of text.split(" ")) {
|
|
44
48
|
const last = lines[lines.length - 1];
|
|
45
49
|
if (codepoints(last) === 0) lines[lines.length - 1] = word;
|
|
46
|
-
else if (codepoints(last) + 1 + codepoints(word) >
|
|
50
|
+
else if (codepoints(last) + 1 + codepoints(word) > columns) lines.push(word);
|
|
47
51
|
else lines[lines.length - 1] = `${last} ${word}`;
|
|
48
52
|
}
|
|
49
|
-
return lines.map((line) =>
|
|
53
|
+
return lines.map((line) => `${spaces(indent)}${line}`).join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Prose wrapped at 66 columns under a two space indent. */
|
|
57
|
+
export function wrap(text: string): string {
|
|
58
|
+
return wrapIndented(text, 2);
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
export const textRule = "=".repeat(64);
|
|
@@ -114,3 +123,34 @@ export function textTableGrid(
|
|
|
114
123
|
export function textTable(cols: string[], rows: string[][], right: number[]): string {
|
|
115
124
|
return textTableGrid(cols, rows, right, null);
|
|
116
125
|
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* One record in a stack: what `RecordStack` renders and `recordStackText`
|
|
129
|
+
* writes. It lives here, with no React, so a plain text renderer can import it.
|
|
130
|
+
*/
|
|
131
|
+
export type RecordStackItem = {
|
|
132
|
+
title: string;
|
|
133
|
+
/** Links the title when set; the plain text twin leaves it out. */
|
|
134
|
+
url?: string | null;
|
|
135
|
+
/** Facts joined by a middle dot on the line under the title; empty items drop. */
|
|
136
|
+
meta: string[];
|
|
137
|
+
note?: string;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The plain text twin of `RecordStack`: each title on its own line under a
|
|
142
|
+
* two space indent, the meta joined by middle dots and the note beneath it,
|
|
143
|
+
* both wrapped under a four space indent, a blank line between records. No
|
|
144
|
+
* line runs past `textWidth`.
|
|
145
|
+
*/
|
|
146
|
+
export function recordStackText(records: RecordStackItem[]): string {
|
|
147
|
+
return records
|
|
148
|
+
.map((record) => {
|
|
149
|
+
const lines = [wrapIndented(record.title, 2)];
|
|
150
|
+
const meta = record.meta.filter((m) => m !== "");
|
|
151
|
+
if (meta.length > 0) lines.push(wrapIndented(meta.join(" · "), 4));
|
|
152
|
+
if (record.note) lines.push(wrapIndented(record.note, 4));
|
|
153
|
+
return lines.join("\n");
|
|
154
|
+
})
|
|
155
|
+
.join("\n\n");
|
|
156
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The router options that keep a not found page alive on a statically
|
|
3
|
+
* prerendered TanStack Start site. Spread them into createRouter:
|
|
4
|
+
*
|
|
5
|
+
* createRouter({ routeTree, ...staticNotFoundRouting(NotFoundPage) })
|
|
6
|
+
*
|
|
7
|
+
* The static host serves the /404 prerender for every unknown path, and it
|
|
8
|
+
* takes a catch all route (src/routes/$.tsx rendering the same page) plus
|
|
9
|
+
* these two options to survive hydration:
|
|
10
|
+
*
|
|
11
|
+
* - The splat gives the client a match below the root. With a dedicated
|
|
12
|
+
* /404 route instead, an unknown path matches only the root, hydrate takes
|
|
13
|
+
* its SPA branch, throws, and the page goes blank.
|
|
14
|
+
* - The splat's match id still carries the path, so it differs from the
|
|
15
|
+
* dehydrated /404 match and hydrate renders the pending state first.
|
|
16
|
+
* Pending as the page itself keeps that first render identical to the
|
|
17
|
+
* server HTML; the default null is a hydration mismatch.
|
|
18
|
+
*
|
|
19
|
+
* Static sites only. On a live app the pending state is a real loading
|
|
20
|
+
* moment, and this would flash the not found page through every slow load;
|
|
21
|
+
* an app sets defaultNotFoundComponent alone.
|
|
22
|
+
*
|
|
23
|
+
* Generic over the page so the router checks it against its own component
|
|
24
|
+
* type; the package stays free of any router import.
|
|
25
|
+
*/
|
|
26
|
+
export function staticNotFoundRouting<Page>(page: Page) {
|
|
27
|
+
return {
|
|
28
|
+
defaultNotFoundComponent: page,
|
|
29
|
+
defaultPendingComponent: page,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/* Layout defaults for the components under src/components. A site's own
|
|
2
|
+
classes (.page-hero, .eyebrow, .lead, .btn) arrive unlayered and win, so
|
|
3
|
+
these only fill in where a site does not style them. */
|
|
4
|
+
@layer atelic-ui {
|
|
5
|
+
.mkt .not-found-hero {
|
|
6
|
+
padding-top: calc(var(--nav-height, 72px) + clamp(40px, 7vw, 86px));
|
|
7
|
+
padding-bottom: clamp(28px, 5vw, 60px);
|
|
8
|
+
}
|
|
9
|
+
.mkt .not-found-hero h1 {
|
|
10
|
+
margin: 16px 0 0;
|
|
11
|
+
}
|
|
12
|
+
.mkt .not-found-hero .lead {
|
|
13
|
+
margin-top: 22px;
|
|
14
|
+
max-width: 56ch;
|
|
15
|
+
}
|
|
16
|
+
.mkt .not-found-links .cta-row,
|
|
17
|
+
.mkt .not-found-closing .cta-row {
|
|
18
|
+
margin-top: 22px;
|
|
19
|
+
}
|
|
20
|
+
}
|