@atelic-action/ui 0.1.0 → 0.3.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 +133 -5
- package/package.json +7 -1
- package/src/components/NotFound.tsx +87 -0
- package/src/components/index.ts +1 -0
- package/src/email/atoms.tsx +465 -0
- package/src/email/frame.tsx +230 -0
- package/src/email/index.ts +98 -0
- package/src/email/render.tsx +116 -0
- package/src/email/scoreboard.tsx +479 -0
- package/src/email/text.ts +116 -0
- package/src/email/theme.tsx +77 -0
- package/src/routing/index.ts +31 -0
- package/src/styles/components.css +20 -0
- package/src/tokens/css.ts +27 -0
- package/src/tokens/index.ts +2 -0
- package/src/tokens/palettes.ts +54 -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,19 +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)) |
|
|
45
|
+
| `@atelic-action/ui/email` | The email components, the theme provider, the plain text helpers, and their prop types (see [Email](#email)) |
|
|
46
|
+
| `@atelic-action/ui/email/render` | `renderEmail` and `renderFailureEmail`, the only entry that imports `react-dom/server` |
|
|
47
|
+
| `@atelic-action/ui/tokens` | `Palette`, `atelicPalette`, `Fonts`, `atelicFonts`, `toThemeCSS`, `themeTokenMap` |
|
|
43
48
|
| `@atelic-action/ui/hooks` | `useScrollSpy` and its `PageStop` type |
|
|
49
|
+
| `@atelic-action/ui/routing` | `staticNotFoundRouting`, the router options behind the not found page |
|
|
44
50
|
| `@atelic-action/ui/styles/base.css` | Resets, the `.mkt` canvas, typography, and layout helpers |
|
|
45
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` |
|
|
46
53
|
|
|
47
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:
|
|
48
55
|
|
|
@@ -66,6 +73,127 @@ import { Footer, SiteHeader, SkipLink, StickyCTABar } from "@atelic-action/ui/ch
|
|
|
66
73
|
|
|
67
74
|
The menu is a native `<dialog>` opened with `showModal()`, so Escape, focus containment, and focus return come from the browser.
|
|
68
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
|
+
|
|
107
|
+
## Email
|
|
108
|
+
|
|
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
|
+
|
|
111
|
+
Colors and fonts come from context, so a client branded email passes its own palette:
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
import { Card, Eyebrow, Footer, Item, Masthead, TitleCard } from "@atelic-action/ui/email";
|
|
115
|
+
import { renderEmail } from "@atelic-action/ui/email/render";
|
|
116
|
+
import { atelicPalette } from "@atelic-action/ui/tokens";
|
|
117
|
+
|
|
118
|
+
const html = renderEmail({
|
|
119
|
+
title: "Week 40 Recruiter",
|
|
120
|
+
preheader: "Three roles cleared every filter.",
|
|
121
|
+
palette: { ...atelicPalette, accent: "#0B6E4F" },
|
|
122
|
+
children: (
|
|
123
|
+
<>
|
|
124
|
+
<Masthead title="Recruiter" />
|
|
125
|
+
<TitleCard
|
|
126
|
+
eyebrowText="Week 40"
|
|
127
|
+
headlineLines={["Three cleared", "every filter."]}
|
|
128
|
+
lede="Two of them are remote."
|
|
129
|
+
/>
|
|
130
|
+
<Eyebrow text="Shortlist" />
|
|
131
|
+
<Card>
|
|
132
|
+
<Item
|
|
133
|
+
name="Pinewood Cabinetry"
|
|
134
|
+
right="4.5"
|
|
135
|
+
subparts={["Staff engineer", "Remote"]}
|
|
136
|
+
body="They answered inside a day."
|
|
137
|
+
linkText="Posting"
|
|
138
|
+
url="https://example.test/posting"
|
|
139
|
+
last
|
|
140
|
+
/>
|
|
141
|
+
</Card>
|
|
142
|
+
<Footer meta="Run 2026-10-05" />
|
|
143
|
+
</>
|
|
144
|
+
),
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`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.
|
|
149
|
+
|
|
150
|
+
### The Mapping
|
|
151
|
+
|
|
152
|
+
Where the jq takes a pre rendered html string (`$rows`, `$body_html`, `$cells_html`, a records cell's `html`, a stat's caption), the React prop is `children` or a `ReactNode`. Where the jq escapes a string argument, the prop is a plain string and React does the escaping.
|
|
153
|
+
|
|
154
|
+
| jq Function | Component | Props |
|
|
155
|
+
|---|---|---|
|
|
156
|
+
| `eyebrow` | `Eyebrow` | `text` |
|
|
157
|
+
| `card` | `Card` | `children` |
|
|
158
|
+
| `fold` | `Fold` | `summary`, `children` |
|
|
159
|
+
| `big_fold` | `BigFold` | `summary`, `count` (a `ReactNode`, absent for the jq's `""`), `children` |
|
|
160
|
+
| `title_line` | `TitleLine` | `name`, `right` |
|
|
161
|
+
| `item` | `Item` | `name`, `right`, `subparts`, `body`, `foldLabel`, `foldBody`, `linkText`, `url`, `last` |
|
|
162
|
+
| `note` | `Note` | `eyebrowText`, `text`, `accented`, `last` |
|
|
163
|
+
| `empty_row` | `EmptyRow` | `text` |
|
|
164
|
+
| `stat` | `Stat` | `n`, `caption` (a `ReactNode`: the jq takes raw html here) |
|
|
165
|
+
| `stats_row` | `StatsRow` | `children`, the `Stat` cells |
|
|
166
|
+
| `list` | `List` | `fontSize`, `children` |
|
|
167
|
+
| `list_row` | `ListRow` | `children` |
|
|
168
|
+
| `lead_row` | `LeadRow` | `lead`, `rest` |
|
|
169
|
+
| `row` | `Row` | `last`, `children` |
|
|
170
|
+
| `fold_row` | `FoldRow` | `last`, `children` |
|
|
171
|
+
| `mono_table` | `MonoTable` | `headers`, `rows` |
|
|
172
|
+
| `bar` | `Bar` | `logged`, `target` |
|
|
173
|
+
| `group_row` | `GroupRow` | `text`, `first` |
|
|
174
|
+
| `target_row` | `TargetRow` | `text`, `logged`, `target` (null is a count with nothing to measure it against), `note`, `last` |
|
|
175
|
+
| `scoreboard` | `Scoreboard` | `children`, the `GroupRow` and `TargetRow` rows |
|
|
176
|
+
| `what_moved` | `WhatMoved` | `items`, `note` |
|
|
177
|
+
| `read_block` | `ReadBlock` | `text`, `divider` |
|
|
178
|
+
| `sub_eyebrow` | `SubEyebrow` | `text` |
|
|
179
|
+
| `badge` | `Badge` | `letter` |
|
|
180
|
+
| `day_strip` | `DayStrip` | `days`, `last` |
|
|
181
|
+
| `stat_strip` | `StatStrip` | `stats` |
|
|
182
|
+
| `records` | `Records` | `columns`, `rows`; a cell's `html` is a `ReactNode` |
|
|
183
|
+
| `masthead` | `Masthead` | `title`, `wordmark` (defaults to `atelic`) |
|
|
184
|
+
| `title_card` | `TitleCard` | `eyebrowText`, `headlineLines`, `lede`, `stats` (the rows under the lede, in place of the jq's `$stats_html`) |
|
|
185
|
+
| `footer` | `Footer` | `meta` |
|
|
186
|
+
| `page` | `renderEmail` | `title`, `preheader`, `children`, `palette`, `fonts` |
|
|
187
|
+
| `failure_page` | `renderFailureEmail`, or `FailurePage` as body rows | `runnerTitle`, `eyebrowText`, `reason`, `logTail` |
|
|
188
|
+
|
|
189
|
+
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.
|
|
190
|
+
|
|
191
|
+
`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.
|
|
192
|
+
|
|
193
|
+
### Tokens
|
|
194
|
+
|
|
195
|
+
`@atelic-action/ui/tokens` carries the palette and the font stacks with no React import, so a build script or a plain text renderer can read them. `toThemeCSS(palette)` writes the palette out as the site token declarations (`--surface`, `--surface-dark`, `--card`, `--ink`, `--primary`), and `themeTokenMap` exposes which palette key each token takes.
|
|
196
|
+
|
|
69
197
|
## Releasing
|
|
70
198
|
|
|
71
199
|
1. Bump `version` in `package.json` and merge it to `main`: patch for a fix, minor for a new component or prop, major for a breaking prop or class rename.
|
|
@@ -76,4 +204,4 @@ The menu is a native `<dialog>` opened with `showModal()`, so Escape, focus cont
|
|
|
76
204
|
git push origin v0.1.1
|
|
77
205
|
```
|
|
78
206
|
|
|
79
|
-
3. The Publish workflow checks that the tag matches the version, runs the gates, and publishes to npm with provenance. It
|
|
207
|
+
3. The Publish workflow checks that the tag matches the version, runs the gates, and publishes to npm with provenance. It authenticates through npm trusted publishing (the package's Trusted Publisher names this repo and `publish.yml`, and its allowed actions must permit direct `npm publish`, since a configuration created after 2026-09-03 allows only `npm stage publish` by default), so no token is stored, and it needs npm 11.5.1 or later, which Node 24 ships.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atelic-action/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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,9 +19,15 @@
|
|
|
19
19
|
],
|
|
20
20
|
"exports": {
|
|
21
21
|
"./chrome": "./src/chrome/index.ts",
|
|
22
|
+
"./components": "./src/components/index.ts",
|
|
23
|
+
"./email": "./src/email/index.ts",
|
|
24
|
+
"./email/render": "./src/email/render.tsx",
|
|
22
25
|
"./hooks": "./src/hooks/index.ts",
|
|
26
|
+
"./routing": "./src/routing/index.ts",
|
|
27
|
+
"./tokens": "./src/tokens/index.ts",
|
|
23
28
|
"./styles/base.css": "./src/styles/base.css",
|
|
24
29
|
"./styles/chrome.css": "./src/styles/chrome.css",
|
|
30
|
+
"./styles/components.css": "./src/styles/components.css",
|
|
25
31
|
"./package.json": "./package.json"
|
|
26
32
|
},
|
|
27
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";
|