@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.
@@ -0,0 +1,130 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { newTabProps } from "../lib/newTabProps";
3
+ import type { CallToAction, MenuContact, NavLink } from "../types";
4
+
5
+ export interface SiteMenuProps {
6
+ /** Whether the menu is showing. The owner holds this state. */
7
+ open: boolean;
8
+ /**
9
+ * Called once each time the menu closes on its own: Escape, a followed
10
+ * link, or the close button. The owner sets its state to closed. A close
11
+ * the owner asked for, by passing `open={false}`, is not reported back.
12
+ */
13
+ onClose: () => void;
14
+ /** The entries in order: a site's nav, or a page's own stops as `#id` links. */
15
+ links: NavLink[];
16
+ /** The primary action, rendered as a standard button below the entries. */
17
+ primaryCTA?: CallToAction;
18
+ /** The phone, email, and address block at the foot. */
19
+ contact?: MenuContact;
20
+ id?: string;
21
+ /** The dialog's accessible name. Defaults to "Menu". */
22
+ label?: string;
23
+ }
24
+
25
+ /**
26
+ * The full screen overlay menu for small screens, on a native modal
27
+ * `<dialog>`: the platform supplies Escape, focus containment, and focus
28
+ * return to the burger. The dialog renders closed on the server and opens
29
+ * only in an effect, so the menu stays safe to prerender. Page scroll holds
30
+ * still through chrome.css while the dialog is open.
31
+ */
32
+ export function SiteMenu({
33
+ open,
34
+ onClose,
35
+ links,
36
+ primaryCTA,
37
+ contact,
38
+ id,
39
+ label = "Menu",
40
+ }: SiteMenuProps) {
41
+ const dialogRef = useRef<HTMLDialogElement>(null);
42
+ // Whether the owner already knows the menu is closed. Escape fires cancel
43
+ // and then close, and a followed link closes the dialog and notifies
44
+ // directly, so every path runs through reportClosed and reports once.
45
+ const closedRef = useRef(true);
46
+
47
+ useEffect(() => {
48
+ const dialog = dialogRef.current;
49
+ if (!dialog) return;
50
+ closedRef.current = !open;
51
+ if (open && !dialog.open) dialog.showModal();
52
+ if (!open && dialog.open) dialog.close();
53
+ }, [open]);
54
+
55
+ const reportClosed = () => {
56
+ if (closedRef.current) return;
57
+ closedRef.current = true;
58
+ onClose();
59
+ };
60
+
61
+ // A followed link closes the dialog before the browser navigates, so the
62
+ // scroll lock is already lifted when an in page jump lands. The owner
63
+ // hears about it now rather than when the close event's task runs.
64
+ const close = () => {
65
+ const dialog = dialogRef.current;
66
+ if (dialog?.open) dialog.close();
67
+ reportClosed();
68
+ };
69
+
70
+ return (
71
+ <dialog
72
+ ref={dialogRef}
73
+ id={id}
74
+ className="mobile-menu"
75
+ aria-label={label}
76
+ onCancel={reportClosed}
77
+ onClose={reportClosed}
78
+ >
79
+ <button type="button" className="mm-close" aria-label="Close menu" onClick={close}>
80
+ <span />
81
+ <span />
82
+ </button>
83
+ {links.map((link, i) => (
84
+ <a key={link.href} className="mm-link" href={link.href} onClick={close}>
85
+ <span>{link.label}</span>
86
+ <span className="idx">{String(i + 1).padStart(2, "0")}</span>
87
+ </a>
88
+ ))}
89
+ {primaryCTA && (
90
+ <a
91
+ className="btn btn-primary btn-lg mm-cta"
92
+ href={primaryCTA.href}
93
+ onClick={close}
94
+ {...newTabProps(primaryCTA.href, primaryCTA.external)}
95
+ >
96
+ {primaryCTA.label}
97
+ </a>
98
+ )}
99
+ {contact && <ContactBlock contact={contact} />}
100
+ </dialog>
101
+ );
102
+ }
103
+
104
+ function ContactBlock({ contact: { phone, email, address } }: { contact: MenuContact }) {
105
+ return (
106
+ <div className="mm-contact">
107
+ {phone && (
108
+ <>
109
+ CALL / TEXT&nbsp;&nbsp;{phone.display}
110
+ <br />
111
+ </>
112
+ )}
113
+ {email && (
114
+ <>
115
+ {email}
116
+ <br />
117
+ </>
118
+ )}
119
+ {address && (
120
+ <>
121
+ {/* A business that publishes a service area but no storefront leaves
122
+ street empty; without the guard the separator dangles. */}
123
+ {address.street && <>{address.street.toUpperCase()} · </>}
124
+ {address.locality.toUpperCase()}, {address.region}
125
+ {address.postalCode ? ` ${address.postalCode}` : ""}
126
+ </>
127
+ )}
128
+ </div>
129
+ );
130
+ }
@@ -0,0 +1,16 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export interface SkipLinkProps {
4
+ /** The main landmark's anchor. Defaults to "#main". */
5
+ href?: string;
6
+ children?: ReactNode;
7
+ }
8
+
9
+ /** The keyboard user's first stop: hidden until focused, then a jump past the chrome. */
10
+ export function SkipLink({ href = "#main", children = "Skip to content" }: SkipLinkProps) {
11
+ return (
12
+ <a className="skip-link" href={href}>
13
+ {children}
14
+ </a>
15
+ );
16
+ }
@@ -0,0 +1,50 @@
1
+ import { Phone as PhoneIcon } from "lucide-react";
2
+ import { useEffect, useState } from "react";
3
+ import { newTabProps } from "../lib/newTabProps";
4
+ import type { CallToAction, Phone } from "../types";
5
+
6
+ export interface StickyCTABarProps {
7
+ /** The primary action, filling the bar. */
8
+ primaryCTA: CallToAction;
9
+ /** Adds a call shortcut beside the CTA. Omit for a business with no public phone. */
10
+ phone?: Phone;
11
+ /** Scroll depth in pixels before the bar slides in. Defaults to 520. */
12
+ threshold?: number;
13
+ /** The call shortcut's accessible name. Defaults to "Call us". */
14
+ callLabel?: string;
15
+ }
16
+
17
+ /** The phone only bottom bar: the primary CTA and a call shortcut, sliding in once scrolled. */
18
+ export function StickyCTABar({
19
+ primaryCTA,
20
+ phone,
21
+ threshold = 520,
22
+ callLabel = "Call us",
23
+ }: StickyCTABarProps) {
24
+ const [show, setShow] = useState(false);
25
+
26
+ useEffect(() => {
27
+ const onScroll = () => setShow(window.scrollY > threshold);
28
+ window.addEventListener("scroll", onScroll, { passive: true });
29
+ onScroll();
30
+ return () => window.removeEventListener("scroll", onScroll);
31
+ }, [threshold]);
32
+
33
+ return (
34
+ // Inert while offscreen, so its links take no keyboard focus before it slides in.
35
+ <div className={show ? "sticky-cta show" : "sticky-cta"} inert={!show}>
36
+ <a
37
+ className="btn btn-primary"
38
+ href={primaryCTA.href}
39
+ {...newTabProps(primaryCTA.href, primaryCTA.external)}
40
+ >
41
+ {primaryCTA.label}
42
+ </a>
43
+ {phone && (
44
+ <a className="btn btn-ghost btn-call" href={`tel:${phone.e164}`} aria-label={callLabel}>
45
+ <PhoneIcon size={18} aria-hidden="true" />
46
+ </a>
47
+ )}
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,20 @@
1
+ export type { PageStop } from "../hooks/useScrollSpy";
2
+ export type {
3
+ Brand,
4
+ BrandLogo,
5
+ CallToAction,
6
+ HoursRow,
7
+ MenuContact,
8
+ NavLink,
9
+ Phone,
10
+ PostalAddress,
11
+ SiteLink,
12
+ SocialLink,
13
+ } from "../types";
14
+ export { BrandLockup, type BrandLockupProps } from "./BrandLockup";
15
+ export { CreditBar } from "./CreditBar";
16
+ export { Footer, type FooterProps } from "./Footer";
17
+ export { SiteHeader, type SiteHeaderProps, type SiteHeaderVariant } from "./SiteHeader";
18
+ export { SiteMenu, type SiteMenuProps } from "./SiteMenu";
19
+ export { SkipLink, type SkipLinkProps } from "./SkipLink";
20
+ export { StickyCTABar, type StickyCTABarProps } from "./StickyCTABar";
@@ -0,0 +1 @@
1
+ export { type PageStop, useScrollSpy } from "./useScrollSpy";
@@ -0,0 +1,44 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ /** One stop in a page's own navigation: an anchor already on the page. */
4
+ export interface PageStop {
5
+ /** Anchor id of an element already on the page. */
6
+ id: string;
7
+ label: string;
8
+ }
9
+
10
+ /**
11
+ * Scroll spy over a list of anchor ids: returns the id whose section is in
12
+ * the reading band, or null before any section has entered it. The header's
13
+ * page stops and a page's own jump rail both highlight from it.
14
+ */
15
+ export function useScrollSpy(ids: string[]): string | null {
16
+ const [activeId, setActiveId] = useState<string | null>(null);
17
+ // Key on the joined ids so callers can pass a freshly mapped array
18
+ // without tearing down the observer every render.
19
+ const key = ids.join("|");
20
+
21
+ useEffect(() => {
22
+ // A new id list never keeps an id it no longer contains.
23
+ setActiveId(null);
24
+ if (typeof IntersectionObserver === "undefined") return;
25
+ const sections = key
26
+ .split("|")
27
+ .filter(Boolean)
28
+ .map((id) => document.getElementById(id))
29
+ .filter((el): el is HTMLElement => el !== null);
30
+ if (sections.length === 0) return;
31
+ const observer = new IntersectionObserver(
32
+ (entries) => {
33
+ for (const entry of entries) {
34
+ if (entry.isIntersecting) setActiveId(entry.target.id);
35
+ }
36
+ },
37
+ { rootMargin: "-30% 0px -60% 0px" },
38
+ );
39
+ for (const el of sections) observer.observe(el);
40
+ return () => observer.disconnect();
41
+ }, [key]);
42
+
43
+ return activeId;
44
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The target="_blank" props for a link, suppressed on protocol links: a
3
+ * mailto: or tel: opened in a new tab leaves a dead blank tab on desktop and
4
+ * breaks the handler handoff on phones, so the new tab request is ignored for
5
+ * them no matter what the caller asks (learned on atelic.me's mailto CTA,
6
+ * 2026-08-28).
7
+ */
8
+ export function newTabProps(
9
+ href: string,
10
+ newTab: boolean | undefined,
11
+ ): { target: "_blank"; rel: "noopener" } | Record<string, never> {
12
+ if (!newTab || /^(mailto|tel|sms):/i.test(href)) return {};
13
+ return { target: "_blank", rel: "noopener" };
14
+ }
@@ -0,0 +1,221 @@
1
+ /* =================================================================
2
+ @atelic-action/ui BASE: the few unscoped resets, then the canvas,
3
+ typography, and layout helpers, scoped to .mkt.
4
+ Tokens are read here and never defined: each site's theme.css owns
5
+ them. Everything sits in the atelic-ui layer, so any rule a site
6
+ writes outside a layer wins without a specificity fight.
7
+ ================================================================= */
8
+ @layer atelic-ui {
9
+ /* ---- Resets ---- */
10
+ *,
11
+ *::before,
12
+ *::after {
13
+ box-sizing: border-box;
14
+ }
15
+ html,
16
+ body {
17
+ margin: 0;
18
+ padding: 0;
19
+ }
20
+ html {
21
+ -webkit-text-size-adjust: 100%;
22
+ scroll-behavior: smooth;
23
+ }
24
+ @media (prefers-reduced-motion: reduce) {
25
+ html {
26
+ scroll-behavior: auto;
27
+ }
28
+ }
29
+
30
+ /* ---- Page canvas ---- */
31
+ /* Horizontal overflow is clipped at the body (where it still propagates to
32
+ the viewport) rather than on .mkt itself: an overflow-x on .mkt would
33
+ silently break position: sticky for every descendant. */
34
+ body:has(.mkt) {
35
+ overflow-x: hidden;
36
+ }
37
+ .mkt {
38
+ font-family: var(--font-sans);
39
+ background: var(--surface);
40
+ color: var(--ink);
41
+ line-height: 1.55;
42
+ font-size: 17px;
43
+ -webkit-font-smoothing: antialiased;
44
+ text-rendering: optimizeLegibility;
45
+ }
46
+ .mkt a {
47
+ color: inherit;
48
+ text-decoration: none;
49
+ }
50
+ .mkt button {
51
+ font: inherit;
52
+ cursor: pointer;
53
+ border: none;
54
+ background: none;
55
+ color: inherit;
56
+ }
57
+ .mkt ul {
58
+ list-style: none;
59
+ padding: 0;
60
+ margin: 0;
61
+ }
62
+ .mkt img,
63
+ .mkt picture,
64
+ .mkt svg,
65
+ .mkt video {
66
+ display: block;
67
+ max-width: 100%;
68
+ }
69
+
70
+ /* ---- Typography ---- */
71
+ .mkt h1,
72
+ .mkt h2,
73
+ .mkt h3,
74
+ .mkt h4 {
75
+ /* Sites pairing a display face with a body face set --font-heading in
76
+ their theme; without it, headings share the body family. */
77
+ font-family: var(--font-heading, var(--font-sans));
78
+ line-height: 1.04;
79
+ font-weight: 800;
80
+ letter-spacing: -0.02em;
81
+ text-wrap: balance;
82
+ margin: 0;
83
+ }
84
+ .mkt h1 {
85
+ font-size: clamp(2.6rem, 8vw, 5rem);
86
+ }
87
+ .mkt h2 {
88
+ font-size: clamp(2rem, 5.5vw, 3.4rem);
89
+ }
90
+ .mkt h3 {
91
+ font-size: clamp(1.35rem, 3vw, 1.85rem);
92
+ letter-spacing: -0.015em;
93
+ }
94
+ .mkt h4 {
95
+ font-size: 1.15rem;
96
+ font-weight: 700;
97
+ letter-spacing: -0.01em;
98
+ }
99
+ .mkt p {
100
+ text-wrap: pretty;
101
+ margin: 0;
102
+ }
103
+ .mkt em.serif,
104
+ .mkt .serif-em {
105
+ font-family: var(--font-serif);
106
+ font-style: italic;
107
+ font-weight: 400;
108
+ letter-spacing: -0.01em;
109
+ color: var(--primary);
110
+ }
111
+ /* Serif accent words in headings carry the brand accent color (the
112
+ wordmark's signature), so the italic word reads as the highlight it is.
113
+ A page on a dark surface lifts its own to the bright primary. */
114
+ .mkt h1 .serif,
115
+ .mkt h2 .serif {
116
+ color: var(--primary);
117
+ }
118
+
119
+ /* ---- Layout helpers ---- */
120
+ .mkt .wrap {
121
+ width: 100%;
122
+ max-width: var(--max-width);
123
+ margin-inline: auto;
124
+ padding-inline: var(--gutter);
125
+ }
126
+ .mkt .section {
127
+ padding-block: clamp(56px, 9vw, 120px);
128
+ }
129
+ /* A middle step, for a page whose alternating bands do the separating and
130
+ whose default rhythm reads too airy as a result. */
131
+ .mkt .section-md {
132
+ padding-block: clamp(44px, 6.5vw, 84px);
133
+ }
134
+ .mkt .section-sm {
135
+ padding-block: clamp(40px, 6vw, 72px);
136
+ }
137
+ /* Section band modifiers, global because routes across chunks use them. */
138
+ .mkt .section.surface-alt {
139
+ background: var(--surface-alt);
140
+ }
141
+ .mkt .section.tight-top {
142
+ padding-top: clamp(20px, 3vw, 40px);
143
+ }
144
+ .mkt .section.tight-bottom {
145
+ padding-bottom: clamp(20px, 3vw, 40px);
146
+ }
147
+ .mkt .stack-sm > * + * {
148
+ margin-top: 0.75rem;
149
+ }
150
+ .mkt .stack > * + * {
151
+ margin-top: 1.25rem;
152
+ }
153
+ .mkt .center {
154
+ text-align: center;
155
+ }
156
+ .mkt .muted {
157
+ color: var(--neutral-600);
158
+ }
159
+ .mkt .nowrap {
160
+ white-space: nowrap;
161
+ }
162
+
163
+ /* ---- CTA row (pairs of buttons under headlines) ---- */
164
+ .mkt .cta-row {
165
+ display: flex;
166
+ flex-wrap: wrap;
167
+ gap: 14px;
168
+ margin-top: 32px;
169
+ }
170
+ .mkt .cta-row.center {
171
+ justify-content: center;
172
+ }
173
+
174
+ /* ---- Chip row ---- */
175
+ .mkt .chip-row {
176
+ display: flex;
177
+ flex-wrap: wrap;
178
+ gap: 10px;
179
+ }
180
+
181
+ /* ---- Prose (long form copy) ---- */
182
+ .mkt .prose {
183
+ max-width: 68ch;
184
+ }
185
+ .mkt .prose p {
186
+ margin-bottom: 1.1rem;
187
+ color: var(--ink-soft);
188
+ font-size: 1.08rem;
189
+ line-height: 1.7;
190
+ }
191
+ .mkt .prose h3 {
192
+ margin: 2.2rem 0 0.8rem;
193
+ }
194
+ .mkt .prose strong {
195
+ color: var(--ink);
196
+ }
197
+
198
+ /* Anchored sections sit under the fixed header; give every id an offset
199
+ so jump links do not land beneath it. */
200
+ .mkt [id] {
201
+ scroll-margin-top: calc(var(--nav-height) + 16px);
202
+ }
203
+
204
+ /* ---- Prose links (long form body copy) ----
205
+ The base anchor rule leaves links undecorated, and inside running text they
206
+ inherit the paragraph color, so a link in a letter or a long note is
207
+ invisible: the descriptive words read as prose and nobody clicks them.
208
+ Underline them wherever prose runs. Learned on a reveal letter, where the
209
+ whole ask was a link nobody could see. Scoped to paragraph links so a button
210
+ dropped into a prose block is untouched, and placed last so it reads after
211
+ the lighter anchor rules above. */
212
+ .mkt .prose p a:not(.btn) {
213
+ color: var(--ink);
214
+ border-bottom: 1px solid var(--neutral-300);
215
+ padding-bottom: 1px;
216
+ }
217
+ .mkt .prose p a:not(.btn):hover {
218
+ color: var(--primary);
219
+ border-color: var(--primary);
220
+ }
221
+ }