@atelic-action/ui 0.12.1 → 0.14.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 +48 -4
- package/dist/gate/wall.d.ts +2 -1
- package/dist/gate/wall.js +2 -1
- package/package.json +4 -1
- package/src/components/GateLogin.tsx +190 -0
- package/src/components/Watermark.tsx +55 -0
- package/src/components/index.ts +3 -0
- package/src/gate/wall.ts +2 -1
- package/src/lib/readCookie.ts +20 -0
- package/src/primitives/Button.tsx +87 -0
- package/src/primitives/Chip.tsx +30 -0
- package/src/primitives/Eyebrow.tsx +25 -0
- package/src/primitives/Lead.tsx +17 -0
- package/src/primitives/SectionHeading.tsx +42 -0
- package/src/primitives/index.ts +5 -0
- package/src/styles/gate.css +207 -0
- package/src/styles/primitives.css +153 -0
package/README.md
CHANGED
|
@@ -31,21 +31,26 @@ Import the stylesheets in this order, from the root route or the site's base she
|
|
|
31
31
|
2. `@atelic-action/ui/styles/base.css`
|
|
32
32
|
3. `@atelic-action/ui/styles/chrome.css`
|
|
33
33
|
4. `@atelic-action/ui/styles/components.css`
|
|
34
|
-
5.
|
|
35
|
-
6.
|
|
34
|
+
5. `@atelic-action/ui/styles/primitives.css`
|
|
35
|
+
6. `@atelic-action/ui/styles/gate.css`, on a site that mounts the access gate
|
|
36
|
+
7. The site's own CSS
|
|
37
|
+
8. The site's `theme.css`, last
|
|
36
38
|
|
|
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
|
|
39
|
+
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 `.btn` classes, which `primitives.css` defines and a site may restyle.
|
|
38
40
|
|
|
39
41
|
## What Is Inside
|
|
40
42
|
|
|
41
43
|
| Import | Exports |
|
|
42
44
|
|---|---|
|
|
43
45
|
| `@atelic-action/ui/chrome` | `SiteHeader`, `SiteMenu`, `Footer`, `CreditBar`, `StickyCTABar`, `BrandLockup`, `SkipLink`, and their prop types |
|
|
44
|
-
| `@atelic-action/ui/components` | `NotFound` and the page components (`Checklist`, `DataTable`, `StatRow`, `Callout`, `Collapsible`, `StackedBar`, `ColumnChart`, `ThresholdScale`, `StarRating`, `Disclosure`), with their prop types (see [The Not Found Page](#the-not-found-page) and [Page Components](#page-components)) |
|
|
46
|
+
| `@atelic-action/ui/components` | `NotFound`, the access gate's `GateLogin` and `Watermark` (with `readCookie`), and the page components (`Checklist`, `DataTable`, `StatRow`, `Callout`, `Collapsible`, `StackedBar`, `ColumnChart`, `ThresholdScale`, `StarRating`, `Disclosure`), with their prop types (see [The Not Found Page](#the-not-found-page) and [Page Components](#page-components)) |
|
|
47
|
+
| `@atelic-action/ui/primitives` | `Button`, `Chip`, `Eyebrow`, `Lead`, `SectionHeading`, and their prop types (see [Primitives](#primitives)) |
|
|
45
48
|
| `@atelic-action/ui/email` | The email components, the theme provider, the plain text helpers, and their prop types (see [Email](#email)) |
|
|
46
49
|
| `@atelic-action/ui/email/render` | `renderEmail` and `renderFailureEmail`, the only entry that imports `react-dom/server` |
|
|
47
50
|
| `@atelic-action/ui/tokens` | `Palette`, `atelicPalette`, `Fonts`, `atelicFonts`, `toThemeCSS`, `themeTokenMap` |
|
|
48
51
|
| `@atelic-action/ui/hooks` | `useScrollSpy` and its `PageStop` type |
|
|
52
|
+
| `@atelic-action/ui/styles/primitives.css` | Styles for everything under `primitives`, the `.btn` classes included |
|
|
53
|
+
| `@atelic-action/ui/styles/gate.css` | Styles for `GateLogin` and `Watermark` |
|
|
49
54
|
| `@atelic-action/ui/gate` | `signToken`, `verifyToken`, `safeNext`, `needsSession`, and the token types |
|
|
50
55
|
| `@atelic-action/ui/gate/request-link` | `POST`, the handler that emails a sign in link, and `createRequestLink` |
|
|
51
56
|
| `@atelic-action/ui/gate/verify` | `GET`, the handler that turns a link into a session |
|
|
@@ -120,6 +125,21 @@ export function NotFoundPage() {
|
|
|
120
125
|
|
|
121
126
|
`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.
|
|
122
127
|
|
|
128
|
+
## Primitives
|
|
129
|
+
|
|
130
|
+
`Button`, `Chip`, `Eyebrow`, `Lead`, and `SectionHeading` are the small pieces a page is set in, the same in every template. Each renders from props and wears a class of the same name (`.btn`, `.chip`, `.eyebrow`, `.lead`, `.section-head`), styled by `primitives.css` from the theme tokens.
|
|
131
|
+
|
|
132
|
+
```tsx
|
|
133
|
+
import { Button, SectionHeading } from "@atelic-action/ui/primitives";
|
|
134
|
+
|
|
135
|
+
<SectionHeading eyebrow="Pricing" title="One number" lead="Flat and posted." />;
|
|
136
|
+
<Button href="/contact" size="lg" arrow>
|
|
137
|
+
Book a visit
|
|
138
|
+
</Button>;
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`Button` is an anchor when it has an `href` and a real button otherwise. It renders the `href` as given: a site served under a base path wraps it once and applies its own prefix there. Its arrow is hidden from the name a screen reader hears.
|
|
142
|
+
|
|
123
143
|
## The Access Gate
|
|
124
144
|
|
|
125
145
|
The gate puts a whole host behind an email sign in with no database: a reader on the allowlist asks for a link, the link starts a signed session cookie, and the middleware holds every page and file back from anyone without one. It is opt in and fails open, so a host with no `GATE_SESSION_SECRET` is fully public.
|
|
@@ -173,6 +193,30 @@ What it does not do, by having no store: a link can be used more than once until
|
|
|
173
193
|
|
|
174
194
|
This is the one corner of the package that ships compiled. Vercel runs a function's and a middleware's package imports as shipped and compiles nothing inside `node_modules`, so `bun run build` emits `src/gate/` to `dist/gate/`, and `prepack` runs it before every publish.
|
|
175
195
|
|
|
196
|
+
### The Screen and the Watermark
|
|
197
|
+
|
|
198
|
+
The two pieces a reader sees are ordinary components, in `@atelic-action/ui/components`, styled by `@atelic-action/ui/styles/gate.css` (import it after `components.css`):
|
|
199
|
+
|
|
200
|
+
```tsx
|
|
201
|
+
// src/routes/login.tsx: the screen alone inside the site's scope, no header
|
|
202
|
+
<div className="mkt">
|
|
203
|
+
<GateLogin
|
|
204
|
+
brandName={site.recipient.name}
|
|
205
|
+
eyebrow={site.gate?.eyebrow}
|
|
206
|
+
headline={site.gate?.headline}
|
|
207
|
+
lead={site.gate?.lead}
|
|
208
|
+
builtBy={site.sender.name}
|
|
209
|
+
contactEmail={site.sender.email}
|
|
210
|
+
/>
|
|
211
|
+
</div>;
|
|
212
|
+
|
|
213
|
+
// the site's shell, once, inside its `.mkt` wrapper, so every page behind
|
|
214
|
+
// the gate wears it (the styles are scoped there, like every sheet's)
|
|
215
|
+
<Watermark />;
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`GateLogin` posts to `/api/auth/request-link`; a site served under a base path passes its own `endpoint`. Its button wears the site's `.btn` classes, as the chrome's do. `Watermark` reads the `gate_email` cookie and draws nothing without one. `readCookie`, exported beside them, is the reader `Watermark` uses and a site's own code can: empty during prerender, for a missing cookie, and for one that will not decode. `GateLogin` says so when an address is not an email, holds a second submit while the first is in flight, and announces each outcome to a screen reader.
|
|
219
|
+
|
|
176
220
|
## Email
|
|
177
221
|
|
|
178
222
|
`@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/dist/gate/wall.d.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* the favicon in its head, and the brand marks the header draws, which every
|
|
12
12
|
* prerendered page preloads because the router's pending page carries the
|
|
13
13
|
* header (each site's e2e/wall.spec.ts fails when the login page asks for
|
|
14
|
-
* anything else); plus
|
|
14
|
+
* anything else); plus everything under /api/auth/, where the two sign in functions
|
|
15
|
+
* live and a host adds nothing private, the login page itself,
|
|
15
16
|
* robots.txt, and the uptime monitor's health.json. /assets/doc/ is the one
|
|
16
17
|
* part of /assets/ held back: the build writes every chunk carrying a
|
|
17
18
|
* private content module there (the private chunks build plugin), so a document's
|
package/dist/gate/wall.js
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* the favicon in its head, and the brand marks the header draws, which every
|
|
12
12
|
* prerendered page preloads because the router's pending page carries the
|
|
13
13
|
* header (each site's e2e/wall.spec.ts fails when the login page asks for
|
|
14
|
-
* anything else); plus
|
|
14
|
+
* anything else); plus everything under /api/auth/, where the two sign in functions
|
|
15
|
+
* live and a host adds nothing private, the login page itself,
|
|
15
16
|
* robots.txt, and the uptime monitor's health.json. /assets/doc/ is the one
|
|
16
17
|
* part of /assets/ held back: the build writes every chunk carrying a
|
|
17
18
|
* private content module there (the private chunks build plugin), so a document's
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atelic-action/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Shared UI for the Atelic templates: site chrome, base styles, the component library, and the access gate they install",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -24,11 +24,14 @@
|
|
|
24
24
|
"./email": "./src/email/index.ts",
|
|
25
25
|
"./email/render": "./src/email/render.tsx",
|
|
26
26
|
"./hooks": "./src/hooks/index.ts",
|
|
27
|
+
"./primitives": "./src/primitives/index.ts",
|
|
27
28
|
"./routing": "./src/routing/index.ts",
|
|
28
29
|
"./tokens": "./src/tokens/index.ts",
|
|
29
30
|
"./styles/base.css": "./src/styles/base.css",
|
|
30
31
|
"./styles/chrome.css": "./src/styles/chrome.css",
|
|
31
32
|
"./styles/components.css": "./src/styles/components.css",
|
|
33
|
+
"./styles/gate.css": "./src/styles/gate.css",
|
|
34
|
+
"./styles/primitives.css": "./src/styles/primitives.css",
|
|
32
35
|
"./styles/star-rating.css": "./src/styles/star-rating.css",
|
|
33
36
|
"./gate": {
|
|
34
37
|
"types": "./dist/gate/index.d.ts",
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { type FormEvent, useEffect, useId, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
type Status = "idle" | "sending" | "sent" | "failed";
|
|
4
|
+
|
|
5
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6
|
+
|
|
7
|
+
export interface GateLoginProps {
|
|
8
|
+
/** Who the page behind the gate is for, set at the top of the screen. */
|
|
9
|
+
brandName: string;
|
|
10
|
+
/** The small line over the headline; with it the brand wears a "Preview" tag. */
|
|
11
|
+
eyebrow?: string;
|
|
12
|
+
headline?: string;
|
|
13
|
+
lead?: string;
|
|
14
|
+
/** Who built what is behind the gate, in the fine print. */
|
|
15
|
+
builtBy?: string;
|
|
16
|
+
/** A reply address, in the fine print. */
|
|
17
|
+
contactEmail?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Where the form posts. A site served under a base path passes its own
|
|
20
|
+
* path to the function.
|
|
21
|
+
*/
|
|
22
|
+
endpoint?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The access gate's sign in screen. Collects an email and posts it to the
|
|
27
|
+
* request link function, which emails a sign in link only to allowlisted
|
|
28
|
+
* addresses. The response is the same either way, so this screen always
|
|
29
|
+
* shows the same "check your inbox" confirmation.
|
|
30
|
+
*
|
|
31
|
+
* It renders the screen only: a site wraps it in its `.mkt` scope with no
|
|
32
|
+
* header, since a header would only point at pages the visitor cannot open
|
|
33
|
+
* yet. The button wears the site's own `.btn` classes, as the chrome's do.
|
|
34
|
+
*/
|
|
35
|
+
export function GateLogin({
|
|
36
|
+
brandName,
|
|
37
|
+
eyebrow,
|
|
38
|
+
headline = "A first look, by invitation.",
|
|
39
|
+
lead = "Enter your email and we will send a private link to the document.",
|
|
40
|
+
builtBy,
|
|
41
|
+
contactEmail,
|
|
42
|
+
endpoint = "/api/auth/request-link",
|
|
43
|
+
}: GateLoginProps) {
|
|
44
|
+
const [status, setStatus] = useState<Status>("idle");
|
|
45
|
+
const [sentTo, setSentTo] = useState("");
|
|
46
|
+
const [expired, setExpired] = useState(false);
|
|
47
|
+
const [invalid, setInvalid] = useState(false);
|
|
48
|
+
const input = useRef<HTMLInputElement>(null);
|
|
49
|
+
const confirmation = useRef<HTMLHeadingElement>(null);
|
|
50
|
+
// A ref, since two submits can land before React renders the first one's state.
|
|
51
|
+
const inFlight = useRef(false);
|
|
52
|
+
const errorId = useId();
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const params = new URLSearchParams(window.location.search);
|
|
56
|
+
if (params.get("e") === "expired") setExpired(true);
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
// The form leaves the page when the confirmation arrives, and focus would
|
|
60
|
+
// fall to the body with it. Moving it to the heading is also what tells a
|
|
61
|
+
// screen reader the request went through.
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (status === "sent") confirmation.current?.focus();
|
|
64
|
+
}, [status]);
|
|
65
|
+
|
|
66
|
+
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
const form = event.currentTarget;
|
|
69
|
+
// A second submit while the first is in flight would post twice, and a
|
|
70
|
+
// late failure would then pull the confirmation back to the form.
|
|
71
|
+
if (inFlight.current) return;
|
|
72
|
+
const email = String(new FormData(form).get("email") ?? "").trim();
|
|
73
|
+
if (!EMAIL_PATTERN.test(email)) {
|
|
74
|
+
// Nothing was sent, so an earlier failure no longer describes anything.
|
|
75
|
+
setStatus("idle");
|
|
76
|
+
setInvalid(true);
|
|
77
|
+
input.current?.focus();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
setInvalid(false);
|
|
81
|
+
|
|
82
|
+
const next = new URLSearchParams(window.location.search).get("next") ?? "/";
|
|
83
|
+
inFlight.current = true;
|
|
84
|
+
setStatus("sending");
|
|
85
|
+
try {
|
|
86
|
+
const response = await fetch(endpoint, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: { "Content-Type": "application/json" },
|
|
89
|
+
body: JSON.stringify({ email, next }),
|
|
90
|
+
});
|
|
91
|
+
if (!response.ok) throw new Error(`status ${response.status}`);
|
|
92
|
+
setSentTo(email);
|
|
93
|
+
setStatus("sent");
|
|
94
|
+
setExpired(false);
|
|
95
|
+
} catch {
|
|
96
|
+
setStatus("failed");
|
|
97
|
+
} finally {
|
|
98
|
+
inFlight.current = false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// "Start over" swaps the confirmation for the form, and the button that
|
|
103
|
+
// held focus goes with it, so focus is handed to the field it leads to.
|
|
104
|
+
function startOver() {
|
|
105
|
+
setStatus("idle");
|
|
106
|
+
requestAnimationFrame(() => input.current?.focus());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div className="gate-screen">
|
|
111
|
+
<div className="gate-brand">
|
|
112
|
+
<span className="gate-brand-name">{brandName}</span>
|
|
113
|
+
{eyebrow && <span className="gate-brand-sub">Preview</span>}
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<div className="gate-body">
|
|
117
|
+
{eyebrow && <p className="gate-eyebrow">{eyebrow}</p>}
|
|
118
|
+
|
|
119
|
+
{status === "sent" ? (
|
|
120
|
+
<>
|
|
121
|
+
<h1 className="gate-headline" ref={confirmation} tabIndex={-1}>
|
|
122
|
+
Check your inbox.
|
|
123
|
+
</h1>
|
|
124
|
+
<div className="gate-sent">
|
|
125
|
+
If <strong>{sentTo}</strong> is on the list, a sign in link is on its way. The link
|
|
126
|
+
expires shortly if unused, and once you are in, you stay signed in for a while.
|
|
127
|
+
</div>
|
|
128
|
+
<p className="gate-fine">
|
|
129
|
+
Wrong address?{" "}
|
|
130
|
+
<button type="button" className="gate-link-button" onClick={startOver}>
|
|
131
|
+
Start over
|
|
132
|
+
</button>
|
|
133
|
+
</p>
|
|
134
|
+
</>
|
|
135
|
+
) : (
|
|
136
|
+
<>
|
|
137
|
+
<h1 className="gate-headline">{headline}</h1>
|
|
138
|
+
{expired && (
|
|
139
|
+
<p className="gate-error" role="alert">
|
|
140
|
+
That link has expired. Enter your email and we will send a fresh one.
|
|
141
|
+
</p>
|
|
142
|
+
)}
|
|
143
|
+
<p className="gate-lead">{lead}</p>
|
|
144
|
+
<form className="gate-form" onSubmit={handleSubmit} noValidate>
|
|
145
|
+
<input
|
|
146
|
+
className="gate-input"
|
|
147
|
+
type="email"
|
|
148
|
+
name="email"
|
|
149
|
+
placeholder="you@company.com"
|
|
150
|
+
autoComplete="email"
|
|
151
|
+
aria-label="Your email"
|
|
152
|
+
aria-invalid={invalid || undefined}
|
|
153
|
+
aria-describedby={invalid ? errorId : undefined}
|
|
154
|
+
ref={input}
|
|
155
|
+
required
|
|
156
|
+
/>
|
|
157
|
+
<button
|
|
158
|
+
type="submit"
|
|
159
|
+
className="btn btn-primary btn-lg"
|
|
160
|
+
disabled={status === "sending"}
|
|
161
|
+
>
|
|
162
|
+
{status === "sending" ? "Sending…" : "Email me my link"}
|
|
163
|
+
<span className="arrow" aria-hidden="true">
|
|
164
|
+
→
|
|
165
|
+
</span>
|
|
166
|
+
</button>
|
|
167
|
+
</form>
|
|
168
|
+
{invalid && (
|
|
169
|
+
<p className="gate-error gate-error-after" id={errorId} role="alert">
|
|
170
|
+
That does not look like an email address. Check it and try again.
|
|
171
|
+
</p>
|
|
172
|
+
)}
|
|
173
|
+
{status === "failed" && (
|
|
174
|
+
<p className="gate-error gate-error-after" role="alert">
|
|
175
|
+
Something went wrong. Please try again in a moment.
|
|
176
|
+
</p>
|
|
177
|
+
)}
|
|
178
|
+
{(builtBy || contactEmail) && (
|
|
179
|
+
<p className="gate-fine">
|
|
180
|
+
{builtBy && `Built by ${builtBy}`}
|
|
181
|
+
{builtBy && contactEmail && " · "}
|
|
182
|
+
{contactEmail && <a href={`mailto:${contactEmail}`}>{contactEmail}</a>}
|
|
183
|
+
</p>
|
|
184
|
+
)}
|
|
185
|
+
</>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { readCookie } from "../lib/readCookie";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The confidential watermark stamped on every page behind the access gate.
|
|
6
|
+
* Reads the signed in viewer's email from the readable `gate_email` cookie
|
|
7
|
+
* (set when a sign in link is verified) and paints a corner pill, plus a
|
|
8
|
+
* faint tiled overlay when the gate runs in "tiled" mode. Renders nothing
|
|
9
|
+
* when there is no session, so it is inert on a public site and during
|
|
10
|
+
* prerender. The email travels into any screenshot. Mount it inside the
|
|
11
|
+
* site's `.mkt` (or `.atelic-ui`) wrapper, where its styles are scoped.
|
|
12
|
+
*
|
|
13
|
+
* It deters and attributes; it does not enforce. The cookies it reads are
|
|
14
|
+
* unsigned and it is drawn in the reader's own browser.
|
|
15
|
+
*/
|
|
16
|
+
export function Watermark() {
|
|
17
|
+
const [email, setEmail] = useState("");
|
|
18
|
+
const [mode, setMode] = useState("pill");
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
setEmail(readCookie("gate_email"));
|
|
22
|
+
setMode(readCookie("gate_wm") || "pill");
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
if (!email) return null;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<>
|
|
29
|
+
{mode === "tiled" && (
|
|
30
|
+
<div className="gate-wm-tile-layer" aria-hidden="true">
|
|
31
|
+
{Array.from({ length: 32 }).map((_, index) => {
|
|
32
|
+
const row = Math.floor(index / 4);
|
|
33
|
+
const col = index % 4;
|
|
34
|
+
return (
|
|
35
|
+
<span
|
|
36
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: fixed static grid, order is stable
|
|
37
|
+
key={index}
|
|
38
|
+
className="gate-wm-tile"
|
|
39
|
+
style={{ top: `${row * 13 + 2}%`, left: `${col * 27 - 6 + (row % 2 ? 13 : 0)}%` }}
|
|
40
|
+
>
|
|
41
|
+
confidential · {email}
|
|
42
|
+
</span>
|
|
43
|
+
);
|
|
44
|
+
})}
|
|
45
|
+
</div>
|
|
46
|
+
)}
|
|
47
|
+
<div className="gate-wm" aria-hidden="true">
|
|
48
|
+
<span className="gate-wm-dot" />
|
|
49
|
+
<span className="gate-wm-text">
|
|
50
|
+
Confidential preview · <b>{email}</b>
|
|
51
|
+
</span>
|
|
52
|
+
</div>
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { readCookie } from "../lib/readCookie";
|
|
1
2
|
export { Callout, type CalloutProps } from "./Callout";
|
|
2
3
|
export { Checklist, type ChecklistItem, type ChecklistProps } from "./Checklist";
|
|
3
4
|
export { Collapsible, type CollapsibleProps } from "./Collapsible";
|
|
@@ -17,6 +18,7 @@ export {
|
|
|
17
18
|
type SortDirection,
|
|
18
19
|
} from "./DataTable";
|
|
19
20
|
export { Disclosure, type DisclosureProps } from "./Disclosure";
|
|
21
|
+
export { GateLogin, type GateLoginProps } from "./GateLogin";
|
|
20
22
|
export { NotFound, type NotFoundProps } from "./NotFound";
|
|
21
23
|
export { StackedBar, type StackedBarProps, type StackedBarSegment } from "./StackedBar";
|
|
22
24
|
export { describeStarRating, StarRating, type StarRatingProps } from "./StarRating";
|
|
@@ -34,3 +36,4 @@ export {
|
|
|
34
36
|
type ThresholdZone,
|
|
35
37
|
} from "./ThresholdScale";
|
|
36
38
|
export type { Tone } from "./tone";
|
|
39
|
+
export { Watermark } from "./Watermark";
|
package/src/gate/wall.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* the favicon in its head, and the brand marks the header draws, which every
|
|
12
12
|
* prerendered page preloads because the router's pending page carries the
|
|
13
13
|
* header (each site's e2e/wall.spec.ts fails when the login page asks for
|
|
14
|
-
* anything else); plus
|
|
14
|
+
* anything else); plus everything under /api/auth/, where the two sign in functions
|
|
15
|
+
* live and a host adds nothing private, the login page itself,
|
|
15
16
|
* robots.txt, and the uptime monitor's health.json. /assets/doc/ is the one
|
|
16
17
|
* part of /assets/ held back: the build writes every chunk carrying a
|
|
17
18
|
* private content module there (the private chunks build plugin), so a document's
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read one cookie the page is allowed to see. Returns "" during prerender,
|
|
3
|
+
* when the cookie is not set, and when its value is not valid percent
|
|
4
|
+
* encoding, so a caller can treat all three as absent. The name is matched
|
|
5
|
+
* as written, never as a pattern, so one holding a dot or a bracket reads
|
|
6
|
+
* its own cookie and nothing else.
|
|
7
|
+
*/
|
|
8
|
+
export function readCookie(name: string): string {
|
|
9
|
+
if (typeof document === "undefined" || !name) return "";
|
|
10
|
+
for (const pair of document.cookie.split(";")) {
|
|
11
|
+
const split = pair.indexOf("=");
|
|
12
|
+
if (split === -1 || pair.slice(0, split).trim() !== name) continue;
|
|
13
|
+
try {
|
|
14
|
+
return decodeURIComponent(pair.slice(split + 1).trim());
|
|
15
|
+
} catch {
|
|
16
|
+
return "";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
import { newTabProps } from "../lib/newTabProps";
|
|
3
|
+
|
|
4
|
+
export interface ButtonProps {
|
|
5
|
+
variant?: "primary" | "dark" | "ghost";
|
|
6
|
+
size?: "md" | "lg";
|
|
7
|
+
/** Inverts ghost styling for use on dark surfaces. */
|
|
8
|
+
onDark?: boolean;
|
|
9
|
+
/** Appends the animated arrow glyph. */
|
|
10
|
+
arrow?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Link target: an internal path ("/services") or protocol link
|
|
13
|
+
* (https, tel, mailto). Marketing pages navigate as an MPA, so plain
|
|
14
|
+
* anchors are intentional here.
|
|
15
|
+
*/
|
|
16
|
+
href?: string;
|
|
17
|
+
/** Open href in a new tab with rel="noopener". */
|
|
18
|
+
newTab?: boolean;
|
|
19
|
+
/** Renders a real button element (e.g. form submit) when set. */
|
|
20
|
+
type?: "submit" | "button";
|
|
21
|
+
onClick?: () => void;
|
|
22
|
+
ariaLabel?: string;
|
|
23
|
+
className?: string;
|
|
24
|
+
style?: CSSProperties;
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function Button({
|
|
29
|
+
variant = "primary",
|
|
30
|
+
size = "md",
|
|
31
|
+
onDark = false,
|
|
32
|
+
arrow = false,
|
|
33
|
+
href,
|
|
34
|
+
newTab = false,
|
|
35
|
+
type,
|
|
36
|
+
onClick,
|
|
37
|
+
ariaLabel,
|
|
38
|
+
className,
|
|
39
|
+
style,
|
|
40
|
+
children,
|
|
41
|
+
}: ButtonProps) {
|
|
42
|
+
const classes = [
|
|
43
|
+
"btn",
|
|
44
|
+
`btn-${variant}`,
|
|
45
|
+
size === "lg" && "btn-lg",
|
|
46
|
+
onDark && "on-dark",
|
|
47
|
+
className,
|
|
48
|
+
]
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
.join(" ");
|
|
51
|
+
|
|
52
|
+
const content = (
|
|
53
|
+
<>
|
|
54
|
+
{children}
|
|
55
|
+
{arrow && (
|
|
56
|
+
<span className="arrow" aria-hidden="true">
|
|
57
|
+
→
|
|
58
|
+
</span>
|
|
59
|
+
)}
|
|
60
|
+
</>
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
if (href) {
|
|
64
|
+
return (
|
|
65
|
+
<a
|
|
66
|
+
href={href}
|
|
67
|
+
className={classes}
|
|
68
|
+
style={style}
|
|
69
|
+
aria-label={ariaLabel}
|
|
70
|
+
{...newTabProps(href, newTab)}
|
|
71
|
+
>
|
|
72
|
+
{content}
|
|
73
|
+
</a>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return (
|
|
77
|
+
<button
|
|
78
|
+
type={type ?? "button"}
|
|
79
|
+
className={classes}
|
|
80
|
+
style={style}
|
|
81
|
+
aria-label={ariaLabel}
|
|
82
|
+
onClick={onClick}
|
|
83
|
+
>
|
|
84
|
+
{content}
|
|
85
|
+
</button>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface ChipProps {
|
|
4
|
+
/** Shows the small brand-colored dot before the label. */
|
|
5
|
+
dot?: boolean;
|
|
6
|
+
/** Inverts styling for dark surfaces. */
|
|
7
|
+
onDark?: boolean;
|
|
8
|
+
/** Renders the chip as an anchor (e.g. jump links on a page hero). */
|
|
9
|
+
href?: string;
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Small mono tag/badge, used for credentials, tags, and service areas. */
|
|
14
|
+
export function Chip({ dot = false, onDark = false, href, children }: ChipProps) {
|
|
15
|
+
const classes = ["chip", onDark && "on-dark"].filter(Boolean).join(" ");
|
|
16
|
+
const content = (
|
|
17
|
+
<>
|
|
18
|
+
{dot && <span className="dot" />}
|
|
19
|
+
{children}
|
|
20
|
+
</>
|
|
21
|
+
);
|
|
22
|
+
if (href) {
|
|
23
|
+
return (
|
|
24
|
+
<a className={classes} href={href}>
|
|
25
|
+
{content}
|
|
26
|
+
</a>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return <span className={classes}>{content}</span>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface EyebrowProps {
|
|
4
|
+
/** Lifts the color for dark surfaces. */
|
|
5
|
+
onDark?: boolean;
|
|
6
|
+
/** Centers the eyebrow (its trailing fade included). */
|
|
7
|
+
center?: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Deprecated no-op. The trailing fade (the wordmark's signature) is now
|
|
10
|
+
* the default and only rendering; the old leading-dash mode is retired.
|
|
11
|
+
* Accepted so existing call sites keep compiling.
|
|
12
|
+
*/
|
|
13
|
+
run?: boolean;
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The small mono, uppercase kicker line above a heading, trailed by a bar
|
|
19
|
+
* that fades out to the right (the practice wordmark's signature), rendered
|
|
20
|
+
* from the primary token so it carries each site's brand color.
|
|
21
|
+
*/
|
|
22
|
+
export function Eyebrow({ onDark = false, center = false, children }: EyebrowProps) {
|
|
23
|
+
const classes = ["eyebrow", onDark && "on-dark", center && "center"].filter(Boolean).join(" ");
|
|
24
|
+
return <p className={classes}>{children}</p>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface LeadProps {
|
|
4
|
+
className?: string;
|
|
5
|
+
style?: CSSProperties;
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Lead paragraph: the larger intro copy under a heading. */
|
|
10
|
+
export function Lead({ className, style, children }: LeadProps) {
|
|
11
|
+
const classes = ["lead", className].filter(Boolean).join(" ");
|
|
12
|
+
return (
|
|
13
|
+
<p className={classes} style={style}>
|
|
14
|
+
{children}
|
|
15
|
+
</p>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Eyebrow } from "./Eyebrow";
|
|
3
|
+
import { Lead } from "./Lead";
|
|
4
|
+
|
|
5
|
+
export interface SectionHeadingProps {
|
|
6
|
+
eyebrow?: string;
|
|
7
|
+
/** Heading content; may include serif emphasis spans. */
|
|
8
|
+
title: ReactNode;
|
|
9
|
+
lead?: ReactNode;
|
|
10
|
+
center?: boolean;
|
|
11
|
+
/** Lifts colors for dark surfaces. */
|
|
12
|
+
onDark?: boolean;
|
|
13
|
+
/** Deprecated no-op: the eyebrow's trailing fade is now its only rendering. */
|
|
14
|
+
eyebrowRun?: boolean;
|
|
15
|
+
/** Heading level; sections default to h2. */
|
|
16
|
+
as?: "h1" | "h2" | "h3";
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Standard section opener: eyebrow, title, optional lead. */
|
|
21
|
+
export function SectionHeading({
|
|
22
|
+
eyebrow,
|
|
23
|
+
title,
|
|
24
|
+
lead,
|
|
25
|
+
center = false,
|
|
26
|
+
onDark = false,
|
|
27
|
+
as: Heading = "h2",
|
|
28
|
+
className,
|
|
29
|
+
}: SectionHeadingProps) {
|
|
30
|
+
const classes = ["section-head", center && "center", className].filter(Boolean).join(" ");
|
|
31
|
+
return (
|
|
32
|
+
<div className={classes}>
|
|
33
|
+
{eyebrow && (
|
|
34
|
+
<Eyebrow onDark={onDark} center={center}>
|
|
35
|
+
{eyebrow}
|
|
36
|
+
</Eyebrow>
|
|
37
|
+
)}
|
|
38
|
+
<Heading>{title}</Heading>
|
|
39
|
+
{lead && <Lead className="section-head-lead">{lead}</Lead>}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { Button, type ButtonProps } from "./Button";
|
|
2
|
+
export { Chip, type ChipProps } from "./Chip";
|
|
3
|
+
export { Eyebrow, type EyebrowProps } from "./Eyebrow";
|
|
4
|
+
export { Lead, type LeadProps } from "./Lead";
|
|
5
|
+
export { SectionHeading, type SectionHeadingProps } from "./SectionHeading";
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/* The access gate's two faces: the sign in screen (GateLogin) and the
|
|
2
|
+
confidential watermark (Watermark). Every value is a theme token, so the
|
|
3
|
+
gate recolors with the rest of a site. Scoped under `.mkt` or
|
|
4
|
+
`.atelic-ui`, which also lifts each rule over the base heading and
|
|
5
|
+
paragraph rules inside this layer. */
|
|
6
|
+
@layer atelic-ui {
|
|
7
|
+
:is(.mkt, .atelic-ui) .gate-screen {
|
|
8
|
+
min-height: 100vh;
|
|
9
|
+
display: flex;
|
|
10
|
+
flex-direction: column;
|
|
11
|
+
background: var(--surface);
|
|
12
|
+
color: var(--ink);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
:is(.mkt, .atelic-ui) .gate-brand {
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: baseline;
|
|
18
|
+
gap: 10px;
|
|
19
|
+
padding: 22px 32px;
|
|
20
|
+
border-bottom: 1px solid var(--neutral-100);
|
|
21
|
+
}
|
|
22
|
+
:is(.mkt, .atelic-ui) .gate-brand-name {
|
|
23
|
+
font-family: var(--font-serif);
|
|
24
|
+
font-style: italic;
|
|
25
|
+
font-size: 24px;
|
|
26
|
+
color: var(--ui-primary, var(--primary));
|
|
27
|
+
}
|
|
28
|
+
:is(.mkt, .atelic-ui) .gate-brand-sub {
|
|
29
|
+
font-family: var(--font-mono);
|
|
30
|
+
font-size: 11px;
|
|
31
|
+
letter-spacing: 0.22em;
|
|
32
|
+
text-transform: uppercase;
|
|
33
|
+
color: var(--ink-soft);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
:is(.mkt, .atelic-ui) .gate-body {
|
|
37
|
+
flex: 1;
|
|
38
|
+
display: flex;
|
|
39
|
+
flex-direction: column;
|
|
40
|
+
justify-content: center;
|
|
41
|
+
align-items: center;
|
|
42
|
+
text-align: center;
|
|
43
|
+
padding: 52px 24px 44px;
|
|
44
|
+
}
|
|
45
|
+
:is(.mkt, .atelic-ui) .gate-eyebrow {
|
|
46
|
+
font-family: var(--font-mono);
|
|
47
|
+
font-size: 12px;
|
|
48
|
+
letter-spacing: 0.18em;
|
|
49
|
+
text-transform: uppercase;
|
|
50
|
+
color: var(--ui-primary, var(--primary));
|
|
51
|
+
margin: 0 0 16px;
|
|
52
|
+
}
|
|
53
|
+
:is(.mkt, .atelic-ui) .gate-headline {
|
|
54
|
+
font-family: var(--font-serif);
|
|
55
|
+
font-size: clamp(2rem, 4.4vw, 2.7rem);
|
|
56
|
+
line-height: 1.08;
|
|
57
|
+
margin: 0 0 16px;
|
|
58
|
+
max-width: 16ch;
|
|
59
|
+
text-wrap: balance;
|
|
60
|
+
color: var(--ink);
|
|
61
|
+
}
|
|
62
|
+
:is(.mkt, .atelic-ui) .gate-lead {
|
|
63
|
+
font-size: 1.05rem;
|
|
64
|
+
color: var(--ink-soft);
|
|
65
|
+
max-width: 46ch;
|
|
66
|
+
margin: 0 0 30px;
|
|
67
|
+
text-wrap: pretty;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
:is(.mkt, .atelic-ui) .gate-form {
|
|
71
|
+
display: flex;
|
|
72
|
+
gap: 10px;
|
|
73
|
+
width: 100%;
|
|
74
|
+
max-width: 440px;
|
|
75
|
+
flex-wrap: wrap;
|
|
76
|
+
justify-content: center;
|
|
77
|
+
}
|
|
78
|
+
:is(.mkt, .atelic-ui) .gate-input {
|
|
79
|
+
flex: 1;
|
|
80
|
+
min-width: 220px;
|
|
81
|
+
font-family: var(--font-sans);
|
|
82
|
+
font-size: 15px;
|
|
83
|
+
/* The pill, matching the primary Button, so the pair reads as one
|
|
84
|
+
control set (Forni's review of the SkySpec gate, 2026-08-25). */
|
|
85
|
+
padding: 15px 22px;
|
|
86
|
+
border-radius: var(--radius-pill);
|
|
87
|
+
border: 1px solid var(--neutral-200);
|
|
88
|
+
background: var(--ui-card, var(--card));
|
|
89
|
+
color: var(--ink);
|
|
90
|
+
outline: none;
|
|
91
|
+
}
|
|
92
|
+
:is(.mkt, .atelic-ui) .gate-input:focus-visible {
|
|
93
|
+
border-color: var(--ui-primary, var(--primary));
|
|
94
|
+
box-shadow: 0 0 0 3px var(--primary-focus-ring);
|
|
95
|
+
}
|
|
96
|
+
:is(.mkt, .atelic-ui) .gate-input::placeholder {
|
|
97
|
+
color: var(--neutral-500);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
:is(.mkt, .atelic-ui) .gate-fine {
|
|
101
|
+
margin: 26px 0 0;
|
|
102
|
+
font-family: var(--font-mono);
|
|
103
|
+
font-size: 11.5px;
|
|
104
|
+
color: var(--neutral-500);
|
|
105
|
+
}
|
|
106
|
+
:is(.mkt, .atelic-ui) .gate-fine a {
|
|
107
|
+
color: var(--ui-primary, var(--primary));
|
|
108
|
+
text-decoration: none;
|
|
109
|
+
}
|
|
110
|
+
:is(.mkt, .atelic-ui) .gate-fine a:hover {
|
|
111
|
+
text-decoration: underline;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
:is(.mkt, .atelic-ui) .gate-sent {
|
|
115
|
+
margin: 4px auto 0;
|
|
116
|
+
max-width: 440px;
|
|
117
|
+
border: 1px solid var(--neutral-100);
|
|
118
|
+
border-radius: var(--radius-sm);
|
|
119
|
+
background: var(--primary-tint);
|
|
120
|
+
padding: 18px 20px;
|
|
121
|
+
text-align: left;
|
|
122
|
+
color: var(--ink-soft);
|
|
123
|
+
}
|
|
124
|
+
:is(.mkt, .atelic-ui) .gate-sent strong {
|
|
125
|
+
color: var(--ink);
|
|
126
|
+
}
|
|
127
|
+
:is(.mkt, .atelic-ui) .gate-error {
|
|
128
|
+
margin: 0 0 22px;
|
|
129
|
+
max-width: 44ch;
|
|
130
|
+
font-size: 0.95rem;
|
|
131
|
+
color: var(--ink);
|
|
132
|
+
background: var(--surface-alt);
|
|
133
|
+
border: 1px solid var(--neutral-200);
|
|
134
|
+
border-radius: var(--radius-sm);
|
|
135
|
+
padding: 11px 16px;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* The "Start over" control reads as a link in the fine print. */
|
|
139
|
+
:is(.mkt, .atelic-ui) .gate-link-button {
|
|
140
|
+
background: none;
|
|
141
|
+
border: none;
|
|
142
|
+
color: var(--ui-primary, var(--primary));
|
|
143
|
+
cursor: pointer;
|
|
144
|
+
font: inherit;
|
|
145
|
+
padding: 0;
|
|
146
|
+
}
|
|
147
|
+
/* The failure line under the form, where the default bottom margin is spare. */
|
|
148
|
+
:is(.mkt, .atelic-ui) .gate-error.gate-error-after {
|
|
149
|
+
margin-top: 22px;
|
|
150
|
+
margin-bottom: 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ---- Watermark ---- */
|
|
154
|
+
:is(.mkt, .atelic-ui) .gate-wm {
|
|
155
|
+
position: fixed;
|
|
156
|
+
right: 16px;
|
|
157
|
+
bottom: 16px;
|
|
158
|
+
z-index: 2147483000;
|
|
159
|
+
display: flex;
|
|
160
|
+
align-items: center;
|
|
161
|
+
gap: 9px;
|
|
162
|
+
max-width: calc(100vw - 32px);
|
|
163
|
+
padding: 8px 13px;
|
|
164
|
+
border-radius: 999px;
|
|
165
|
+
font-family: var(--font-mono);
|
|
166
|
+
font-size: 11px;
|
|
167
|
+
letter-spacing: 0.03em;
|
|
168
|
+
background: var(--surface-dark);
|
|
169
|
+
color: var(--surface);
|
|
170
|
+
box-shadow: var(--shadow-md, 0 8px 24px -10px rgba(0, 0, 0, 0.5));
|
|
171
|
+
pointer-events: none;
|
|
172
|
+
}
|
|
173
|
+
:is(.mkt, .atelic-ui) .gate-wm b {
|
|
174
|
+
color: #fff;
|
|
175
|
+
font-weight: 600;
|
|
176
|
+
}
|
|
177
|
+
:is(.mkt, .atelic-ui) .gate-wm-dot {
|
|
178
|
+
width: 7px;
|
|
179
|
+
height: 7px;
|
|
180
|
+
border-radius: 50%;
|
|
181
|
+
background: var(--primary-bright);
|
|
182
|
+
flex-shrink: 0;
|
|
183
|
+
}
|
|
184
|
+
:is(.mkt, .atelic-ui) .gate-wm-text {
|
|
185
|
+
overflow: hidden;
|
|
186
|
+
text-overflow: ellipsis;
|
|
187
|
+
white-space: nowrap;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
:is(.mkt, .atelic-ui) .gate-wm-tile-layer {
|
|
191
|
+
position: fixed;
|
|
192
|
+
inset: 0;
|
|
193
|
+
z-index: 2147482000;
|
|
194
|
+
pointer-events: none;
|
|
195
|
+
overflow: hidden;
|
|
196
|
+
opacity: 0.5;
|
|
197
|
+
}
|
|
198
|
+
:is(.mkt, .atelic-ui) .gate-wm-tile {
|
|
199
|
+
position: absolute;
|
|
200
|
+
font-family: var(--font-mono);
|
|
201
|
+
font-size: 12px;
|
|
202
|
+
color: var(--ui-primary, var(--primary));
|
|
203
|
+
opacity: 0.12;
|
|
204
|
+
white-space: nowrap;
|
|
205
|
+
transform: rotate(-30deg);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/* The primitives under src/primitives: the button, the chip, the eyebrow,
|
|
2
|
+
the lead paragraph, and the section heading. Every value is a theme token.
|
|
3
|
+
They sit in the package's layer like every sheet here, so any rule a site
|
|
4
|
+
writes outside a layer still wins over them whatever its specificity. */
|
|
5
|
+
@layer atelic-ui {
|
|
6
|
+
/* CTA buttons: primary (brand), dark (ink), ghost (outline). */
|
|
7
|
+
:is(.mkt, .atelic-ui) .btn {
|
|
8
|
+
display: inline-flex;
|
|
9
|
+
align-items: center;
|
|
10
|
+
justify-content: center;
|
|
11
|
+
gap: 0.6em;
|
|
12
|
+
padding: 0.95em 1.6em;
|
|
13
|
+
font-weight: 700;
|
|
14
|
+
font-size: 1rem;
|
|
15
|
+
letter-spacing: -0.01em;
|
|
16
|
+
border-radius: var(--radius-pill);
|
|
17
|
+
transition:
|
|
18
|
+
transform 0.18s ease,
|
|
19
|
+
background 0.2s ease,
|
|
20
|
+
box-shadow 0.2s ease,
|
|
21
|
+
color 0.2s ease;
|
|
22
|
+
text-align: center;
|
|
23
|
+
line-height: 1;
|
|
24
|
+
}
|
|
25
|
+
:is(.mkt, .atelic-ui) .btn:active {
|
|
26
|
+
transform: translateY(1px) scale(0.99);
|
|
27
|
+
}
|
|
28
|
+
:is(.mkt, .atelic-ui) .btn-primary {
|
|
29
|
+
background: var(--primary);
|
|
30
|
+
color: #fff;
|
|
31
|
+
box-shadow: var(--primary-shadow);
|
|
32
|
+
}
|
|
33
|
+
:is(.mkt, .atelic-ui) .btn-primary:hover {
|
|
34
|
+
background: var(--primary-deep);
|
|
35
|
+
box-shadow: var(--primary-shadow-hover);
|
|
36
|
+
transform: translateY(-1px);
|
|
37
|
+
}
|
|
38
|
+
:is(.mkt, .atelic-ui) .btn-dark {
|
|
39
|
+
background: var(--ink);
|
|
40
|
+
color: var(--surface);
|
|
41
|
+
}
|
|
42
|
+
:is(.mkt, .atelic-ui) .btn-dark:hover {
|
|
43
|
+
background: #000;
|
|
44
|
+
transform: translateY(-1px);
|
|
45
|
+
}
|
|
46
|
+
:is(.mkt, .atelic-ui) .btn-ghost {
|
|
47
|
+
background: transparent;
|
|
48
|
+
color: var(--ink);
|
|
49
|
+
box-shadow: inset 0 0 0 1.5px var(--neutral-300);
|
|
50
|
+
}
|
|
51
|
+
:is(.mkt, .atelic-ui) .btn-ghost:hover {
|
|
52
|
+
box-shadow: inset 0 0 0 1.5px var(--ink);
|
|
53
|
+
}
|
|
54
|
+
:is(.mkt, .atelic-ui) .btn-ghost.on-dark {
|
|
55
|
+
color: #fff;
|
|
56
|
+
box-shadow: inset 0 0 0 1.5px rgba(255, 255, 255, 0.45);
|
|
57
|
+
}
|
|
58
|
+
:is(.mkt, .atelic-ui) .btn-ghost.on-dark:hover {
|
|
59
|
+
box-shadow: inset 0 0 0 1.5px #fff;
|
|
60
|
+
background: rgba(255, 255, 255, 0.08);
|
|
61
|
+
}
|
|
62
|
+
:is(.mkt, .atelic-ui) .btn-lg {
|
|
63
|
+
padding: 1.1em 2em;
|
|
64
|
+
font-size: 1.08rem;
|
|
65
|
+
}
|
|
66
|
+
:is(.mkt, .atelic-ui) .btn .arrow {
|
|
67
|
+
transition: transform 0.2s ease;
|
|
68
|
+
}
|
|
69
|
+
:is(.mkt, .atelic-ui) .btn:hover .arrow {
|
|
70
|
+
transform: translateX(3px);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* Chip: small mono tag for credentials, feature tags, service areas. */
|
|
74
|
+
:is(.mkt, .atelic-ui) .chip {
|
|
75
|
+
display: inline-flex;
|
|
76
|
+
align-items: center;
|
|
77
|
+
gap: 0.5em;
|
|
78
|
+
font-family: var(--font-mono);
|
|
79
|
+
font-size: 0.72rem;
|
|
80
|
+
letter-spacing: 0.06em;
|
|
81
|
+
text-transform: uppercase;
|
|
82
|
+
padding: 0.5em 0.85em;
|
|
83
|
+
border-radius: var(--radius-pill);
|
|
84
|
+
background: var(--card);
|
|
85
|
+
color: var(--ink-soft);
|
|
86
|
+
box-shadow: inset 0 0 0 1px var(--neutral-200);
|
|
87
|
+
white-space: nowrap;
|
|
88
|
+
}
|
|
89
|
+
:is(.mkt, .atelic-ui) .chip .dot {
|
|
90
|
+
width: 6px;
|
|
91
|
+
height: 6px;
|
|
92
|
+
border-radius: 50%;
|
|
93
|
+
background: var(--primary);
|
|
94
|
+
}
|
|
95
|
+
:is(.mkt, .atelic-ui) .chip svg {
|
|
96
|
+
flex: none;
|
|
97
|
+
}
|
|
98
|
+
:is(.mkt, .atelic-ui) .chip.on-dark {
|
|
99
|
+
background: rgba(255, 255, 255, 0.08);
|
|
100
|
+
color: #efe7da;
|
|
101
|
+
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* Eyebrow: mono uppercase kicker trailed by a bar that fades off to the
|
|
105
|
+
right — the wordmark's signature, in the brand's primary. This fade is
|
|
106
|
+
the only rendering; the old leading-dash mode is retired. */
|
|
107
|
+
:is(.mkt, .atelic-ui) .eyebrow {
|
|
108
|
+
font-family: var(--font-mono);
|
|
109
|
+
font-size: 0.74rem;
|
|
110
|
+
font-weight: 500;
|
|
111
|
+
letter-spacing: 0.18em;
|
|
112
|
+
text-transform: uppercase;
|
|
113
|
+
color: var(--primary);
|
|
114
|
+
display: inline-flex;
|
|
115
|
+
align-items: center;
|
|
116
|
+
gap: 0.6em;
|
|
117
|
+
}
|
|
118
|
+
:is(.mkt, .atelic-ui) .eyebrow.on-dark {
|
|
119
|
+
color: var(--primary-bright);
|
|
120
|
+
}
|
|
121
|
+
:is(.mkt, .atelic-ui) .eyebrow.center {
|
|
122
|
+
justify-content: center;
|
|
123
|
+
}
|
|
124
|
+
:is(.mkt, .atelic-ui) .eyebrow::after {
|
|
125
|
+
content: "";
|
|
126
|
+
width: 40px;
|
|
127
|
+
height: 2px;
|
|
128
|
+
display: inline-block;
|
|
129
|
+
/* Themes with a dedicated ruling accent (a brand color reserved for
|
|
130
|
+
rules, never text) set --rule-accent; default stays the text color. */
|
|
131
|
+
background: linear-gradient(90deg, var(--rule-accent, currentColor), transparent);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
:is(.mkt, .atelic-ui) .lead {
|
|
135
|
+
font-size: clamp(1.05rem, 2vw, 1.3rem);
|
|
136
|
+
color: var(--ink-soft);
|
|
137
|
+
line-height: 1.55;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
:is(.mkt, .atelic-ui) .section-head {
|
|
141
|
+
/* Width is constrained on the lead only: titles size to their section so
|
|
142
|
+
they never wrap early into dead space (the 60ch cap here was measured
|
|
143
|
+
at the body font size, boxing every title into ~510px). */
|
|
144
|
+
max-width: none;
|
|
145
|
+
}
|
|
146
|
+
:is(.mkt, .atelic-ui) .section-head.center {
|
|
147
|
+
margin-inline: auto;
|
|
148
|
+
}
|
|
149
|
+
:is(.mkt, .atelic-ui) .section-head .section-head-lead {
|
|
150
|
+
margin-top: 16px;
|
|
151
|
+
max-width: 60ch;
|
|
152
|
+
}
|
|
153
|
+
}
|