@wenathlan/cobaia 0.0.0-stage → 2.0.158
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/App.tsx +52 -0
- package/Dockerfile +48 -0
- package/_headers +4 -0
- package/_redirects +1 -0
- package/adsconfig.ts +59 -0
- package/biome.jsonc +134 -0
- package/bun.lock +2451 -0
- package/capacitor.config.ts +51 -0
- package/checkoutlinks.ts +80 -0
- package/cli.ts +114 -0
- package/cobaia.csproj +22 -0
- package/cobaia.gemspec +14 -0
- package/cobaia.java +16 -0
- package/content.ts +425 -0
- package/db.ts +219 -0
- package/devthink.toml +5 -0
- package/docs/features.md +58 -0
- package/docs/monetization.md +61 -0
- package/familyurl.ts +22 -0
- package/favicon.svg +1 -0
- package/icon.svg +1 -0
- package/index.html +26 -0
- package/labels.ts +291 -0
- package/manifest.webmanifest +56 -0
- package/mime.types +12 -0
- package/netlify.toml +2 -0
- package/package.json +207 -4
- package/pnpm-workspace.yaml +101 -0
- package/pnpmboot.package-lock.json +157 -0
- package/pnpmboot.package.json +6 -0
- package/pom.xml +29 -0
- package/posts.ts +54 -0
- package/robots.txt +90 -0
- package/roster.ts +29 -0
- package/sandboxmodality.ts +138 -0
- package/schema.prisma +31 -0
- package/sitemap.xml +42 -0
- package/sol/Sol.tsx +91 -0
- package/sol/apps/apps.tsx +66 -0
- package/sol/blog/blog.tsx +86 -0
- package/sol/home/home.tsx +140 -0
- package/sol/intro/intro.tsx +187 -0
- package/sol/legal/legal.tsx +76 -0
- package/sol/notfound/notfound.tsx +32 -0
- package/sol/pricing/pricing.tsx +98 -0
- package/sol/shell/Shell.tsx +161 -0
- package/sol/sol.css +1951 -0
- package/sol/support/support.tsx +102 -0
- package/sol/wingets/IntroFooter.tsx +69 -0
- package/sol/wingets/IntroPlans.tsx +93 -0
- package/sol/wingets/IntroSplash.tsx +105 -0
- package/sol/wingets/ShellAdsslot.tsx +47 -0
- package/sol/wingets/ShellBrand.tsx +86 -0
- package/sol/wingets/index.ts +18 -0
- package/tests/adsconfig.test.ts +51 -0
- package/tests/checkoutlinks.test.ts +73 -0
- package/tests/familyurl.test.ts +40 -0
- package/tests/posts.test.ts +76 -0
- package/tests/sandboxmodality.test.ts +77 -0
- package/tsconfig.json +22 -0
- package/tsconfig.node.json +22 -0
- package/vercel.json +3 -0
- package/vite.config.ts +44 -0
- package/vitest.config.ts +42 -0
- package/wrangler.toml +7 -0
- package/README.md +0 -3
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* support page anchor — layer 3 of the anchor architecture.
|
|
3
|
+
* The file carrying the folder's own name is the path manager of the page:
|
|
4
|
+
* it imports the loose components beside it, mounts the page and re-exports
|
|
5
|
+
* the public component surface. Only the theme anchor (sol/Sol.tsx) consumes
|
|
6
|
+
* this file.
|
|
7
|
+
*
|
|
8
|
+
* The support page of the cobaia content site: the two configured channels
|
|
9
|
+
* (github sponsors and ko-fi, both zero platform fee) and the honest text of
|
|
10
|
+
* why the family takes support at all. The channel links read from the pure
|
|
11
|
+
* config at the app root (checkoutlinks.ts) — an unconfigured channel renders
|
|
12
|
+
* its honest empty state, never a fake link. No ad slot lives on this page:
|
|
13
|
+
* nothing that asks for support ever sits beside an advertisement.
|
|
14
|
+
*/
|
|
15
|
+
import type { CSSProperties } from "react";
|
|
16
|
+
import { isconfigured, supportlinks } from "../../checkoutlinks";
|
|
17
|
+
import { useContent } from "../../content.ts";
|
|
18
|
+
import { label } from "../../labels";
|
|
19
|
+
|
|
20
|
+
/** one support channel: the card, the promise and its config link. */
|
|
21
|
+
function Channel({
|
|
22
|
+
name,
|
|
23
|
+
promise,
|
|
24
|
+
href,
|
|
25
|
+
cta,
|
|
26
|
+
index,
|
|
27
|
+
}: {
|
|
28
|
+
name: string;
|
|
29
|
+
promise: string;
|
|
30
|
+
href: string;
|
|
31
|
+
cta: string;
|
|
32
|
+
index: number;
|
|
33
|
+
}) {
|
|
34
|
+
const live = isconfigured(href);
|
|
35
|
+
return (
|
|
36
|
+
<article className="cb-card cb-in" style={{ "--cb-d": index } as CSSProperties} aria-label={name}>
|
|
37
|
+
<h3 className="cb-cardtitle">{name}</h3>
|
|
38
|
+
<p className="cb-channeltext">{promise}</p>
|
|
39
|
+
{live ? (
|
|
40
|
+
<a className="cb-btn" href={href} target="_blank" rel="noreferrer">
|
|
41
|
+
{cta}
|
|
42
|
+
</a>
|
|
43
|
+
) : (
|
|
44
|
+
<>
|
|
45
|
+
<button className="cb-btn" type="button" disabled>
|
|
46
|
+
{cta}
|
|
47
|
+
</button>
|
|
48
|
+
<p className="cb-note">{label("support.action.notConfigured")}</p>
|
|
49
|
+
</>
|
|
50
|
+
)}
|
|
51
|
+
</article>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function Support() {
|
|
56
|
+
useContent();
|
|
57
|
+
const channels = supportlinks();
|
|
58
|
+
return (
|
|
59
|
+
<div className="cb-page">
|
|
60
|
+
<section className="cb-hero" aria-label={label("support.aria.hero")}>
|
|
61
|
+
<p className="cb-eyebrow cb-in" style={{ "--cb-d": 0 } as CSSProperties}>
|
|
62
|
+
{label("support.eyebrow")}
|
|
63
|
+
</p>
|
|
64
|
+
<h1 className="cb-title cb-in" style={{ "--cb-d": 1 } as CSSProperties}>
|
|
65
|
+
{label("support.title")}
|
|
66
|
+
</h1>
|
|
67
|
+
<p className="cb-lede cb-in" style={{ "--cb-d": 2 } as CSSProperties}>
|
|
68
|
+
{label("support.lede")}
|
|
69
|
+
</p>
|
|
70
|
+
</section>
|
|
71
|
+
|
|
72
|
+
<section className="cb-section" aria-label={label("support.section.channelsAria")}>
|
|
73
|
+
<div className="cb-grid cb-grid--2">
|
|
74
|
+
<Channel
|
|
75
|
+
index={1}
|
|
76
|
+
name={label("support.channel.sponsors.name")}
|
|
77
|
+
promise={label("support.channel.sponsors.promise")}
|
|
78
|
+
href={channels.sponsors}
|
|
79
|
+
cta={label("support.channel.sponsors.cta")}
|
|
80
|
+
/>
|
|
81
|
+
<Channel
|
|
82
|
+
index={2}
|
|
83
|
+
name={label("support.channel.kofi.name")}
|
|
84
|
+
promise={label("support.channel.kofi.promise")}
|
|
85
|
+
href={channels.kofi}
|
|
86
|
+
cta={label("support.channel.kofi.cta")}
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
</section>
|
|
90
|
+
|
|
91
|
+
<section className="cb-section" aria-label={label("support.section.otherAria")}>
|
|
92
|
+
<div className="cb-secthead">
|
|
93
|
+
<h2 className="cb-secttitle">{label("support.section.otherTitle")}</h2>
|
|
94
|
+
<p className="cb-sectlede">{label("support.section.otherLede")}</p>
|
|
95
|
+
</div>
|
|
96
|
+
<p className="cb-note is-live">{label("support.section.otherNote")}</p>
|
|
97
|
+
</section>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export default Support;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* intro.footer.tsx — the footer strip of the intro: the doors. The site row
|
|
3
|
+
* (the public sections of the content site, the labels riding the shell's
|
|
4
|
+
* own foot keys so the doors can never drift from the chrome), the family
|
|
5
|
+
* row (every deploy unit beside the cobaia, resolved through the relative
|
|
6
|
+
* familyurl contract so the doors survive any mount point, each carrying
|
|
7
|
+
* the one accent the spec table gives it) and the legal line (the shared
|
|
8
|
+
* colophon). Plain anchors, no decorative dots, zero storage, zero network —
|
|
9
|
+
* it works on a static deploy.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { CSSProperties } from "react";
|
|
13
|
+
import { useContent } from "../../content.ts";
|
|
14
|
+
import { familyurl } from "../../familyurl";
|
|
15
|
+
import { type LabelKey, label } from "../../labels";
|
|
16
|
+
import { roster } from "../../roster.ts";
|
|
17
|
+
|
|
18
|
+
/** the site doors of the footer row, in reading order (the labels ride the
|
|
19
|
+
* shell foot keys — the same words the chrome's footer carries). */
|
|
20
|
+
const SITEDOORS: readonly { href: string; key: LabelKey }[] = [
|
|
21
|
+
{ href: "/apps", key: "shell.foot.apps" },
|
|
22
|
+
{ href: "/pricing", key: "shell.foot.pricing" },
|
|
23
|
+
{ href: "/blog", key: "shell.foot.blog" },
|
|
24
|
+
{ href: "/support", key: "shell.foot.support" },
|
|
25
|
+
{ href: "/legal", key: "shell.foot.legal" },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The footer strip: the site doors, the family row and the legal line.
|
|
30
|
+
*
|
|
31
|
+
* @returns the footer element.
|
|
32
|
+
*/
|
|
33
|
+
export function IntroFooter() {
|
|
34
|
+
useContent();
|
|
35
|
+
return (
|
|
36
|
+
<footer className="cb-introfoot">
|
|
37
|
+
<div className="cb-introwrap">
|
|
38
|
+
<nav className="cb-introfoot__row" aria-label={label("intro.footer.siteAria")}>
|
|
39
|
+
<span className="cb-eyebrow">{label("shell.foot.siteHead")}</span>
|
|
40
|
+
{SITEDOORS.map((door) => (
|
|
41
|
+
<a key={door.href} className="cb-introfoot__door" href={door.href}>
|
|
42
|
+
{label(door.key)}
|
|
43
|
+
</a>
|
|
44
|
+
))}
|
|
45
|
+
</nav>
|
|
46
|
+
<nav className="cb-introfoot__row" aria-label={label("intro.footer.familyAria")}>
|
|
47
|
+
<span className="cb-eyebrow">{label("intro.footer.family")}</span>
|
|
48
|
+
{roster().map((member) => (
|
|
49
|
+
<a
|
|
50
|
+
key={member.slug}
|
|
51
|
+
className="cb-introfoot__fam"
|
|
52
|
+
href={familyurl(member.slug)}
|
|
53
|
+
target="_blank"
|
|
54
|
+
rel="noreferrer"
|
|
55
|
+
style={{ "--cb-dot": member.accent } as CSSProperties}
|
|
56
|
+
data-tip={label("intro.footer.memberTip").replaceAll("{slug}", member.slug)}
|
|
57
|
+
>
|
|
58
|
+
<span className="cb-introfoot__dot" aria-hidden="true" />
|
|
59
|
+
{member.slug}
|
|
60
|
+
</a>
|
|
61
|
+
))}
|
|
62
|
+
</nav>
|
|
63
|
+
<p className="cb-introfoot__legal">{label("shell.colophon")}</p>
|
|
64
|
+
</div>
|
|
65
|
+
</footer>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default IntroFooter;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* intro.plans.tsx — the plans zone of the intro: the real offer, read from
|
|
3
|
+
* the one checkoutlinks table the pricing page reads — never restated, never
|
|
4
|
+
* rounded, never invented. The billing values (ids, prices, links) answer
|
|
5
|
+
* from checkoutlinks and every plan sentence (name, blurb, features) rides
|
|
6
|
+
* the label store — zero hardcoded copy in the winget. A configured
|
|
7
|
+
* checkout href renders a live anchor; an empty one renders the disabled
|
|
8
|
+
* button with the honest note "checkout not configured yet" — the
|
|
9
|
+
* not-configured state stays honest, exactly like /pricing. The studio plan
|
|
10
|
+
* leads raised, the same lead law the pricing page carries. The section
|
|
11
|
+
* rides a ladder band (the surface rung s0) and the cb-in stagger under the
|
|
12
|
+
* second; zero network calls — the links are plain hrefs the visitor
|
|
13
|
+
* chooses to follow.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { CSSProperties } from "react";
|
|
17
|
+
import { Link } from "wouter";
|
|
18
|
+
import { checkoutlinks, type checkoutplan, isconfigured } from "../../checkoutlinks";
|
|
19
|
+
import { useContent } from "../../content.ts";
|
|
20
|
+
import { label, labelfill } from "../../labels";
|
|
21
|
+
|
|
22
|
+
/** renders one plan's checkout control: a live anchor when the owner
|
|
23
|
+
* configured the link, the honest disabled state until then. */
|
|
24
|
+
function PlanAction({ plan }: { plan: checkoutplan }) {
|
|
25
|
+
if (isconfigured(plan.href)) {
|
|
26
|
+
return (
|
|
27
|
+
<a className="cb-btn" href={plan.href} rel="noreferrer">
|
|
28
|
+
{labelfill("intro.plans.choose", { name: label(plan.nameKey) })}
|
|
29
|
+
</a>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return (
|
|
33
|
+
<>
|
|
34
|
+
<button className="cb-btn" type="button" disabled>
|
|
35
|
+
{labelfill("intro.plans.choose", { name: label(plan.nameKey) })}
|
|
36
|
+
</button>
|
|
37
|
+
<p className="cb-note">{label("intro.plans.notConfigured")}</p>
|
|
38
|
+
</>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The plans zone: the section head, the three honest plans and the door to
|
|
44
|
+
* the full pricing page.
|
|
45
|
+
*
|
|
46
|
+
* @returns the plans section element.
|
|
47
|
+
*/
|
|
48
|
+
export function IntroPlans() {
|
|
49
|
+
useContent();
|
|
50
|
+
return (
|
|
51
|
+
<section className="cb-introband cb-introband--s1" id="intro-plans" aria-label={label("intro.plans.aria")}>
|
|
52
|
+
<div className="cb-introwrap">
|
|
53
|
+
<div className="cb-secthead cb-in" style={{ "--cb-d": 0 } as CSSProperties}>
|
|
54
|
+
<p className="cb-eyebrow">{label("intro.plans.eyebrow")}</p>
|
|
55
|
+
<h2 className="cb-secttitle">{label("intro.plans.title")}</h2>
|
|
56
|
+
<p className="cb-sectlede">{label("intro.plans.lede")}</p>
|
|
57
|
+
</div>
|
|
58
|
+
<div className="cb-plans">
|
|
59
|
+
{checkoutlinks().map((plan, index) => (
|
|
60
|
+
<article
|
|
61
|
+
key={plan.id}
|
|
62
|
+
className={plan.id === "studio" ? "cb-plan cb-plan--lead cb-in" : "cb-plan cb-in"}
|
|
63
|
+
style={{ "--cb-d": index + 1 } as CSSProperties}
|
|
64
|
+
aria-label={labelfill("pricing.card.planAria", { name: label(plan.nameKey) })}
|
|
65
|
+
>
|
|
66
|
+
<p className="cb-planname">{label(plan.nameKey)}</p>
|
|
67
|
+
<p className="cb-planprice">
|
|
68
|
+
${plan.priceusd}
|
|
69
|
+
<span className="cb-planper"> / {label(plan.perKey)}</span>
|
|
70
|
+
</p>
|
|
71
|
+
<p className="cb-planblurb">{label(plan.blurbKey)}</p>
|
|
72
|
+
<ul className="cb-planfeats">
|
|
73
|
+
{plan.featureKeys.map((featureKey) => (
|
|
74
|
+
<li key={featureKey} className="cb-planfeat">
|
|
75
|
+
{label(featureKey)}
|
|
76
|
+
</li>
|
|
77
|
+
))}
|
|
78
|
+
</ul>
|
|
79
|
+
<PlanAction plan={plan} />
|
|
80
|
+
</article>
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
<p className="cb-introdoorline cb-in" style={{ "--cb-d": 4 } as CSSProperties}>
|
|
84
|
+
<Link className="cb-link" href="/pricing">
|
|
85
|
+
{label("intro.plans.door")}
|
|
86
|
+
</Link>
|
|
87
|
+
</p>
|
|
88
|
+
</div>
|
|
89
|
+
</section>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export default IntroPlans;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* intro.splash.tsx — the opening act of the cobaia intro: the akash floor of
|
|
3
|
+
* the family. The drawn mark springs onto the deep canvas on the overshoot
|
|
4
|
+
* curve cubic-bezier(.34,1.56,.64,1), the name rises with the same backOut
|
|
5
|
+
* spring, the role line fades in late; the exit slides the whole overlay up
|
|
6
|
+
* in exactly 0.8s on the leave curve cubic-bezier(0.76,0,0.24,1) and reveals
|
|
7
|
+
* the landing beneath. No loading dots, no spinner — the mark speaks. One
|
|
8
|
+
* click, one tap or any key skips straight to the leave; prefers-reduced-
|
|
9
|
+
* motion hands over instantly. The cobaia no storage law holds: the splash
|
|
10
|
+
* writes nothing to the visitor machine — no seen flag, no cache row — it
|
|
11
|
+
* plays per visit to /intro and leaves nothing behind, so the leave marks
|
|
12
|
+
* nothing and the done callback is the only hand-over.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
16
|
+
import { label } from "../../labels";
|
|
17
|
+
import { BrandMark } from "./ShellBrand.tsx";
|
|
18
|
+
|
|
19
|
+
/** the beat plan of the splash (ms): mark 0 → name 140 → role 400 → leave 1250. */
|
|
20
|
+
const BEATS = { name: 140, role: 400, leave: 1250 } as const;
|
|
21
|
+
|
|
22
|
+
/** the exit slide rides exactly 0.8s (the akash leave law). */
|
|
23
|
+
const LEAVE_MS = 800;
|
|
24
|
+
|
|
25
|
+
type SplashProps = {
|
|
26
|
+
/** fired after the slide completes (the caller unmounts the splash) */
|
|
27
|
+
onDone: () => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The akash-floor splash over the intro landing.
|
|
32
|
+
*
|
|
33
|
+
* @param props the done hand-over callback.
|
|
34
|
+
* @returns the splash element.
|
|
35
|
+
*/
|
|
36
|
+
export function IntroSplash({ onDone }: SplashProps) {
|
|
37
|
+
const [leaving, setLeaving] = useState(false);
|
|
38
|
+
const doneRef = useRef(onDone);
|
|
39
|
+
doneRef.current = onDone;
|
|
40
|
+
const left = useRef(false);
|
|
41
|
+
const timerRef = useRef<number | null>(null);
|
|
42
|
+
|
|
43
|
+
/** the single guarded leave: the stage slides once, the done lands after
|
|
44
|
+
* the slide — the leave marks nothing (the no storage law). */
|
|
45
|
+
const leave = useCallback(() => {
|
|
46
|
+
if (left.current) return;
|
|
47
|
+
left.current = true;
|
|
48
|
+
setLeaving(true);
|
|
49
|
+
timerRef.current = window.setTimeout(() => doneRef.current(), LEAVE_MS);
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
// the slide timer never outlives the splash
|
|
53
|
+
useEffect(
|
|
54
|
+
() => () => {
|
|
55
|
+
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
|
|
56
|
+
},
|
|
57
|
+
[],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// reduced motion hands over instantly — the splash never plays
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
63
|
+
left.current = true;
|
|
64
|
+
doneRef.current();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const timer = window.setTimeout(leave, BEATS.leave);
|
|
68
|
+
return () => window.clearTimeout(timer);
|
|
69
|
+
}, [leave]);
|
|
70
|
+
|
|
71
|
+
// one click, one tap or any key skips straight to the leave
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const skip = (event: MouseEvent | KeyboardEvent): void => {
|
|
74
|
+
if (event.type === "keydown" || (event as MouseEvent).button === 0) leave();
|
|
75
|
+
};
|
|
76
|
+
window.addEventListener("keydown", skip);
|
|
77
|
+
window.addEventListener("pointerdown", skip);
|
|
78
|
+
return () => {
|
|
79
|
+
window.removeEventListener("keydown", skip);
|
|
80
|
+
window.removeEventListener("pointerdown", skip);
|
|
81
|
+
};
|
|
82
|
+
}, [leave]);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
className="cb-splash"
|
|
87
|
+
data-leaving={leaving ? "true" : undefined}
|
|
88
|
+
role="status"
|
|
89
|
+
aria-label={label("intro.aria.splash")}
|
|
90
|
+
>
|
|
91
|
+
<div className="cb-splash__stage" aria-hidden="true">
|
|
92
|
+
<span className="cb-splash__mark">
|
|
93
|
+
<BrandMark size={96} />
|
|
94
|
+
</span>
|
|
95
|
+
<p className="cb-splash__name">{label("intro.splash.name")}</p>
|
|
96
|
+
<p className="cb-splash__role">{label("intro.splash.role")}</p>
|
|
97
|
+
</div>
|
|
98
|
+
<span className="cb-splash__skip" aria-hidden="true">
|
|
99
|
+
{label("intro.splash.skip")}
|
|
100
|
+
</span>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export default IntroSplash;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* adsslot.tsx — the honest ad container of the content pages. The component
|
|
3
|
+
* renders a RESERVED box only: a fixed aspect-ratio container on a hairline
|
|
4
|
+
* well, a visible mono label reading "advertisement", and an honest empty
|
|
5
|
+
* state — a hairline box breathing on the opacity-only skeleton shimmer —
|
|
6
|
+
* while no slot id is configured. The container never shifts the page
|
|
7
|
+
* and never sits beside a call to action.
|
|
8
|
+
*
|
|
9
|
+
* THE SCRIPT RULE: no ad network script lives in this repository. The
|
|
10
|
+
* AdSense (or equivalent) loader enters ONLY after the network approves the
|
|
11
|
+
* site, injected through the deploy environment (an env-bound snippet
|
|
12
|
+
* mounted inside the reserved box) — it is never committed here, and this
|
|
13
|
+
* component makes zero network calls by design. While the slot id in
|
|
14
|
+
* adsconfig.ts stays empty, the visitor sees the honest placeholder. No
|
|
15
|
+
* automated traffic ever touches an ad surface.
|
|
16
|
+
*/
|
|
17
|
+
import type { CSSProperties } from "react";
|
|
18
|
+
import { ADS_EMPTY, ADS_LABEL, adsslotfor, adsslotlive } from "../../adsconfig.ts";
|
|
19
|
+
import { labelfill } from "../../labels";
|
|
20
|
+
|
|
21
|
+
type AdSlotProps = {
|
|
22
|
+
/** the content page the slot belongs to (home, apps, blog) */
|
|
23
|
+
page: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** The reserved ad slot of one content page: honest container, zero scripts. */
|
|
27
|
+
export function AdSlot({ page }: AdSlotProps) {
|
|
28
|
+
const slot = adsslotfor(page);
|
|
29
|
+
if (!slot) return null;
|
|
30
|
+
const live = adsslotlive(slot);
|
|
31
|
+
return (
|
|
32
|
+
<aside className="cb-ads" aria-label={ADS_LABEL} style={{ "--cb-ads-ratio": slot.ratio } as CSSProperties}>
|
|
33
|
+
<span className="cb-adslabel">{ADS_LABEL}</span>
|
|
34
|
+
<div className="cb-adsbox">
|
|
35
|
+
{live ? (
|
|
36
|
+
// the reserved container: the network script mounts here after
|
|
37
|
+
// approval, injected by the deploy environment — never committed
|
|
38
|
+
<span className="cb-adsnote">{labelfill("ads.slot.reserved", { format: slot.format })}</span>
|
|
39
|
+
) : (
|
|
40
|
+
<span className="cb-empty cb-skel">{ADS_EMPTY}</span>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
</aside>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default AdSlot;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brand.tsx — the drawn brand mark of the cobaia theme, one hand-drawn SVG
|
|
3
|
+
* icon in the house finishing: a gradient squircle face over the DARK AMBER
|
|
4
|
+
* story (#d97706 → #92400e — the identity of the site, carried by the mark
|
|
5
|
+
* ALONE: focus, CTA and every interactive voice answer the Windows accent),
|
|
6
|
+
* a soft top gloss, a discrete film grain and the glyph itself — a beaker,
|
|
7
|
+
* the mark of the experiment the site runs — in thick ivory strokes with a
|
|
8
|
+
* translucent backing. On hover the face tilts gently in perspective (plain
|
|
9
|
+
* CSS on transform only, riding the house cubic-bezier; the stylesheet owns
|
|
10
|
+
* the motion and the prefers-reduced-motion guard). No decorative dots and
|
|
11
|
+
* no loops: the mark speaks for itself. The name "cobaia" is provisional —
|
|
12
|
+
* the mark is deliberately abstract so it survives a rename.
|
|
13
|
+
*/
|
|
14
|
+
import type { CSSProperties } from "react";
|
|
15
|
+
|
|
16
|
+
/** the warm ivory of the glyph stroke */
|
|
17
|
+
const IVORY = "#fbf5ea";
|
|
18
|
+
|
|
19
|
+
/** the backing translucency of the outlined glyph shapes */
|
|
20
|
+
const BACKING = "rgba(255,255,255,.16)";
|
|
21
|
+
|
|
22
|
+
/** the dark amber story of the mark: the cobaia identity and its deeper
|
|
23
|
+
* amber floor — the one place the identity color is allowed to live */
|
|
24
|
+
const SUN = "#d97706";
|
|
25
|
+
const RUST = "#92400e";
|
|
26
|
+
|
|
27
|
+
/** the asymmetric squircle of the mark face: tighter shoulders, heavier base */
|
|
28
|
+
const SQUIRCLE = "M22 0 L74 0 Q96 0 96 22 L96 66 Q96 96 66 96 L30 96 Q0 96 0 66 L0 22 Q0 0 22 0 Z";
|
|
29
|
+
|
|
30
|
+
type BrandMarkProps = {
|
|
31
|
+
/** rendered square size in px; omitted, the mark fills its box */
|
|
32
|
+
size?: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** The drawn brand mark of cobaia. */
|
|
36
|
+
export function BrandMark({ size }: BrandMarkProps) {
|
|
37
|
+
const vars: CSSProperties = size !== undefined ? { width: size, height: size } : {};
|
|
38
|
+
return (
|
|
39
|
+
<span className="cbMark" style={vars} aria-hidden="true">
|
|
40
|
+
<svg className="cbMarkSvg" viewBox="0 0 96 96" aria-hidden="true" focusable="false">
|
|
41
|
+
<defs>
|
|
42
|
+
<linearGradient id="cbm-bg" x1="0" y1="0" x2="0" y2="1">
|
|
43
|
+
<stop offset="0" stopColor={SUN} />
|
|
44
|
+
<stop offset=".62" stopColor={SUN} />
|
|
45
|
+
<stop offset="1" stopColor={RUST} />
|
|
46
|
+
</linearGradient>
|
|
47
|
+
<linearGradient id="cbm-gloss" x1="0" y1="0" x2="0" y2="1">
|
|
48
|
+
<stop offset="0" stopColor="#ffffff" stopOpacity=".3" />
|
|
49
|
+
<stop offset="1" stopColor="#ffffff" stopOpacity="0" />
|
|
50
|
+
</linearGradient>
|
|
51
|
+
<filter id="cbm-soft" x="-40%" y="-40%" width="180%" height="180%">
|
|
52
|
+
<feGaussianBlur stdDeviation="2.5" />
|
|
53
|
+
</filter>
|
|
54
|
+
<filter id="cbm-grain" x="0%" y="0%" width="100%" height="100%">
|
|
55
|
+
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" stitchTiles="stitch" result="n" />
|
|
56
|
+
<feColorMatrix in="n" type="saturate" values="0" />
|
|
57
|
+
<feComposite operator="in" in2="SourceGraphic" />
|
|
58
|
+
</filter>
|
|
59
|
+
<clipPath id="cbm-clip">
|
|
60
|
+
<path d={SQUIRCLE} />
|
|
61
|
+
</clipPath>
|
|
62
|
+
</defs>
|
|
63
|
+
|
|
64
|
+
{/* the face: gradient squircle with a soft top gloss */}
|
|
65
|
+
<path d={SQUIRCLE} fill="url(#cbm-bg)" />
|
|
66
|
+
<path d={SQUIRCLE} fill="url(#cbm-gloss)" opacity=".55" />
|
|
67
|
+
|
|
68
|
+
<g clipPath="url(#cbm-clip)">
|
|
69
|
+
{/* the liquid line of the beaker, a quiet inner seam */}
|
|
70
|
+
<ellipse cx="48" cy="94" rx="34" ry="9" fill={RUST} opacity=".45" filter="url(#cbm-soft)" />
|
|
71
|
+
{/* the discrete film grain */}
|
|
72
|
+
<path d={SQUIRCLE} fill="#ffffff" filter="url(#cbm-grain)" opacity=".08" />
|
|
73
|
+
</g>
|
|
74
|
+
|
|
75
|
+
{/* the glyph: the beaker, thick ivory strokes */}
|
|
76
|
+
<g fill="none" stroke={IVORY} strokeWidth="5.5" strokeLinecap="round" strokeLinejoin="round">
|
|
77
|
+
<path d="M39 25 H57" />
|
|
78
|
+
<path d="M43 25 V44 L31.5 64 A5 5 0 0 0 35.8 72 H60.2 A5 5 0 0 0 64.5 64 L53 44 V25" fill={BACKING} />
|
|
79
|
+
<path d="M37 56 H59" />
|
|
80
|
+
</g>
|
|
81
|
+
</svg>
|
|
82
|
+
</span>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export default BrandMark;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* index.ts — the universal winget surface of cobaia: every winget of the
|
|
3
|
+
* sol/wingets folder answers from this one module, so the pages import
|
|
4
|
+
* their components from a single place and the dotted winget file name
|
|
5
|
+
* lives exactly once (here) instead of repeating across every page —
|
|
6
|
+
* the components stay complete independent files beside this surface.
|
|
7
|
+
*/
|
|
8
|
+
export * from "../../roster.ts";
|
|
9
|
+
export * from "./IntroFooter.tsx";
|
|
10
|
+
export { default as IntroFooter } from "./IntroFooter.tsx";
|
|
11
|
+
export * from "./IntroPlans.tsx";
|
|
12
|
+
export { default as IntroPlans } from "./IntroPlans.tsx";
|
|
13
|
+
export * from "./IntroSplash.tsx";
|
|
14
|
+
export { default as IntroSplash } from "./IntroSplash.tsx";
|
|
15
|
+
export * from "./ShellAdsslot.tsx";
|
|
16
|
+
export { default as ShellAdsslot } from "./ShellAdsslot.tsx";
|
|
17
|
+
export * from "./ShellBrand.tsx";
|
|
18
|
+
export { default as ShellBrand } from "./ShellBrand.tsx";
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// # adsconfig.test — the honest ad slot registry of the family site.
|
|
2
|
+
// Run: pnpm vitest run tests/adsconfig.test.ts
|
|
3
|
+
// The suite watches the promise the ad slot makes: one reserved slot per
|
|
4
|
+
// content page (home, apps, blog), every slot id shipping EMPTY (no ad
|
|
5
|
+
// network script, no id, no key is ever committed — the loader enters after
|
|
6
|
+
// approval through the deploy environment), the fixed visible label, and the
|
|
7
|
+
// aspect-ratio shapes the containers reserve so the page never shifts.
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import { ADS_EMPTY, ADS_LABEL, ADSSLOTS, adsslotfor, adsslotlive } from "../adsconfig.ts";
|
|
11
|
+
|
|
12
|
+
describe("the ad slot registry", () => {
|
|
13
|
+
it("reserves one slot per content page and nothing else", () => {
|
|
14
|
+
assert.deepEqual(Object.keys(ADSSLOTS), ["home", "apps", "blog"]);
|
|
15
|
+
assert.deepEqual(
|
|
16
|
+
Object.values(ADSSLOTS).map((slot) => slot.format),
|
|
17
|
+
["display", "in-feed", "in-article"],
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("ships every slot id empty — the honest placeholder", () => {
|
|
22
|
+
for (const [page, slot] of Object.entries(ADSSLOTS)) {
|
|
23
|
+
assert.equal(slot.slotid, "", `${page} must not carry a committed ad network id`);
|
|
24
|
+
assert.equal(adsslotlive(slot), false, `${page} must read as not configured`);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("labels the container advertisement, visibly and always", () => {
|
|
29
|
+
assert.equal(ADS_LABEL, "advertisement");
|
|
30
|
+
assert.ok(ADS_EMPTY.length > 0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("reserves fixed aspect ratios so the page never shifts", () => {
|
|
34
|
+
for (const slot of Object.values(ADSSLOTS)) {
|
|
35
|
+
assert.match(slot.ratio, /^\d+ \/ \d+$/);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("answers the live contract on real slot ids only", () => {
|
|
40
|
+
assert.equal(adsslotlive(undefined), false);
|
|
41
|
+
assert.equal(adsslotlive({ slotid: " ", format: "display", ratio: "728 / 90" }), false);
|
|
42
|
+
assert.equal(adsslotlive({ slotid: "ca-pub-0000000000000000", format: "display", ratio: "728 / 90" }), true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns nothing for pages without a slot", () => {
|
|
46
|
+
expect(adsslotfor("pricing")).toBeUndefined();
|
|
47
|
+
expect(adsslotfor("support")).toBeUndefined();
|
|
48
|
+
expect(adsslotfor("legal")).toBeUndefined();
|
|
49
|
+
expect(adsslotfor("notfound")).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// # checkoutlinks.test — the billing configuration of the family site.
|
|
2
|
+
// Run: pnpm vitest run tests/checkoutlinks.test.ts
|
|
3
|
+
// The suite watches the promise the pricing page makes: the three plans
|
|
4
|
+
// exist with the placeholder prices (US$0 / 5 / 19 a month), every checkout
|
|
5
|
+
// href ships EMPTY so the interface renders the honest empty state instead
|
|
6
|
+
// of a dead link, the support channels obey the same rule, and the
|
|
7
|
+
// configured/not-configured contract answers only on real values. The plan
|
|
8
|
+
// prose (names, billing period, blurbs, features) lives in the label
|
|
9
|
+
// manifest — the suite pins that the store carries a sentence for every key
|
|
10
|
+
// the billing table cites, so a card never renders a bare key.
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
import { checkoutlinks, isconfigured, planbyid, supportlinks } from "../checkoutlinks.ts";
|
|
14
|
+
import { LABEL_MANIFEST } from "../labels.ts";
|
|
15
|
+
|
|
16
|
+
describe("checkout links", () => {
|
|
17
|
+
it("carries the three honest plans in display order", () => {
|
|
18
|
+
assert.deepEqual(
|
|
19
|
+
checkoutlinks().map((plan) => plan.id),
|
|
20
|
+
["free", "pro", "studio"],
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("prices the plans at the placeholder us$0 / 5 / 19 a month", () => {
|
|
25
|
+
const prices = new Map(checkoutlinks().map((plan) => [plan.id, plan.priceusd]));
|
|
26
|
+
expect(prices.get("free")).toBe(0);
|
|
27
|
+
expect(prices.get("pro")).toBe(5);
|
|
28
|
+
expect(prices.get("studio")).toBe(19);
|
|
29
|
+
for (const plan of checkoutlinks()) {
|
|
30
|
+
assert.ok(plan.featureKeys.length >= 3, `${plan.id} must list at least three honest features`);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("answers every plan sentence from the label manifest — zero hardcoded prose", () => {
|
|
35
|
+
const prose = (key: string): string => (LABEL_MANIFEST as Record<string, string>)[key];
|
|
36
|
+
for (const plan of checkoutlinks()) {
|
|
37
|
+
assert.ok(prose(plan.nameKey), `${plan.id} name key must answer from the manifest`);
|
|
38
|
+
assert.ok(prose(plan.perKey), `${plan.id} period key must answer from the manifest`);
|
|
39
|
+
assert.ok(prose(plan.blurbKey), `${plan.id} blurb key must answer from the manifest`);
|
|
40
|
+
for (const featureKey of plan.featureKeys) {
|
|
41
|
+
assert.ok(prose(featureKey), `${plan.id} feature key must answer from the manifest`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
assert.equal(prose("pricing.plan.free.name"), "Free");
|
|
45
|
+
assert.equal(prose("pricing.plan.pro.name"), "Pro");
|
|
46
|
+
assert.equal(prose("pricing.plan.studio.name"), "Studio");
|
|
47
|
+
assert.equal(prose("pricing.plan.free.per"), "month");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("ships every checkout href empty — the honest empty state", () => {
|
|
51
|
+
for (const plan of checkoutlinks()) {
|
|
52
|
+
assert.equal(plan.href, "", `${plan.id} must not carry a fake checkout link`);
|
|
53
|
+
assert.equal(isconfigured(plan.href), false, `${plan.id} must read as not configured`);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("keeps the support channels unconfigured until the owner sets them", () => {
|
|
58
|
+
assert.equal(supportlinks().sponsors, "");
|
|
59
|
+
assert.equal(supportlinks().kofi, "");
|
|
60
|
+
assert.equal(isconfigured(supportlinks().sponsors), false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("answers the configured contract on real values only", () => {
|
|
64
|
+
assert.equal(isconfigured(""), false);
|
|
65
|
+
assert.equal(isconfigured(" "), false);
|
|
66
|
+
assert.equal(isconfigured("https://buy.stripe.com/example"), true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("resolves plans by id and nothing else", () => {
|
|
70
|
+
assert.equal(planbyid("pro")?.id, "pro");
|
|
71
|
+
assert.equal(planbyid("enterprise"), undefined);
|
|
72
|
+
});
|
|
73
|
+
});
|