@atelic-action/ui 0.17.2 → 1.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 +432 -45
- package/bin/atelic-check-base-path.ts +5 -0
- package/bin/atelic-clean-sitemap.ts +5 -0
- package/bin/atelic-contact-canary.ts +5 -0
- package/bin/atelic-print-sheets.sh +1 -1
- package/bin/atelic-seal-document.mjs +1 -1
- package/bin/atelic-serve-static.ts +1 -1
- package/dist/gate/index.d.ts +1 -1
- package/dist/gate/index.js +1 -1
- package/dist/gate/middleware.d.ts +4 -1
- package/dist/gate/middleware.js +14 -5
- package/dist/gate/wall.d.ts +25 -0
- package/dist/gate/wall.js +41 -2
- package/dist/routing/basePath.d.ts +24 -0
- package/dist/routing/basePath.js +54 -0
- package/dist/vite/index.d.ts +3 -2
- package/dist/vite/index.js +3 -2
- package/dist/vite/marketing-config.d.ts +50 -0
- package/dist/vite/marketing-config.js +83 -0
- package/package.json +164 -5
- package/src/app/AppProvider.tsx +41 -0
- package/src/app/AuthGuard.tsx +27 -0
- package/src/app/CardErrorBoundary.tsx +39 -0
- package/src/app/ErrorFallback.tsx +39 -0
- package/src/app/GoogleGlyph.tsx +22 -0
- package/src/app/config.ts +54 -0
- package/src/app/configure.ts +18 -0
- package/src/app/context/AppContext.tsx +52 -0
- package/src/app/context/SidebarContext.tsx +124 -0
- package/src/app/context/UserContext.tsx +139 -0
- package/src/app/hooks/useDebounce.ts +12 -0
- package/src/app/hooks/useIsDesktop.ts +31 -0
- package/src/app/hooks/useWakeLock.ts +49 -0
- package/src/app/index.ts +64 -0
- package/src/app/layout/ActionButton.tsx +67 -0
- package/src/app/layout/AppLayout.tsx +110 -0
- package/src/app/layout/AppPage.tsx +268 -0
- package/src/app/layout/Breadcrumbs.tsx +57 -0
- package/src/app/layout/DesktopRail.tsx +168 -0
- package/src/app/layout/FormActionButtons.tsx +69 -0
- package/src/app/layout/MobileMenu.tsx +156 -0
- package/src/app/layout/MobileTabBar.tsx +43 -0
- package/src/app/layout/PageError.tsx +30 -0
- package/src/app/layout/PageSkeleton.tsx +65 -0
- package/src/app/layout/SectionCards.tsx +33 -0
- package/src/app/layout/UserMenu.tsx +104 -0
- package/src/app/layout/identity.ts +22 -0
- package/src/app/layout/index.ts +20 -0
- package/src/app/layout/nav.ts +52 -0
- package/src/app/lib/dates.ts +57 -0
- package/src/app/lib/env.ts +37 -0
- package/src/app/lib/format.ts +61 -0
- package/src/app/lib/http.ts +205 -0
- package/src/app/lib/lastAuth.ts +45 -0
- package/src/app/lib/oauth.ts +32 -0
- package/src/app/lib/publicRoutes.ts +33 -0
- package/src/app/lib/queryClient.ts +38 -0
- package/src/app/lib/search.ts +17 -0
- package/src/app/lib/session.ts +36 -0
- package/src/app/lib/tokenRefresh.ts +137 -0
- package/src/app/lib/transformKeys.ts +60 -0
- package/src/app/lib/units.ts +228 -0
- package/src/app/lib/unwrap.ts +48 -0
- package/src/app/lib/utils.ts +10 -0
- package/src/app/stores/authStore.ts +80 -0
- package/src/app/stores/storage.ts +40 -0
- package/src/app/stores/unitsStore.ts +54 -0
- package/src/app/types/index.ts +72 -0
- package/src/app/types/query.ts +11 -0
- package/src/app/ui/accordion.tsx +54 -0
- package/src/app/ui/alert-dialog.tsx +114 -0
- package/src/app/ui/alert.tsx +49 -0
- package/src/app/ui/avatar.tsx +45 -0
- package/src/app/ui/badge-list.tsx +51 -0
- package/src/app/ui/badge.tsx +33 -0
- package/src/app/ui/button.tsx +49 -0
- package/src/app/ui/callout.tsx +22 -0
- package/src/app/ui/card.tsx +55 -0
- package/src/app/ui/checkbox.tsx +26 -0
- package/src/app/ui/clear-button.tsx +42 -0
- package/src/app/ui/command.tsx +140 -0
- package/src/app/ui/country-dropdown.tsx +184 -0
- package/src/app/ui/dialog.tsx +104 -0
- package/src/app/ui/dropdown-menu.tsx +185 -0
- package/src/app/ui/form.tsx +172 -0
- package/src/app/ui/index.ts +36 -0
- package/src/app/ui/input.tsx +22 -0
- package/src/app/ui/label.tsx +19 -0
- package/src/app/ui/option-select.tsx +56 -0
- package/src/app/ui/pagination.tsx +90 -0
- package/src/app/ui/popover.tsx +29 -0
- package/src/app/ui/progress.tsx +25 -0
- package/src/app/ui/search-input.tsx +63 -0
- package/src/app/ui/select.tsx +151 -0
- package/src/app/ui/separator.tsx +24 -0
- package/src/app/ui/sheet.tsx +119 -0
- package/src/app/ui/slider.tsx +23 -0
- package/src/app/ui/switch.tsx +27 -0
- package/src/app/ui/textarea.tsx +21 -0
- package/src/app/ui/tooltip.tsx +32 -0
- package/src/artifact/ArtifactShell.tsx +2 -2
- package/src/artifact/Gate.tsx +2 -2
- package/src/artifact/Note.tsx +9 -39
- package/src/artifact/Paragraph.tsx +65 -0
- package/src/artifact/Read.tsx +406 -0
- package/src/artifact/Writeup.tsx +7 -7
- package/src/artifact/index.ts +1 -0
- package/src/chrome/SiteShell.tsx +141 -0
- package/src/chrome/index.ts +2 -0
- package/src/components/Disclosure.tsx +80 -14
- package/src/components/GateLogin.tsx +1 -1
- package/src/components/Watermark.tsx +1 -1
- package/src/components/index.ts +6 -2
- package/src/gate/index.ts +1 -1
- package/src/gate/middleware.ts +17 -6
- package/src/gate/wall.ts +60 -2
- package/src/lib/basePathAudit.ts +96 -0
- package/src/lib/displayDate.ts +25 -0
- package/src/lib/honeypot.ts +11 -0
- package/src/lib/hours.ts +28 -0
- package/src/lib/sitemap.ts +34 -0
- package/src/lib/trackEvent.ts +26 -0
- package/src/lib/useOpenHeight.ts +28 -0
- package/src/primitives/Button.tsx +3 -1
- package/src/primitives/Chip.tsx +3 -1
- package/src/primitives/ImageFrame.tsx +48 -0
- package/src/primitives/ProseBlocks.tsx +67 -0
- package/src/primitives/QuoteCard.tsx +20 -0
- package/src/primitives/StarRow.tsx +37 -0
- package/src/primitives/TickList.tsx +24 -0
- package/src/primitives/index.ts +5 -0
- package/src/routing/BasePathProvider.tsx +47 -0
- package/src/routing/basePath.ts +57 -0
- package/src/routing/index.ts +8 -0
- package/src/scripts/check-base-path.ts +130 -0
- package/src/scripts/clean-sitemap.ts +27 -0
- package/src/scripts/contact-canary.ts +98 -0
- package/src/scripts/serve-static.ts +29 -3
- package/src/sections/BlogIndex.tsx +57 -0
- package/src/sections/BlogPost.tsx +65 -0
- package/src/sections/CTABanner.tsx +93 -0
- package/src/sections/CTASection.tsx +60 -0
- package/src/sections/ComparisonTable.tsx +114 -0
- package/src/sections/ContactForm.tsx +163 -0
- package/src/sections/ContactInfoCard.tsx +92 -0
- package/src/sections/CredentialList.tsx +28 -0
- package/src/sections/DeviceShowcase.tsx +39 -0
- package/src/sections/ExpandList.tsx +58 -0
- package/src/sections/FAQBrowser.tsx +283 -0
- package/src/sections/FeatureDetail.tsx +60 -0
- package/src/sections/FeatureGrid.tsx +83 -0
- package/src/sections/Gallery.tsx +84 -0
- package/src/sections/Hero.tsx +110 -0
- package/src/sections/HoursTable.tsx +23 -0
- package/src/sections/JumpRail.tsx +35 -0
- package/src/sections/LegalPage.tsx +43 -0
- package/src/sections/LocationSection.tsx +66 -0
- package/src/sections/Locations.tsx +71 -0
- package/src/sections/LogoMarquee.tsx +27 -0
- package/src/sections/MapEmbed.tsx +75 -0
- package/src/sections/MediaBand.tsx +20 -0
- package/src/sections/MenuList.tsx +44 -0
- package/src/sections/PageHero.tsx +25 -0
- package/src/sections/PressQuotes.tsx +51 -0
- package/src/sections/PricingBand.tsx +43 -0
- package/src/sections/PricingCard.tsx +36 -0
- package/src/sections/ProcessSteps.tsx +45 -0
- package/src/sections/ProfileCards.tsx +73 -0
- package/src/sections/RevealSlider.tsx +156 -0
- package/src/sections/ReviewCarousel.tsx +223 -0
- package/src/sections/ReviewWall.tsx +220 -0
- package/src/sections/SocialStrip.tsx +83 -0
- package/src/sections/SplitSection.tsx +56 -0
- package/src/sections/index.ts +76 -0
- package/src/sections/siteConfig.ts +201 -0
- package/src/styles/app.css +673 -0
- package/src/styles/artifact.css +759 -401
- package/src/styles/base.css +49 -47
- package/src/styles/chrome.css +71 -71
- package/src/styles/components.css +150 -140
- package/src/styles/gate.css +29 -29
- package/src/styles/primitives.css +190 -25
- package/src/styles/sections.css +2652 -0
- package/src/styles/star-rating.css +2 -2
- package/src/vite/index.ts +9 -2
- package/src/vite/marketing-config.ts +128 -0
- package/src/components/Collapsible.tsx +0 -43
- /package/src/scripts/{build-gate-payload.mjs → seal-document.mjs} +0 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
import { cn } from "../lib/utils";
|
|
7
|
+
|
|
8
|
+
const TooltipProvider = TooltipPrimitive.Provider;
|
|
9
|
+
|
|
10
|
+
const Tooltip = TooltipPrimitive.Root;
|
|
11
|
+
|
|
12
|
+
const TooltipTrigger = TooltipPrimitive.Trigger;
|
|
13
|
+
|
|
14
|
+
const TooltipContent = React.forwardRef<
|
|
15
|
+
React.ElementRef<typeof TooltipPrimitive.Content>,
|
|
16
|
+
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
|
17
|
+
>(({ className, sideOffset = 4, ...props }, ref) => (
|
|
18
|
+
<TooltipPrimitive.Portal>
|
|
19
|
+
<TooltipPrimitive.Content
|
|
20
|
+
ref={ref}
|
|
21
|
+
sideOffset={sideOffset}
|
|
22
|
+
className={cn(
|
|
23
|
+
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-tooltip-content-transform-origin)",
|
|
24
|
+
className,
|
|
25
|
+
)}
|
|
26
|
+
{...props}
|
|
27
|
+
/>
|
|
28
|
+
</TooltipPrimitive.Portal>
|
|
29
|
+
));
|
|
30
|
+
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
|
31
|
+
|
|
32
|
+
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
|
@@ -19,7 +19,7 @@ export interface ArtifactShellProps {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* The wrapper every artifact page uses: the `.
|
|
22
|
+
* The wrapper every artifact page uses: the `.atelic-ui` theming
|
|
23
23
|
* scope, the skip link, the header, the credit band, and the access gate's
|
|
24
24
|
* watermark, composed from the chrome. The header is always the dark
|
|
25
25
|
* variant with the sender's lockup and never a site nav or a CTA, because the
|
|
@@ -34,7 +34,7 @@ export function ArtifactShell({ site, currentPath, stops, children }: ArtifactSh
|
|
|
34
34
|
const recipient = site.recipient.logo;
|
|
35
35
|
|
|
36
36
|
return (
|
|
37
|
-
<div className="
|
|
37
|
+
<div className="atelic-ui">
|
|
38
38
|
<SkipLink />
|
|
39
39
|
<SiteHeader
|
|
40
40
|
brand={{ name, href, logo, recipient }}
|
package/src/artifact/Gate.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { type FormEvent, useEffect, useRef, useState } from "react";
|
|
|
2
2
|
import { Button } from "../primitives/Button";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* PBKDF2 work factor and hash. Mirrored by scripts/
|
|
5
|
+
* PBKDF2 work factor and hash. Mirrored by scripts/seal-document.mjs;
|
|
6
6
|
* the two must agree exactly or every sealed payload stops opening.
|
|
7
7
|
*/
|
|
8
8
|
const PBKDF2_ITERATIONS = 200000;
|
|
@@ -40,7 +40,7 @@ export interface GatePayload {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export interface GateProps {
|
|
43
|
-
/** Built by scripts/
|
|
43
|
+
/** Built by scripts/seal-document.mjs. Carries no plaintext. */
|
|
44
44
|
payload: GatePayload;
|
|
45
45
|
/** localStorage key holding the reader's email, so a return visit walks straight in. */
|
|
46
46
|
storageKey?: string;
|
package/src/artifact/Note.tsx
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { Callout } from "../components";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type ArtifactLink,
|
|
4
|
+
type ArtifactParagraph,
|
|
5
|
+
isEmpty,
|
|
6
|
+
ArtifactAnchor as NoteAnchor,
|
|
7
|
+
Paragraph,
|
|
8
|
+
} from "./Paragraph";
|
|
3
9
|
import { Shot, type WriteupShot } from "./Shot";
|
|
4
10
|
import { SignOff, type WriteupPerson } from "./SignOff";
|
|
5
11
|
|
|
@@ -20,13 +26,10 @@ import { SignOff, type WriteupPerson } from "./SignOff";
|
|
|
20
26
|
*/
|
|
21
27
|
|
|
22
28
|
/** A link inside running text: the words the reader clicks and where they go. */
|
|
23
|
-
export
|
|
24
|
-
text: string;
|
|
25
|
-
href: string;
|
|
26
|
-
}
|
|
29
|
+
export type NoteLink = ArtifactLink;
|
|
27
30
|
|
|
28
31
|
/** A paragraph: a plain string, or runs of text and links in order. */
|
|
29
|
-
export type NoteParagraph =
|
|
32
|
+
export type NoteParagraph = ArtifactParagraph;
|
|
30
33
|
|
|
31
34
|
/** One defect and what it costs, with the owner's screen under it when there is one. */
|
|
32
35
|
export interface NoteFinding {
|
|
@@ -86,39 +89,6 @@ export interface NoteProps {
|
|
|
86
89
|
close: NoteClose;
|
|
87
90
|
}
|
|
88
91
|
|
|
89
|
-
/** A link opens in a new tab when it leaves the page, and in place when it is an anchor or a mail link. */
|
|
90
|
-
function NoteAnchor({ href, children }: { href: string; children: string }) {
|
|
91
|
-
return (
|
|
92
|
-
<a href={href} {...newTabProps(href, /^https?:/i.test(href))}>
|
|
93
|
-
{children}
|
|
94
|
-
</a>
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** A paragraph with nothing to read: a blank string, or runs that are all blank strings. A link always counts. */
|
|
99
|
-
function isEmpty(paragraph: NoteParagraph): boolean {
|
|
100
|
-
const runs = typeof paragraph === "string" ? [paragraph] : paragraph;
|
|
101
|
-
return runs.every((run) => typeof run === "string" && run.trim() === "");
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function Paragraph({ paragraph, className }: { paragraph: NoteParagraph; className?: string }) {
|
|
105
|
-
const runs = typeof paragraph === "string" ? [paragraph] : paragraph;
|
|
106
|
-
return (
|
|
107
|
-
<p className={className}>
|
|
108
|
-
{runs.map((run, i) =>
|
|
109
|
-
typeof run === "string" ? (
|
|
110
|
-
run
|
|
111
|
-
) : (
|
|
112
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: runs are static content in a fixed order
|
|
113
|
-
<NoteAnchor href={run.href} key={i}>
|
|
114
|
-
{run.text}
|
|
115
|
-
</NoteAnchor>
|
|
116
|
-
),
|
|
117
|
-
)}
|
|
118
|
-
</p>
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
92
|
function Finding({ finding }: { finding: NoteFinding }) {
|
|
123
93
|
return (
|
|
124
94
|
<li className={finding.shot ? "nt-finding has-shot" : "nt-finding"}>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { newTabProps } from "../lib/newTabProps";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Plain data text for the documents a routine can write as JSON (the note
|
|
5
|
+
* and the read): a paragraph is a string, or runs of text and links in
|
|
6
|
+
* order. A link opens in a new tab when it leaves the page, and in place
|
|
7
|
+
* when it is an anchor or a mail link.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** A link inside running text: the words the reader clicks and where they go. */
|
|
11
|
+
export interface ArtifactLink {
|
|
12
|
+
text: string;
|
|
13
|
+
href: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A paragraph: a plain string, or runs of text and links in order. */
|
|
17
|
+
export type ArtifactParagraph = string | Array<string | ArtifactLink>;
|
|
18
|
+
|
|
19
|
+
/** A link opens in a new tab when it leaves the page, and in place when it is an anchor or a mail link. */
|
|
20
|
+
export function ArtifactAnchor({ href, children }: { href: string; children: string }) {
|
|
21
|
+
return (
|
|
22
|
+
<a href={href} {...newTabProps(href, /^https?:/i.test(href))}>
|
|
23
|
+
{children}
|
|
24
|
+
</a>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A paragraph with nothing to read: a blank string, or runs that are all blank strings. A link always counts. */
|
|
29
|
+
export function isEmpty(paragraph: ArtifactParagraph): boolean {
|
|
30
|
+
const runs = typeof paragraph === "string" ? [paragraph] : paragraph;
|
|
31
|
+
return runs.every((run) => typeof run === "string" && run.trim() === "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The runs alone, for a container that is not a paragraph (a list item, a table cell). */
|
|
35
|
+
export function Runs({ paragraph }: { paragraph: ArtifactParagraph }) {
|
|
36
|
+
const runs = typeof paragraph === "string" ? [paragraph] : paragraph;
|
|
37
|
+
return (
|
|
38
|
+
<>
|
|
39
|
+
{runs.map((run, i) =>
|
|
40
|
+
typeof run === "string" ? (
|
|
41
|
+
run
|
|
42
|
+
) : (
|
|
43
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: runs are static content in a fixed order
|
|
44
|
+
<ArtifactAnchor href={run.href} key={i}>
|
|
45
|
+
{run.text}
|
|
46
|
+
</ArtifactAnchor>
|
|
47
|
+
),
|
|
48
|
+
)}
|
|
49
|
+
</>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function Paragraph({
|
|
54
|
+
paragraph,
|
|
55
|
+
className,
|
|
56
|
+
}: {
|
|
57
|
+
paragraph: ArtifactParagraph;
|
|
58
|
+
className?: string;
|
|
59
|
+
}) {
|
|
60
|
+
return (
|
|
61
|
+
<p className={className}>
|
|
62
|
+
<Runs paragraph={paragraph} />
|
|
63
|
+
</p>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { ArrowUpRight } from "lucide-react";
|
|
2
|
+
import { newTabProps } from "../lib/newTabProps";
|
|
3
|
+
import { GradeChip, gradeLabel, type WriteupGrade } from "./GradeChip";
|
|
4
|
+
import { type ArtifactLink, type ArtifactParagraph, isEmpty, Paragraph, Runs } from "./Paragraph";
|
|
5
|
+
import { Shot, type WriteupShot } from "./Shot";
|
|
6
|
+
import { SignOff, type WriteupPerson } from "./SignOff";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The read: what a customer gets at the end of an engagement's first week,
|
|
10
|
+
* before anything changes. Where the writeup says what letter each section
|
|
11
|
+
* earned, the read says why, and what changes. Its shape is the practice's
|
|
12
|
+
* FOOTPRINT.md, under What the Owner Gets: a title and a dated lede; the gist
|
|
13
|
+
* table (every section, its grade, and one line); then the tiers, each
|
|
14
|
+
* section a question in three beats (what we found, why, what changes) with
|
|
15
|
+
* any proof pictures and the tools it was read from; then the plan, in order;
|
|
16
|
+
* then one closing line and the sign off.
|
|
17
|
+
*
|
|
18
|
+
* It is the writeup's sibling, not a variant: it wears the writeup's column,
|
|
19
|
+
* head, grade chip and key, tier heading, section card, screen, sources, and
|
|
20
|
+
* close (the `.op` classes), and adds only what is its own (the `.rd`
|
|
21
|
+
* classes). The gist table is drawn from the tiers, so a tier added later
|
|
22
|
+
* (Conversion, once the analytics access lands) is one more entry in `tiers`
|
|
23
|
+
* and appears in the table and the body alike, with no change to the props.
|
|
24
|
+
* Every prop is plain data, so a routine can write a read as JSON; the one
|
|
25
|
+
* exception is the sign off person's `role`, which takes a node and is
|
|
26
|
+
* equally happy with a string.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** A link inside running text: the words the reader clicks and where they go. */
|
|
30
|
+
export type ReadLink = ArtifactLink;
|
|
31
|
+
|
|
32
|
+
/** A paragraph: a plain string, or runs of text and links in order. */
|
|
33
|
+
export type ReadParagraph = ArtifactParagraph;
|
|
34
|
+
|
|
35
|
+
/** A place a reading came from: the tool, opened in a new tab when it has an address. */
|
|
36
|
+
export interface ReadSource {
|
|
37
|
+
label: string;
|
|
38
|
+
href?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One section of a tier: a question in three beats. */
|
|
42
|
+
export interface ReadSection {
|
|
43
|
+
/** The anchor the gist table links to, e.g. "technical-seo". */
|
|
44
|
+
id: string;
|
|
45
|
+
/** The section's trade name, e.g. "Technical SEO": the gist table's Section column, and the line over the question. */
|
|
46
|
+
name: string;
|
|
47
|
+
/** The question the section answers, in the owner's words, e.g. "Can Google read the site?". The section's heading. */
|
|
48
|
+
question: string;
|
|
49
|
+
/** The baseline's grade. Omit for a section not yet graded; the table shows a quiet dash. */
|
|
50
|
+
grade?: WriteupGrade;
|
|
51
|
+
/** The one line in the gist table. */
|
|
52
|
+
gist: ReadParagraph;
|
|
53
|
+
/** What we found: one paragraph or more. */
|
|
54
|
+
found: ReadParagraph[];
|
|
55
|
+
/** A few concrete examples of what was found, as a short list under it. */
|
|
56
|
+
examples?: ReadParagraph[];
|
|
57
|
+
/** Proof pictures, each over its caption, e.g. a search result capture per market. */
|
|
58
|
+
shots?: WriteupShot[];
|
|
59
|
+
/** Why, in one sentence, set apart. */
|
|
60
|
+
why: ReadParagraph;
|
|
61
|
+
/** What changes: one paragraph or more. */
|
|
62
|
+
changes: ReadParagraph[];
|
|
63
|
+
/** The tools the readings came from, linked, so the owner can open them. */
|
|
64
|
+
sources?: ReadSource[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A tier, e.g. Health: its heading, the question it answers, and its sections in order. */
|
|
68
|
+
export interface ReadTier {
|
|
69
|
+
/** The anchor a header stop points at, e.g. "health". */
|
|
70
|
+
id?: string;
|
|
71
|
+
/** The tier's name, e.g. "Health". */
|
|
72
|
+
title: string;
|
|
73
|
+
/** The question the tier answers, in the owner's words. */
|
|
74
|
+
lead?: string;
|
|
75
|
+
sections: ReadSection[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One step of the plan: a bold title and a sentence or two. */
|
|
79
|
+
export interface ReadStep {
|
|
80
|
+
title: string;
|
|
81
|
+
body?: ReadParagraph;
|
|
82
|
+
/** Whose step it is when it is not the practice's, e.g. "Yours" or a name; set beside the title. */
|
|
83
|
+
who?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The plan: the steps in order, numbered. */
|
|
87
|
+
export interface ReadPlan {
|
|
88
|
+
/** The anchor a header stop points at, e.g. "plan". */
|
|
89
|
+
id?: string;
|
|
90
|
+
/** The heading; defaults to the `plan` label. */
|
|
91
|
+
title?: string;
|
|
92
|
+
/** A line over the steps. */
|
|
93
|
+
lead?: ReadParagraph;
|
|
94
|
+
steps: ReadStep[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The close: one line, then the sign off. */
|
|
98
|
+
export interface ReadClose {
|
|
99
|
+
/** One sentence, e.g. what follows and when. */
|
|
100
|
+
line: ReadParagraph;
|
|
101
|
+
signOff?: string;
|
|
102
|
+
person?: WriteupPerson;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The fixed words the page sets, each overridable. */
|
|
106
|
+
export interface ReadLabels {
|
|
107
|
+
/** The gist table's first column. */
|
|
108
|
+
section: string;
|
|
109
|
+
/** The gist table's second column. */
|
|
110
|
+
grade: string;
|
|
111
|
+
/** The gist table's third column. */
|
|
112
|
+
gist: string;
|
|
113
|
+
/** What a screen reader hears for a section with no grade. */
|
|
114
|
+
notGraded: string;
|
|
115
|
+
found: string;
|
|
116
|
+
why: string;
|
|
117
|
+
changes: string;
|
|
118
|
+
/** The label over a section's tools. */
|
|
119
|
+
sources: string;
|
|
120
|
+
/** The plan's heading when it names none. */
|
|
121
|
+
plan: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const readLabels: ReadLabels = {
|
|
125
|
+
section: "Section",
|
|
126
|
+
grade: "Grade",
|
|
127
|
+
gist: "The Gist",
|
|
128
|
+
notGraded: "Not graded yet",
|
|
129
|
+
found: "What We Found",
|
|
130
|
+
why: "Why",
|
|
131
|
+
changes: "What Changes",
|
|
132
|
+
sources: "Sources",
|
|
133
|
+
plan: "The Plan",
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export interface ReadProps {
|
|
137
|
+
title: string;
|
|
138
|
+
/** The business, set in the dateline. */
|
|
139
|
+
preparedFor: string;
|
|
140
|
+
/** The ISO date, e.g. "2026-10-16". */
|
|
141
|
+
date: string;
|
|
142
|
+
/** Two or three sentences under the title. */
|
|
143
|
+
lede: ReadParagraph;
|
|
144
|
+
/** The heading over the gist table, e.g. "At a Glance". */
|
|
145
|
+
gistTitle?: string;
|
|
146
|
+
/** The key: what each letter means, in a few words each. */
|
|
147
|
+
gradeKey?: Array<{ grade: WriteupGrade; label: string }>;
|
|
148
|
+
/** The tiers in order. Each section appears in the gist table and in the body. */
|
|
149
|
+
tiers: ReadTier[];
|
|
150
|
+
plan: ReadPlan;
|
|
151
|
+
close: ReadClose;
|
|
152
|
+
labels?: Partial<ReadLabels>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function Sources({ sources, label }: { sources: ReadSource[]; label: string }) {
|
|
156
|
+
return (
|
|
157
|
+
<footer className="op-sources">
|
|
158
|
+
<span className="op-sources-label">{label}</span>
|
|
159
|
+
<ul>
|
|
160
|
+
{sources.map((source) => (
|
|
161
|
+
<li key={source.label}>
|
|
162
|
+
{source.href ? (
|
|
163
|
+
<a href={source.href} {...newTabProps(source.href, true)}>
|
|
164
|
+
{source.label}
|
|
165
|
+
<ArrowUpRight size={12} aria-hidden="true" />
|
|
166
|
+
</a>
|
|
167
|
+
) : (
|
|
168
|
+
source.label
|
|
169
|
+
)}
|
|
170
|
+
</li>
|
|
171
|
+
))}
|
|
172
|
+
</ul>
|
|
173
|
+
</footer>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function Gist({
|
|
178
|
+
tiers,
|
|
179
|
+
title,
|
|
180
|
+
gradeKey,
|
|
181
|
+
labels,
|
|
182
|
+
}: {
|
|
183
|
+
tiers: ReadTier[];
|
|
184
|
+
title?: string;
|
|
185
|
+
gradeKey?: ReadProps["gradeKey"];
|
|
186
|
+
labels: ReadLabels;
|
|
187
|
+
}) {
|
|
188
|
+
return (
|
|
189
|
+
<section className="rd-gist" aria-label={title ? undefined : labels.gist}>
|
|
190
|
+
{(title || gradeKey) && (
|
|
191
|
+
<div className="op-ov-head">
|
|
192
|
+
{title && <h2 className="op-ov-title">{title}</h2>}
|
|
193
|
+
{gradeKey && (
|
|
194
|
+
<ul className="op-legend">
|
|
195
|
+
{gradeKey.map((entry) => (
|
|
196
|
+
<li key={entry.grade}>
|
|
197
|
+
<GradeChip grade={entry.grade} />
|
|
198
|
+
<span>{entry.label}</span>
|
|
199
|
+
</li>
|
|
200
|
+
))}
|
|
201
|
+
</ul>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
<div className="rd-gist-card">
|
|
206
|
+
<table className="rd-gist-table">
|
|
207
|
+
<thead>
|
|
208
|
+
<tr>
|
|
209
|
+
<th scope="col" className="rd-gist-section">
|
|
210
|
+
{labels.section}
|
|
211
|
+
</th>
|
|
212
|
+
<th scope="col" className="rd-gist-grade">
|
|
213
|
+
{labels.grade}
|
|
214
|
+
</th>
|
|
215
|
+
<th scope="col" className="rd-gist-read">
|
|
216
|
+
{labels.gist}
|
|
217
|
+
</th>
|
|
218
|
+
</tr>
|
|
219
|
+
</thead>
|
|
220
|
+
{tiers.map((tier) => (
|
|
221
|
+
<tbody key={tier.title}>
|
|
222
|
+
<tr className="rd-gist-tier">
|
|
223
|
+
<th scope="rowgroup" colSpan={3}>
|
|
224
|
+
{tier.title}
|
|
225
|
+
</th>
|
|
226
|
+
</tr>
|
|
227
|
+
{tier.sections.map((section) => (
|
|
228
|
+
<tr key={section.id}>
|
|
229
|
+
<th scope="row" className="rd-gist-section">
|
|
230
|
+
<a href={`#${section.id}`}>{section.name}</a>
|
|
231
|
+
</th>
|
|
232
|
+
<td className="rd-gist-grade">
|
|
233
|
+
{section.grade ? (
|
|
234
|
+
<>
|
|
235
|
+
<span className="op-sr-only">{gradeLabel[section.grade]}</span>
|
|
236
|
+
<span aria-hidden="true">
|
|
237
|
+
<GradeChip grade={section.grade} />
|
|
238
|
+
</span>
|
|
239
|
+
</>
|
|
240
|
+
) : (
|
|
241
|
+
<>
|
|
242
|
+
<span className="op-sr-only">{labels.notGraded}</span>
|
|
243
|
+
<span className="op-dash" aria-hidden="true" />
|
|
244
|
+
</>
|
|
245
|
+
)}
|
|
246
|
+
</td>
|
|
247
|
+
<td className="rd-gist-read">
|
|
248
|
+
<Runs paragraph={section.gist} />
|
|
249
|
+
</td>
|
|
250
|
+
</tr>
|
|
251
|
+
))}
|
|
252
|
+
</tbody>
|
|
253
|
+
))}
|
|
254
|
+
</table>
|
|
255
|
+
</div>
|
|
256
|
+
</section>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function Section({ section, labels }: { section: ReadSection; labels: ReadLabels }) {
|
|
261
|
+
const found = section.found.filter((paragraph) => !isEmpty(paragraph));
|
|
262
|
+
const changes = section.changes.filter((paragraph) => !isEmpty(paragraph));
|
|
263
|
+
const shots = section.shots ?? [];
|
|
264
|
+
return (
|
|
265
|
+
<section className="op-topic rd-section" id={section.id}>
|
|
266
|
+
<header className="op-topic-head">
|
|
267
|
+
<div>
|
|
268
|
+
<p className="rd-name">{section.name}</p>
|
|
269
|
+
<h3>{section.question}</h3>
|
|
270
|
+
</div>
|
|
271
|
+
{section.grade && (
|
|
272
|
+
<>
|
|
273
|
+
<span className="op-sr-only">{`${labels.grade} ${gradeLabel[section.grade]}`}</span>
|
|
274
|
+
<span className="rd-grade" aria-hidden="true">
|
|
275
|
+
<GradeChip grade={section.grade} />
|
|
276
|
+
</span>
|
|
277
|
+
</>
|
|
278
|
+
)}
|
|
279
|
+
</header>
|
|
280
|
+
<div className="rd-beat rd-found">
|
|
281
|
+
<h4>{labels.found}</h4>
|
|
282
|
+
{found.map((paragraph, i) => (
|
|
283
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: paragraphs are static content in a fixed order
|
|
284
|
+
<Paragraph paragraph={paragraph} key={i} />
|
|
285
|
+
))}
|
|
286
|
+
{section.examples && section.examples.length > 0 && (
|
|
287
|
+
<ul className="rd-examples">
|
|
288
|
+
{section.examples.map((example, i) => (
|
|
289
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: examples are static content in a fixed order
|
|
290
|
+
<li key={i}>
|
|
291
|
+
<Runs paragraph={example} />
|
|
292
|
+
</li>
|
|
293
|
+
))}
|
|
294
|
+
</ul>
|
|
295
|
+
)}
|
|
296
|
+
{shots.length > 0 && (
|
|
297
|
+
<div className="rd-shots" data-count={Math.min(shots.length, 3)}>
|
|
298
|
+
{shots.map((shot) => (
|
|
299
|
+
<Shot shot={shot} key={shot.src} />
|
|
300
|
+
))}
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
303
|
+
</div>
|
|
304
|
+
<div className="rd-beat rd-why">
|
|
305
|
+
<h4>{labels.why}</h4>
|
|
306
|
+
<Paragraph paragraph={section.why} />
|
|
307
|
+
</div>
|
|
308
|
+
<div className="rd-beat rd-changes">
|
|
309
|
+
<h4>{labels.changes}</h4>
|
|
310
|
+
{changes.map((paragraph, i) => (
|
|
311
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: paragraphs are static content in a fixed order
|
|
312
|
+
<Paragraph paragraph={paragraph} key={i} />
|
|
313
|
+
))}
|
|
314
|
+
</div>
|
|
315
|
+
{section.sources && section.sources.length > 0 && (
|
|
316
|
+
<Sources sources={section.sources} label={labels.sources} />
|
|
317
|
+
)}
|
|
318
|
+
</section>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function Plan({ plan, labels }: { plan: ReadPlan; labels: ReadLabels }) {
|
|
323
|
+
return (
|
|
324
|
+
<section className="rd-plan" id={plan.id}>
|
|
325
|
+
<h2>{plan.title ?? labels.plan}</h2>
|
|
326
|
+
{plan.lead && !isEmpty(plan.lead) && (
|
|
327
|
+
<Paragraph paragraph={plan.lead} className="rd-plan-lead" />
|
|
328
|
+
)}
|
|
329
|
+
<ol className="rd-steps">
|
|
330
|
+
{plan.steps.map((step) => (
|
|
331
|
+
<li className="rd-step" key={step.title}>
|
|
332
|
+
<p>
|
|
333
|
+
<b className="rd-step-title">{step.title}</b>
|
|
334
|
+
{step.who && (
|
|
335
|
+
<>
|
|
336
|
+
{" "}
|
|
337
|
+
<span className="rd-step-who">{step.who}</span>
|
|
338
|
+
</>
|
|
339
|
+
)}
|
|
340
|
+
{step.body && !isEmpty(step.body) && (
|
|
341
|
+
<>
|
|
342
|
+
{" "}
|
|
343
|
+
<Runs paragraph={step.body} />
|
|
344
|
+
</>
|
|
345
|
+
)}
|
|
346
|
+
</p>
|
|
347
|
+
</li>
|
|
348
|
+
))}
|
|
349
|
+
</ol>
|
|
350
|
+
</section>
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function Read({
|
|
355
|
+
title,
|
|
356
|
+
preparedFor,
|
|
357
|
+
date,
|
|
358
|
+
lede,
|
|
359
|
+
gistTitle,
|
|
360
|
+
gradeKey,
|
|
361
|
+
tiers,
|
|
362
|
+
plan,
|
|
363
|
+
close,
|
|
364
|
+
labels: overrides,
|
|
365
|
+
}: ReadProps) {
|
|
366
|
+
const labels = { ...readLabels, ...overrides };
|
|
367
|
+
const shownTiers = tiers.filter((tier) => tier.sections.length > 0);
|
|
368
|
+
return (
|
|
369
|
+
<div className="op rd">
|
|
370
|
+
<article className="op-doc rd-doc">
|
|
371
|
+
<header className="op-head">
|
|
372
|
+
<p className="op-meta">
|
|
373
|
+
Prepared for <b>{preparedFor}</b>
|
|
374
|
+
<span className="op-meta-sep" aria-hidden="true">
|
|
375
|
+
{" · "}
|
|
376
|
+
</span>
|
|
377
|
+
<span className="op-meta-date">{date}</span>
|
|
378
|
+
</p>
|
|
379
|
+
<h1>{title}</h1>
|
|
380
|
+
{!isEmpty(lede) && <Paragraph paragraph={lede} className="op-intro rd-lede" />}
|
|
381
|
+
</header>
|
|
382
|
+
{shownTiers.length > 0 && (
|
|
383
|
+
<Gist tiers={shownTiers} title={gistTitle} gradeKey={gradeKey} labels={labels} />
|
|
384
|
+
)}
|
|
385
|
+
{shownTiers.map((tier) => (
|
|
386
|
+
<div className="rd-tier" key={tier.title}>
|
|
387
|
+
<div className="op-part" id={tier.id}>
|
|
388
|
+
<h2>{tier.title}</h2>
|
|
389
|
+
{tier.lead && <p>{tier.lead}</p>}
|
|
390
|
+
</div>
|
|
391
|
+
{tier.sections.map((section) => (
|
|
392
|
+
<Section section={section} labels={labels} key={section.id} />
|
|
393
|
+
))}
|
|
394
|
+
</div>
|
|
395
|
+
))}
|
|
396
|
+
{plan.steps.length > 0 && <Plan plan={plan} labels={labels} />}
|
|
397
|
+
{!isEmpty(close.line) && <Paragraph paragraph={close.line} className="rd-closing" />}
|
|
398
|
+
{close.signOff && close.person && (
|
|
399
|
+
<footer className="op-close rd-close">
|
|
400
|
+
<SignOff signOff={close.signOff} person={close.person} />
|
|
401
|
+
</footer>
|
|
402
|
+
)}
|
|
403
|
+
</article>
|
|
404
|
+
</div>
|
|
405
|
+
);
|
|
406
|
+
}
|
package/src/artifact/Writeup.tsx
CHANGED
|
@@ -4,10 +4,10 @@ import {
|
|
|
4
4
|
Callout,
|
|
5
5
|
Checklist,
|
|
6
6
|
type ChecklistItem,
|
|
7
|
-
Collapsible,
|
|
8
7
|
ColumnChart,
|
|
9
8
|
type ColumnChartGroup,
|
|
10
9
|
DataTable,
|
|
10
|
+
Disclosure,
|
|
11
11
|
StackedBar,
|
|
12
12
|
type StackedBarSegment,
|
|
13
13
|
type StatItem,
|
|
@@ -34,7 +34,7 @@ export type { WriteupPerson } from "./SignOff";
|
|
|
34
34
|
* type brought down, flowing across letter pages; nothing is clipped.
|
|
35
35
|
*
|
|
36
36
|
* Every block is a component from @atelic-action/ui (the checklist, the
|
|
37
|
-
* sortable table, the charts, the stat row, the callout, the
|
|
37
|
+
* sortable table, the charts, the stat row, the callout, the disclosure);
|
|
38
38
|
* this file owns only the document: the overview, the tiers, the section
|
|
39
39
|
* card, the markets table, and the close. Its skeleton is the practice's
|
|
40
40
|
* FOOTPRINT.md, and what it says follows the pitch surface rules
|
|
@@ -158,7 +158,7 @@ export interface WriteupShots {
|
|
|
158
158
|
shots: WriteupShot[];
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
/** Blocks that can sit inside a
|
|
161
|
+
/** Blocks that can sit inside a fold (a `more` block). */
|
|
162
162
|
export type WriteupInnerBlock =
|
|
163
163
|
| WriteupChecks
|
|
164
164
|
| WriteupTable
|
|
@@ -170,8 +170,8 @@ export type WriteupInnerBlock =
|
|
|
170
170
|
| WriteupShots;
|
|
171
171
|
|
|
172
172
|
/**
|
|
173
|
-
* A
|
|
174
|
-
* shows, so the detail needs no explaining once open.
|
|
173
|
+
* A fold, the native Disclosure: the label says what is inside and the
|
|
174
|
+
* summary says what it shows, so the detail needs no explaining once open.
|
|
175
175
|
*/
|
|
176
176
|
export interface WriteupMore {
|
|
177
177
|
kind: "more";
|
|
@@ -432,14 +432,14 @@ function Shots({ block }: { block: WriteupShots }) {
|
|
|
432
432
|
|
|
433
433
|
function More({ block }: { block: WriteupMore }) {
|
|
434
434
|
return (
|
|
435
|
-
<
|
|
435
|
+
<Disclosure className="op-block op-more" label={block.label} summary={block.summary}>
|
|
436
436
|
<div className="op-more-body">
|
|
437
437
|
{block.blocks.map((inner, i) => (
|
|
438
438
|
// biome-ignore lint/suspicious/noArrayIndexKey: blocks are static content in a fixed order
|
|
439
439
|
<TopicBlock block={inner} key={i} />
|
|
440
440
|
))}
|
|
441
441
|
</div>
|
|
442
|
-
</
|
|
442
|
+
</Disclosure>
|
|
443
443
|
);
|
|
444
444
|
}
|
|
445
445
|
|
package/src/artifact/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ export { Gate, type GatePayload, type GatePerson, type GateProps, normalizeEmail
|
|
|
13
13
|
export { GradeChip, gradeLabel } from "./GradeChip";
|
|
14
14
|
export * from "./Note";
|
|
15
15
|
export { buildPageHead, canonicalUrl, type PageMeta } from "./pageHead";
|
|
16
|
+
export * from "./Read";
|
|
16
17
|
export * from "./Report";
|
|
17
18
|
export {
|
|
18
19
|
mintToken,
|