@atelic-action/ui 0.1.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 ADDED
@@ -0,0 +1,79 @@
1
+ # @atelic-action/ui
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`.
4
+
5
+ The package ships source, not a build. Its TSX and CSS arrive as written and compile inside each site's own Vite.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ bun add @atelic-action/ui
11
+ ```
12
+
13
+ Peer dependencies: `react` and `react-dom` at `^19.0.0`, and `lucide-react` at `^1.23.0`.
14
+
15
+ ## Vite Config
16
+
17
+ Prerendering and server rendering have to compile the package too, so keep it out of Vite's SSR externals. That is the one line a consumer adds:
18
+
19
+ ```ts
20
+ // vite.config.ts
21
+ export default defineConfig({
22
+ ssr: { noExternal: ["@atelic-action/ui"] },
23
+ });
24
+ ```
25
+
26
+ ## CSS Import Order
27
+
28
+ Import the stylesheets in this order, from the root route or the site's base sheet:
29
+
30
+ 1. The site's fonts (`fonts.css`)
31
+ 2. `@atelic-action/ui/styles/base.css`
32
+ 3. `@atelic-action/ui/styles/chrome.css`
33
+ 4. The site's own CSS
34
+ 5. The site's `theme.css`, last
35
+
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
+
38
+ ## What Is Inside
39
+
40
+ | Import | Exports |
41
+ |---|---|
42
+ | `@atelic-action/ui/chrome` | `SiteHeader`, `SiteMenu`, `Footer`, `CreditBar`, `StickyCTABar`, `BrandLockup`, `SkipLink`, and their prop types |
43
+ | `@atelic-action/ui/hooks` | `useScrollSpy` and its `PageStop` type |
44
+ | `@atelic-action/ui/styles/base.css` | Resets, the `.mkt` canvas, typography, and layout helpers |
45
+ | `@atelic-action/ui/styles/chrome.css` | Styles for everything under `chrome` |
46
+
47
+ 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
+
49
+ ```tsx
50
+ import { Footer, SiteHeader, SkipLink, StickyCTABar } from "@atelic-action/ui/chrome";
51
+
52
+ <div className="mkt">
53
+ <SkipLink />
54
+ <SiteHeader
55
+ brand={{ name: site.name, logo: site.logo }}
56
+ links={site.nav.map((item) => ({ label: item.label, href: item.to }))}
57
+ primaryCTA={site.cta}
58
+ variant="transparent"
59
+ currentPath={pathname}
60
+ />
61
+ <main id="main">{children}</main>
62
+ <StickyCTABar primaryCTA={site.cta} phone={site.phone} />
63
+ <Footer brand={{ name: site.name, logo: site.logo }} email={site.email} />
64
+ </div>;
65
+ ```
66
+
67
+ The menu is a native `<dialog>` opened with `showModal()`, so Escape, focus containment, and focus return come from the browser.
68
+
69
+ ## Releasing
70
+
71
+ 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.
72
+ 2. Tag the merged commit `vX.Y.Z` with the same version and push the tag:
73
+
74
+ ```bash
75
+ git tag v0.1.1
76
+ git push origin v0.1.1
77
+ ```
78
+
79
+ 3. The Publish workflow checks that the tag matches the version, runs the gates, and publishes to npm with provenance. It needs the `NPM_TOKEN` repository secret and the `@atelic` npm organization.
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@atelic-action/ui",
3
+ "version": "0.1.0",
4
+ "description": "Shared UI for the Atelic templates: site chrome, base styles, and the component library they install",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/atelic-action/ui.git"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "files": [
15
+ "src"
16
+ ],
17
+ "sideEffects": [
18
+ "**/*.css"
19
+ ],
20
+ "exports": {
21
+ "./chrome": "./src/chrome/index.ts",
22
+ "./hooks": "./src/hooks/index.ts",
23
+ "./styles/base.css": "./src/styles/base.css",
24
+ "./styles/chrome.css": "./src/styles/chrome.css",
25
+ "./package.json": "./package.json"
26
+ },
27
+ "scripts": {
28
+ "lint": "biome check .",
29
+ "lint:fix": "biome check . --write",
30
+ "typecheck": "tsc --noEmit",
31
+ "test:run": "vitest run"
32
+ },
33
+ "peerDependencies": {
34
+ "lucide-react": "^1.23.0",
35
+ "react": "^19.0.0",
36
+ "react-dom": "^19.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "@biomejs/biome": "^2.5.13",
40
+ "@testing-library/jest-dom": "^6.6.2",
41
+ "@testing-library/react": "^16.0.1",
42
+ "@types/react": "^19.0.0",
43
+ "@types/react-dom": "^19.0.0",
44
+ "jsdom": "^25.0.1",
45
+ "lucide-react": "^1.23.0",
46
+ "react": "^19.0.0",
47
+ "react-dom": "^19.0.0",
48
+ "typescript": "^5.6.3",
49
+ "vitest": "^4.0.0"
50
+ },
51
+ "trustedDependencies": [
52
+ "@biomejs/biome"
53
+ ]
54
+ }
@@ -0,0 +1,29 @@
1
+ import type { Brand } from "../types";
2
+
3
+ export interface BrandLockupProps {
4
+ brand: Brand;
5
+ /** The link's accessible name. Defaults to "<name> home". */
6
+ ariaLabel?: string;
7
+ }
8
+
9
+ /**
10
+ * The header's identity as one link: the mark (a glyph tile or an image),
11
+ * the wordmark, and the trailing run. The image carries empty alt text
12
+ * because the link's own label already names it.
13
+ */
14
+ export function BrandLockup({ brand, ariaLabel }: BrandLockupProps) {
15
+ const { name, href = "/", logo } = brand;
16
+ return (
17
+ <a className="nav-logo" href={href} aria-label={ariaLabel ?? `${name} home`}>
18
+ {logo?.tile ? (
19
+ <span className="logo-tile" aria-hidden="true">
20
+ {logo.tile}
21
+ </span>
22
+ ) : (
23
+ logo?.src && <img className="mark" src={logo.src} alt="" />
24
+ )}
25
+ <span className="wordmark">{logo?.wordmark ?? name}</span>
26
+ {logo?.run && <span className="wordmark-run" aria-hidden="true" />}
27
+ </a>
28
+ );
29
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The standardized developer credit: a full width band, visually separate
3
+ * from anything above it, centered and muted. Footer renders it at its foot,
4
+ * and a page that drops the footer renders it on its own.
5
+ *
6
+ * The heart ships as an escape sequence: the no emoji test bars literal
7
+ * pictographs from `src/`.
8
+ */
9
+ export function CreditBar() {
10
+ return (
11
+ <div className="credit-bar">
12
+ Developed with {"\u{1F49A}"} in Denver by{" "}
13
+ <a href="https://atelic.me" target="_blank" rel="noopener">
14
+ Atelic
15
+ </a>
16
+ </div>
17
+ );
18
+ }
@@ -0,0 +1,243 @@
1
+ import { Mail, UserRound } from "lucide-react";
2
+ import type { ComponentProps } from "react";
3
+ import { newTabProps } from "../lib/newTabProps";
4
+ import type {
5
+ Brand,
6
+ HoursRow,
7
+ NavLink,
8
+ Phone,
9
+ PostalAddress,
10
+ SiteLink,
11
+ SocialLink,
12
+ } from "../types";
13
+ import { CreditBar } from "./CreditBar";
14
+
15
+ /*
16
+ * lucide-react ships no brand marks (removed upstream by policy), and a code
17
+ * glyph standing in for GitHub or a briefcase for LinkedIn misleads the
18
+ * reader. These two are the brands' own simple marks, carried by hand as a
19
+ * considered exception to the lucide only rule; like lucide icons they take
20
+ * currentColor and a size prop.
21
+ */
22
+ type MarkProps = { size?: number } & ComponentProps<"svg">;
23
+
24
+ function GitHubMark({ size = 18, ...rest }: MarkProps) {
25
+ return (
26
+ <svg
27
+ aria-hidden="true"
28
+ viewBox="0 0 24 24"
29
+ width={size}
30
+ height={size}
31
+ fill="currentColor"
32
+ {...rest}
33
+ >
34
+ <path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.55 0-.27-.01-1.17-.02-2.12-3.2.7-3.87-1.36-3.87-1.36-.52-1.33-1.28-1.68-1.28-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.19 1.76 1.19 1.03 1.76 2.69 1.25 3.34.96.1-.75.4-1.25.72-1.54-2.55-.29-5.23-1.28-5.23-5.68 0-1.26.45-2.28 1.19-3.09-.12-.29-.52-1.46.11-3.05 0 0 .97-.31 3.17 1.18a11 11 0 0 1 5.77 0c2.2-1.49 3.16-1.18 3.16-1.18.63 1.59.23 2.76.12 3.05.74.81 1.18 1.83 1.18 3.09 0 4.41-2.69 5.38-5.25 5.67.41.35.77 1.05.77 2.12 0 1.53-.01 2.76-.01 3.14 0 .3.2.67.8.55A11.51 11.51 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5z" />
35
+ </svg>
36
+ );
37
+ }
38
+
39
+ function LinkedInMark({ size = 18, ...rest }: MarkProps) {
40
+ return (
41
+ <svg
42
+ aria-hidden="true"
43
+ viewBox="0 0 24 24"
44
+ width={size}
45
+ height={size}
46
+ fill="currentColor"
47
+ {...rest}
48
+ >
49
+ <path d="M4.98 3.5C4.98 4.88 3.87 6 2.5 6S0 4.88 0 3.5 1.12 1 2.5 1s2.48 1.12 2.48 2.5zM.22 8.1h4.56V23H.22V8.1zM8.34 8.1h4.37v2.03h.06c.61-1.15 2.1-2.37 4.32-2.37 4.62 0 5.47 3.04 5.47 6.99V23h-4.55v-7.28c0-1.74-.03-3.97-2.42-3.97-2.42 0-2.79 1.89-2.79 3.84V23H8.34V8.1z" />
50
+ </svg>
51
+ );
52
+ }
53
+
54
+ const SOCIAL_ICONS = {
55
+ github: GitHubMark,
56
+ linkedin: LinkedInMark,
57
+ mail: Mail,
58
+ user: UserRound,
59
+ } as const;
60
+
61
+ function SocialIconLink({ link }: { link: SocialLink }) {
62
+ const Icon = SOCIAL_ICONS[link.icon];
63
+ return (
64
+ <a
65
+ className="footer-social-link"
66
+ href={link.href}
67
+ aria-label={link.label}
68
+ title={link.label}
69
+ {...newTabProps(link.href, true)}
70
+ >
71
+ <Icon aria-hidden size={18} />
72
+ </a>
73
+ );
74
+ }
75
+
76
+ function FooterLink({ link }: { link: SiteLink }) {
77
+ return (
78
+ <a href={link.href} {...newTabProps(link.href, link.external)}>
79
+ {link.label}
80
+ </a>
81
+ );
82
+ }
83
+
84
+ export interface FooterProps {
85
+ /** The identity at the head of the brand column. */
86
+ brand: Brand;
87
+ /** Short brand paragraph under the identity. */
88
+ blurb?: string;
89
+ /** Icon links (socials, the founder's own site) under the blurb. */
90
+ socialLinks?: SocialLink[];
91
+ /** The site's nav, repeated in the explore column. */
92
+ links?: NavLink[];
93
+ /** Explore links beyond the nav, e.g. a patient portal. */
94
+ extraLinks?: SiteLink[];
95
+ /** Heading over the contact column. Defaults to "Visit". */
96
+ contactHeading?: string;
97
+ /**
98
+ * The street address, printed under the business name. Omit it for a
99
+ * practice with no storefront, and the phone and email stand alone under
100
+ * the heading.
101
+ */
102
+ address?: PostalAddress;
103
+ phone?: Phone;
104
+ email?: string;
105
+ /** Opening hours as display rows, already formatted by the site. */
106
+ hours?: HoursRow[];
107
+ /** Suffix on the copyright line, e.g. "Dr. Alex Rivers, PT, DPT". */
108
+ attribution?: string;
109
+ /** A short closing note beside the copyright line. */
110
+ note?: string;
111
+ legalLinks?: SiteLink[];
112
+ /** The developer credit band at the foot. Defaults on. */
113
+ credit?: boolean;
114
+ }
115
+
116
+ /** The site footer: brand blurb, explore nav, NAP block, hours, and the credit band. */
117
+ export function Footer({
118
+ brand,
119
+ blurb,
120
+ socialLinks,
121
+ links,
122
+ extraLinks,
123
+ contactHeading = "Visit",
124
+ address,
125
+ phone,
126
+ email,
127
+ hours,
128
+ attribution,
129
+ note,
130
+ legalLinks,
131
+ credit = true,
132
+ }: FooterProps) {
133
+ const { name, logo } = brand;
134
+ const wordmark = logo?.wordmark ?? name;
135
+
136
+ return (
137
+ <>
138
+ <footer className="footer">
139
+ <div className="wrap">
140
+ <div className="footer-grid">
141
+ <div className="footer-brand">
142
+ {logo?.tile ? (
143
+ <p className="footer-wordmark">
144
+ <span className="logo-tile" aria-hidden="true">
145
+ {logo.tile}
146
+ </span>
147
+ {wordmark}
148
+ {logo.run && <span className="wordmark-run" aria-hidden="true" />}
149
+ </p>
150
+ ) : logo?.src ? (
151
+ <img src={logo.src} alt={logo.alt ?? name} />
152
+ ) : (
153
+ <p className="footer-wordmark">
154
+ {wordmark}
155
+ {logo?.run && <span className="wordmark-run" aria-hidden="true" />}
156
+ </p>
157
+ )}
158
+ {blurb && <p>{blurb}</p>}
159
+ {socialLinks && socialLinks.length > 0 && (
160
+ <div className="footer-social">
161
+ {socialLinks.map((link) => (
162
+ <SocialIconLink key={link.href} link={link} />
163
+ ))}
164
+ </div>
165
+ )}
166
+ </div>
167
+ <div>
168
+ <h4>Explore</h4>
169
+ <nav aria-label="Footer">
170
+ {links?.map((link) => (
171
+ <a key={link.href} href={link.href}>
172
+ {link.label}
173
+ </a>
174
+ ))}
175
+ {extraLinks?.map((link) => (
176
+ <FooterLink key={link.href} link={link} />
177
+ ))}
178
+ </nav>
179
+ </div>
180
+ <div>
181
+ <h4>{contactHeading}</h4>
182
+ <address className="nap">
183
+ {address && (
184
+ <>
185
+ <strong>{name}</strong>
186
+ <br />
187
+ {/* A business that publishes a service area but no storefront
188
+ leaves street empty; a blank line reads as a broken block. */}
189
+ {address.street && (
190
+ <>
191
+ {address.street}
192
+ <br />
193
+ </>
194
+ )}
195
+ {address.locality}, {address.region}
196
+ {address.postalCode ? ` ${address.postalCode}` : ""}
197
+ <br />
198
+ <br />
199
+ </>
200
+ )}
201
+ {phone && (
202
+ <>
203
+ <a href={`tel:${phone.e164}`}>{phone.display}</a>
204
+ <br />
205
+ </>
206
+ )}
207
+ {email && <a href={`mailto:${email}`}>{email}</a>}
208
+ </address>
209
+ </div>
210
+ {hours && hours.length > 0 && (
211
+ <div>
212
+ <h4>Hours</h4>
213
+ <div className="footer-hours">
214
+ {hours.map((row) => (
215
+ <span key={row.label}>
216
+ {row.label}&nbsp;&nbsp;{row.range}
217
+ <br />
218
+ </span>
219
+ ))}
220
+ </div>
221
+ </div>
222
+ )}
223
+ </div>
224
+ <div className="footer-bottom">
225
+ <span>
226
+ © {new Date().getFullYear()} {name}
227
+ {attribution ? ` · ${attribution}` : ""}
228
+ </span>
229
+ {note && <span>{note}</span>}
230
+ {legalLinks && legalLinks.length > 0 && (
231
+ <span className="footer-legal">
232
+ {legalLinks.map((link) => (
233
+ <FooterLink key={link.href} link={link} />
234
+ ))}
235
+ </span>
236
+ )}
237
+ </div>
238
+ </div>
239
+ </footer>
240
+ {credit && <CreditBar />}
241
+ </>
242
+ );
243
+ }
@@ -0,0 +1,152 @@
1
+ import { useEffect, useId, useState } from "react";
2
+ import { type PageStop, useScrollSpy } from "../hooks/useScrollSpy";
3
+ import { newTabProps } from "../lib/newTabProps";
4
+ import type { Brand, CallToAction, MenuContact, NavLink } from "../types";
5
+ import { BrandLockup } from "./BrandLockup";
6
+ import { SiteMenu } from "./SiteMenu";
7
+
8
+ /**
9
+ * `transparent` sits over a full bleed hero and turns solid on scroll,
10
+ * `solid` is the light bar, and `dark` matches a dark page surface.
11
+ */
12
+ export type SiteHeaderVariant = "transparent" | "solid" | "dark";
13
+
14
+ export interface SiteHeaderProps {
15
+ /** The identity the lockup renders. */
16
+ brand: Brand;
17
+ /** The site's primary nav. Omit on a page with no site nav, such as an artifact. */
18
+ links?: NavLink[];
19
+ /** The primary action, in the bar on desktop and in the menu. */
20
+ primaryCTA?: CallToAction;
21
+ /** Defaults to `solid`. */
22
+ variant?: SiteHeaderVariant;
23
+ /** The current path, marking the matching nav link with aria-current. */
24
+ currentPath?: string;
25
+ /**
26
+ * Presentation mode: the page's own stops replace the nav links, scroll
27
+ * spied, on desktop and in the menu. The CTA and the menu's contact block
28
+ * step aside, since both are site chrome and the page owns its close.
29
+ */
30
+ stops?: PageStop[];
31
+ /** Hides the bar's CTA (e.g. a reveal, where the ask is the letter). The menu keeps it. */
32
+ hideCTA?: boolean;
33
+ /** The contact block at the foot of the menu. */
34
+ contact?: MenuContact;
35
+ }
36
+
37
+ /**
38
+ * The fixed header every page wears: the lockup, the nav or the page's
39
+ * stops, the CTA, and the burger that opens the menu. It takes the current
40
+ * path as a prop rather than asking a router, so it runs under any of them.
41
+ */
42
+ export function SiteHeader({
43
+ brand,
44
+ links,
45
+ primaryCTA,
46
+ variant = "solid",
47
+ currentPath,
48
+ stops,
49
+ hideCTA = false,
50
+ contact,
51
+ }: SiteHeaderProps) {
52
+ const [scrolled, setScrolled] = useState(false);
53
+ const [menuOpen, setMenuOpen] = useState(false);
54
+ const menuId = useId();
55
+ const activeId = useScrollSpy(stops ? stops.map((stop) => stop.id) : []);
56
+
57
+ useEffect(() => {
58
+ if (variant !== "transparent") return;
59
+ const onScroll = () => setScrolled(window.scrollY > window.innerHeight * 0.6);
60
+ window.addEventListener("scroll", onScroll, { passive: true });
61
+ onScroll();
62
+ return () => window.removeEventListener("scroll", onScroll);
63
+ }, [variant]);
64
+
65
+ const solid = variant !== "transparent" || scrolled;
66
+ const menuLinks = stops
67
+ ? stops.map((stop) => ({ label: stop.label, href: `#${stop.id}` }))
68
+ : (links ?? []);
69
+ const menuCTA = stops ? undefined : primaryCTA;
70
+ const barCTA = hideCTA ? undefined : menuCTA;
71
+ const hasMenu = menuLinks.length > 0 || menuCTA !== undefined;
72
+
73
+ return (
74
+ <>
75
+ <header
76
+ className={["nav", solid ? "is-solid" : "is-transparent", variant === "dark" && "nav-dark"]
77
+ .filter(Boolean)
78
+ .join(" ")}
79
+ >
80
+ <div className="nav-inner">
81
+ <BrandLockup brand={brand} />
82
+ {stops
83
+ ? stops.length > 0 && (
84
+ <nav className="nav-links" aria-label="Page sections">
85
+ {stops.map((stop) => (
86
+ <a
87
+ key={stop.id}
88
+ href={`#${stop.id}`}
89
+ className={activeId === stop.id ? "active" : undefined}
90
+ >
91
+ {stop.label}
92
+ </a>
93
+ ))}
94
+ </nav>
95
+ )
96
+ : links &&
97
+ links.length > 0 && (
98
+ <nav className="nav-links" aria-label="Primary">
99
+ {links.map((link) => (
100
+ <a
101
+ key={link.href}
102
+ href={link.href}
103
+ aria-current={currentPath === link.href ? "page" : undefined}
104
+ >
105
+ {link.label}
106
+ </a>
107
+ ))}
108
+ </nav>
109
+ )}
110
+ {barCTA && (
111
+ // The class sits on a wrapper, not the button: a site's own .btn
112
+ // display rule lives outside the atelic-ui layer and would beat
113
+ // any rule here that hid the button itself.
114
+ <span className="nav-cta">
115
+ <a
116
+ className="btn btn-primary"
117
+ href={barCTA.href}
118
+ {...newTabProps(barCTA.href, barCTA.external)}
119
+ >
120
+ {barCTA.label}
121
+ </a>
122
+ </span>
123
+ )}
124
+ {hasMenu && (
125
+ <button
126
+ type="button"
127
+ className="nav-burger"
128
+ aria-label={menuOpen ? "Close menu" : "Open menu"}
129
+ aria-expanded={menuOpen}
130
+ aria-controls={menuId}
131
+ onClick={() => setMenuOpen((open) => !open)}
132
+ >
133
+ <span />
134
+ <span />
135
+ <span />
136
+ </button>
137
+ )}
138
+ </div>
139
+ </header>
140
+ {hasMenu && (
141
+ <SiteMenu
142
+ id={menuId}
143
+ open={menuOpen}
144
+ onClose={() => setMenuOpen(false)}
145
+ links={menuLinks}
146
+ primaryCTA={menuCTA}
147
+ contact={stops ? undefined : contact}
148
+ />
149
+ )}
150
+ </>
151
+ );
152
+ }