@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 +79 -0
- package/package.json +54 -0
- package/src/chrome/BrandLockup.tsx +29 -0
- package/src/chrome/CreditBar.tsx +18 -0
- package/src/chrome/Footer.tsx +243 -0
- package/src/chrome/SiteHeader.tsx +152 -0
- package/src/chrome/SiteMenu.tsx +130 -0
- package/src/chrome/SkipLink.tsx +16 -0
- package/src/chrome/StickyCTABar.tsx +50 -0
- package/src/chrome/index.ts +20 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useScrollSpy.ts +44 -0
- package/src/lib/newTabProps.ts +14 -0
- package/src/styles/base.css +221 -0
- package/src/styles/chrome.css +460 -0
- package/src/types.ts +88 -0
|
@@ -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 {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
|
+
}
|