@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.
@@ -0,0 +1,116 @@
1
+ /*
2
+ * The plain text alternative part, ported one to one from the plain text
3
+ * section of homebase `runners/lib/email.jq`. Every label and number sits in
4
+ * its own padded column or on its own line; nothing is ever concatenated,
5
+ * which is what a mail service's own html to text conversion does to a table.
6
+ *
7
+ * jq counts a string's length in Unicode codepoints, so every width here does
8
+ * too. `.length` on a JavaScript string counts UTF-16 units and would pad an
9
+ * astral character two columns short.
10
+ */
11
+
12
+ function codepoints(value: string): number {
13
+ return [...value].length;
14
+ }
15
+
16
+ /** jq's ascii_upcase: ASCII letters only, everything else untouched. */
17
+ export function asciiUpcase(value: string): string {
18
+ return value.replace(/[a-z]/g, (c) => c.toUpperCase());
19
+ }
20
+
21
+ /** jq's ascii_downcase: ASCII letters only, everything else untouched. */
22
+ export function asciiDowncase(value: string): string {
23
+ return value.replace(/[A-Z]/g, (c) => c.toLowerCase());
24
+ }
25
+
26
+ export function spaces(n: number): string {
27
+ return " ".repeat(Math.max(n, 0));
28
+ }
29
+
30
+ /** Pads on the right to a column count. */
31
+ export function rpad(value: string, width: number): string {
32
+ return value + spaces(width - codepoints(value));
33
+ }
34
+
35
+ /** Pads on the left to a column count. */
36
+ export function lpad(value: string, width: number): string {
37
+ return spaces(width - codepoints(value)) + value;
38
+ }
39
+
40
+ /** Prose wrapped at 66 columns under a two space indent. */
41
+ export function wrap(text: string): string {
42
+ const lines = [""];
43
+ for (const word of text.split(" ")) {
44
+ const last = lines[lines.length - 1];
45
+ if (codepoints(last) === 0) lines[lines.length - 1] = word;
46
+ else if (codepoints(last) + 1 + codepoints(word) > 66) lines.push(word);
47
+ else lines[lines.length - 1] = `${last} ${word}`;
48
+ }
49
+ return lines.map((line) => ` ${line}`).join("\n");
50
+ }
51
+
52
+ export const textRule = "=".repeat(64);
53
+
54
+ export function textSection(title: string): string {
55
+ return `\n\n${textRule}\n${asciiUpcase(title)}\n${textRule}\n\n`;
56
+ }
57
+
58
+ export function textRead(text: string): string {
59
+ return `The Read\n${wrap(text)}\n`;
60
+ }
61
+
62
+ export function textBar(logged: number, target: number | null): string {
63
+ if (target === null || target === 0) return "";
64
+ const filled = Math.min(logged, target);
65
+ return Array.from({ length: target }, (_, i) => (i < filled ? "#" : ".")).join("");
66
+ }
67
+
68
+ export type TextTarget = {
69
+ label: string;
70
+ logged: number;
71
+ target: number | null;
72
+ note: string;
73
+ };
74
+
75
+ /** One scoreboard line. */
76
+ export function textTarget({ label, logged, target, note }: TextTarget): string {
77
+ const count = `${logged}${target === null ? "" : ` / ${target}`}`;
78
+ return ` ${rpad(label, 15)}${rpad(count, 8)}${rpad(textBar(logged, target), 7)}${note}`;
79
+ }
80
+
81
+ /**
82
+ * A column table. `right` holds the column indexes that align right. `widths`,
83
+ * when given, fixes every column's width and keeps every column, so sibling
84
+ * tables line up; null sizes each column to its content and drops any column
85
+ * every row leaves empty.
86
+ */
87
+ export function textTableGrid(
88
+ cols: string[],
89
+ rows: string[][],
90
+ right: number[],
91
+ widths: number[] | null,
92
+ ): string {
93
+ const keep = cols
94
+ .map((_, i) => i)
95
+ .filter((i) => widths !== null || rows.some((row) => (row[i] ?? "") !== ""));
96
+ const widthOf = keep.map((i) =>
97
+ widths !== null
98
+ ? widths[i]
99
+ : Math.max(...[cols[i], ...rows.map((row) => row[i] ?? "")].map(codepoints)),
100
+ );
101
+ return [cols, ...rows]
102
+ .map(
103
+ (row) =>
104
+ ` ${keep
105
+ .map((i, k) =>
106
+ right.includes(i) ? lpad(row[i] ?? "", widthOf[k]) : rpad(row[i] ?? "", widthOf[k]),
107
+ )
108
+ .join(" ")
109
+ .replace(/ +$/, "")}`,
110
+ )
111
+ .join("\n");
112
+ }
113
+
114
+ export function textTable(cols: string[], rows: string[][], right: number[]): string {
115
+ return textTableGrid(cols, rows, right, null);
116
+ }
@@ -0,0 +1,77 @@
1
+ import { type CSSProperties, createContext, type ReactNode, useContext } from "react";
2
+ import { atelicFonts, atelicPalette, type Fonts, type Palette } from "../tokens";
3
+
4
+ export type EmailTheme = {
5
+ palette: Palette;
6
+ fonts: Fonts;
7
+ };
8
+
9
+ const EmailThemeContext = createContext<EmailTheme>({
10
+ palette: atelicPalette,
11
+ fonts: atelicFonts,
12
+ });
13
+
14
+ export type EmailThemeProviderProps = {
15
+ palette?: Palette;
16
+ fonts?: Fonts;
17
+ children?: ReactNode;
18
+ };
19
+
20
+ /**
21
+ * Wraps a tree of email rows in a palette and a font pair. A client branded
22
+ * email passes its own; everything defaults to Atelic.
23
+ */
24
+ export function EmailThemeProvider({
25
+ palette = atelicPalette,
26
+ fonts = atelicFonts,
27
+ children,
28
+ }: EmailThemeProviderProps) {
29
+ return (
30
+ <EmailThemeContext.Provider value={{ palette, fonts }}>{children}</EmailThemeContext.Provider>
31
+ );
32
+ }
33
+
34
+ export function useEmailTheme(): EmailTheme {
35
+ return useContext(EmailThemeContext);
36
+ }
37
+
38
+ /**
39
+ * The shared eyebrow: monospace, small, wide, uppercase. The caller adds the
40
+ * color, because which color an eyebrow wears is what it says.
41
+ */
42
+ export function eyebrowStyle(fonts: Fonts): CSSProperties {
43
+ return {
44
+ fontFamily: fonts.mono,
45
+ fontSize: "11px",
46
+ letterSpacing: "0.12em",
47
+ textTransform: "uppercase",
48
+ };
49
+ }
50
+
51
+ /** The props every layout table in an email carries. */
52
+ export const tableReset = {
53
+ role: "presentation" as const,
54
+ cellPadding: "0",
55
+ cellSpacing: "0",
56
+ border: 0,
57
+ };
58
+
59
+ /**
60
+ * `#FC4A1A` to `rgba(252,74,26,0)`, the masthead rule's fade out stop. An accent
61
+ * that is not a three or six digit hex fades to `transparent` rather than to NaN.
62
+ */
63
+ export function fadeStop(hex: string): string {
64
+ if (!/^#?(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(hex)) return "transparent";
65
+ const digits = hex.replace("#", "");
66
+ const full =
67
+ digits.length === 3
68
+ ? digits
69
+ .split("")
70
+ .map((c) => c + c)
71
+ .join("")
72
+ : digits;
73
+ const r = Number.parseInt(full.slice(0, 2), 16);
74
+ const g = Number.parseInt(full.slice(2, 4), 16);
75
+ const b = Number.parseInt(full.slice(4, 6), 16);
76
+ return `rgba(${r},${g},${b},0)`;
77
+ }
@@ -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
+ }
@@ -0,0 +1,27 @@
1
+ import type { Palette } from "./palettes";
2
+
3
+ /**
4
+ * Which site token each email palette key answers to, so an email palette can
5
+ * dress a web page and a page's theme can dress an email. The names are the
6
+ * ones in a site's `theme.css`; only the keys with a clear counterpart are
7
+ * mapped, which is why the neutral ramp, the radii, and the shadows are
8
+ * absent. `--ink` and `--surface-dark` both take the ink, exactly as the site
9
+ * defines them.
10
+ */
11
+ export const themeTokenMap: Record<string, keyof Palette> = {
12
+ "--surface": "ground",
13
+ "--surface-dark": "ink",
14
+ "--card": "paper",
15
+ "--ink": "ink",
16
+ "--primary": "accent",
17
+ };
18
+
19
+ /**
20
+ * The palette as CSS custom property declarations, one per line, ready to drop
21
+ * inside a selector block.
22
+ */
23
+ export function toThemeCSS(palette: Palette): string {
24
+ return Object.entries(themeTokenMap)
25
+ .map(([token, key]) => `${token}: ${palette[key]};`)
26
+ .join("\n");
27
+ }
@@ -0,0 +1,2 @@
1
+ export { themeTokenMap, toThemeCSS } from "./css";
2
+ export { atelicFonts, atelicPalette, type Fonts, type Palette } from "./palettes";
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The palette the runner emails are painted in, ported one to one from
3
+ * homebase `runners/lib/email.jq` (the tokens section). Cream ground, paper
4
+ * cards on a hairline, near black ink, the orange reserved for the one number
5
+ * that matters on a row.
6
+ *
7
+ * A client branded email passes its own Palette to EmailThemeProvider; the
8
+ * values below are the default.
9
+ */
10
+ export type Palette = {
11
+ /** The page behind the cards. */
12
+ ground: string;
13
+ /** A card's own fill. */
14
+ paper: string;
15
+ /** Headings and anything read closely. */
16
+ ink: string;
17
+ /** Secondary prose. */
18
+ dim: string;
19
+ /** Eyebrows, captions, and anything deliberately quiet. */
20
+ faint: string;
21
+ /** The single accent. */
22
+ accent: string;
23
+ /** A card's border and the heavier rule. */
24
+ line: string;
25
+ /** The lighter rule inside a card. */
26
+ hair: string;
27
+ };
28
+
29
+ export const atelicPalette: Palette = {
30
+ ground: "#F6F1E7",
31
+ paper: "#FDFBF6",
32
+ ink: "#151515",
33
+ dim: "#55503f",
34
+ faint: "#8a8272",
35
+ accent: "#FC4A1A",
36
+ line: "#E6DFD2",
37
+ hair: "#F0EAE0",
38
+ };
39
+
40
+ /**
41
+ * Font family values, not whole declarations: the components compose them
42
+ * into a `fontFamily` style property.
43
+ */
44
+ export type Fonts = {
45
+ /** Eyebrows, numbers, and anything the reader copies rather than reads. */
46
+ mono: string;
47
+ /** Everything read. */
48
+ sans: string;
49
+ };
50
+
51
+ export const atelicFonts: Fonts = {
52
+ mono: "'Courier New',monospace",
53
+ sans: "'Helvetica Neue',Helvetica,Arial,sans-serif",
54
+ };