@atelic-action/ui 0.2.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 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. The site's own CSS
34
- 5. The site's `theme.css`, last
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
- Both 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
+ 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atelic-action/ui",
3
- "version": "0.2.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,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";
@@ -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
+ }