@wenathlan/cobaia 2.0.165 → 2.0.167
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/.dockerignore +1 -1
- package/App.tsx +2 -31
- package/Dockerfile +162 -4
- package/biome.jsonc +3 -3
- package/build.gradle.kts +4 -4
- package/bun.lock +1820 -487
- package/cobaia.csproj +2 -2
- package/cobaia.gemspec +2 -2
- package/cobaia.java +2 -2
- package/db.ts +87 -3
- package/docker-compose.yml +58 -0
- package/docker.config +74 -0
- package/docs/cobaia.md +31 -0
- package/docs/distributionlanes.md +1 -1
- package/keywords.txt +1 -0
- package/labels.ts +23 -1
- package/monetization.ts +248 -0
- package/nginx.conf +106 -0
- package/onboardingstate.ts +211 -0
- package/package.json +430 -83
- package/pnpm-workspace.yaml +60 -38
- package/pom.xml +4 -4
- package/routerbase.ts +1 -0
- package/schema.prisma +23 -0
- package/sol/Sol.tsx +20 -15
- package/sol/apps/apps.tsx +1 -1
- package/sol/blog/blog.tsx +1 -1
- package/sol/home/home.tsx +14 -4
- package/sol/intro/intro.tsx +13 -5
- package/sol/onboarding/onboarding.tsx +146 -0
- package/sol/pricing/pricing.tsx +1 -1
- package/sol/shell/Shell.tsx +1 -1
- package/sol/sol.css +153 -0
- package/sol/support/support.tsx +1 -1
- package/sol/wingets/HomeEntrygate.tsx +64 -0
- package/sol/wingets/IntroFooter.tsx +1 -1
- package/sol/wingets/IntroPlans.tsx +1 -1
- package/sol/wingets/ShellAdsslot.tsx +1 -1
- package/sol/wingets/index.ts +3 -1
- package/tauri.conf.json +1 -1
- package/{tests → test}/adsconfig.test.ts +2 -2
- package/{tests → test}/checkoutlinks.test.ts +2 -2
- package/test/entryflow.test.ts +124 -0
- package/{tests → test}/familyurl.test.ts +1 -1
- package/{tests → test}/posts.test.ts +2 -2
- package/{tests → test}/sandboxmodality.test.ts +1 -1
- package/vitest.config.ts +1 -1
- package/adsconfig.ts +0 -59
- package/checkoutlinks.ts +0 -80
- package/posts.ts +0 -54
- package/roster.ts +0 -29
package/sol/sol.css
CHANGED
|
@@ -125,6 +125,9 @@
|
|
|
125
125
|
--color-win: #4cc2ff;
|
|
126
126
|
--color-win-ink: #0a0a0a;
|
|
127
127
|
--color-focus: var(--color-win);
|
|
128
|
+
/* the ice-vault accent of the family spec (PR9): the #a3d7e6 lane the
|
|
129
|
+
vault surfaces borrow — focus stays the windows #4cc2ff */
|
|
130
|
+
--accent-ice-vault: #a3d7e6;
|
|
128
131
|
|
|
129
132
|
/* the canvas + the 8 surface rungs (elevation by color, never by border) */
|
|
130
133
|
--sol-canvas: var(--color-canvas);
|
|
@@ -1949,3 +1952,153 @@ code {
|
|
|
1949
1952
|
html {
|
|
1950
1953
|
touch-action: pan-x pan-y;
|
|
1951
1954
|
}
|
|
1955
|
+
|
|
1956
|
+
/* ---------- 21. THE ONBOARDING — the first-run frames of the entry flow
|
|
1957
|
+
(intro → onboarding → entrada; the family law). the three frames ride
|
|
1958
|
+
one centered card on the deep canvas — the site voice, no window chrome,
|
|
1959
|
+
responsive from the phone to the tv (the card caps its width and the
|
|
1960
|
+
feet stack under the narrow gate). ---------- */
|
|
1961
|
+
.cb-onboard {
|
|
1962
|
+
min-height: 100vh;
|
|
1963
|
+
display: grid;
|
|
1964
|
+
place-items: center;
|
|
1965
|
+
padding: 24px;
|
|
1966
|
+
background:
|
|
1967
|
+
radial-gradient(120% 90% at 50% -10%, rgb(255 255 255 / 4%), transparent 60%),
|
|
1968
|
+
var(--sol-canvas-deep);
|
|
1969
|
+
}
|
|
1970
|
+
.cb-onboard__card {
|
|
1971
|
+
width: min(560px, 100%);
|
|
1972
|
+
background: var(--sol-surface-2);
|
|
1973
|
+
border: 1px solid var(--sol-hairline);
|
|
1974
|
+
border-radius: 10px;
|
|
1975
|
+
box-shadow: 0 24px 48px -24px rgb(0 0 0 / 60%);
|
|
1976
|
+
overflow: hidden;
|
|
1977
|
+
}
|
|
1978
|
+
.cb-onboard__head {
|
|
1979
|
+
display: flex;
|
|
1980
|
+
align-items: center;
|
|
1981
|
+
gap: 10px;
|
|
1982
|
+
padding: 14px 18px;
|
|
1983
|
+
border-bottom: 1px solid var(--sol-hairline);
|
|
1984
|
+
background: var(--sol-surface-1);
|
|
1985
|
+
}
|
|
1986
|
+
.cb-onboard__title {
|
|
1987
|
+
font-size: 12px;
|
|
1988
|
+
font-weight: 550;
|
|
1989
|
+
letter-spacing: 0.08em;
|
|
1990
|
+
text-transform: lowercase;
|
|
1991
|
+
color: var(--sol-muted);
|
|
1992
|
+
}
|
|
1993
|
+
.cb-onboard__body {
|
|
1994
|
+
padding: 26px 24px 8px;
|
|
1995
|
+
min-height: 220px;
|
|
1996
|
+
}
|
|
1997
|
+
.cb-onboard__step {
|
|
1998
|
+
animation: cbOnboardIn 250ms var(--sol-ease, cubic-bezier(0.22, 1, 0.36, 1)) backwards;
|
|
1999
|
+
}
|
|
2000
|
+
@keyframes cbOnboardIn {
|
|
2001
|
+
from {
|
|
2002
|
+
opacity: 0;
|
|
2003
|
+
transform: translateY(8px);
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
.cb-onboard__lead {
|
|
2007
|
+
margin: 6px 0 10px;
|
|
2008
|
+
font-size: 22px;
|
|
2009
|
+
line-height: 1.2;
|
|
2010
|
+
font-weight: 600;
|
|
2011
|
+
color: var(--sol-ink);
|
|
2012
|
+
}
|
|
2013
|
+
.cb-onboard__lede {
|
|
2014
|
+
margin: 0;
|
|
2015
|
+
max-width: 46ch;
|
|
2016
|
+
color: var(--sol-muted);
|
|
2017
|
+
}
|
|
2018
|
+
.cb-onboard__facts {
|
|
2019
|
+
list-style: none;
|
|
2020
|
+
margin: 14px 0 0;
|
|
2021
|
+
padding: 0;
|
|
2022
|
+
display: grid;
|
|
2023
|
+
gap: 8px;
|
|
2024
|
+
}
|
|
2025
|
+
.cb-onboard__fact {
|
|
2026
|
+
padding: 10px 12px;
|
|
2027
|
+
border: 1px solid var(--sol-hairline);
|
|
2028
|
+
border-radius: 8px;
|
|
2029
|
+
background: var(--sol-surface-1);
|
|
2030
|
+
color: var(--sol-muted);
|
|
2031
|
+
}
|
|
2032
|
+
.cb-onboard__foot {
|
|
2033
|
+
display: flex;
|
|
2034
|
+
align-items: center;
|
|
2035
|
+
justify-content: space-between;
|
|
2036
|
+
gap: 12px;
|
|
2037
|
+
flex-wrap: wrap;
|
|
2038
|
+
padding: 16px 18px;
|
|
2039
|
+
border-top: 1px solid var(--sol-hairline);
|
|
2040
|
+
background: var(--sol-surface-1);
|
|
2041
|
+
}
|
|
2042
|
+
.cb-onboard__ladder {
|
|
2043
|
+
display: flex;
|
|
2044
|
+
gap: 6px;
|
|
2045
|
+
}
|
|
2046
|
+
.cb-onboard__segrun {
|
|
2047
|
+
width: 18px;
|
|
2048
|
+
height: 3px;
|
|
2049
|
+
border-radius: 2px;
|
|
2050
|
+
background: var(--sol-hairline-strong);
|
|
2051
|
+
}
|
|
2052
|
+
.cb-onboard__segrun[data-active="true"] {
|
|
2053
|
+
background: var(--sol-ink);
|
|
2054
|
+
}
|
|
2055
|
+
.cb-onboard__actions {
|
|
2056
|
+
display: flex;
|
|
2057
|
+
align-items: center;
|
|
2058
|
+
gap: 8px;
|
|
2059
|
+
flex-wrap: wrap;
|
|
2060
|
+
}
|
|
2061
|
+
.cb-onboard__skip {
|
|
2062
|
+
margin-right: auto;
|
|
2063
|
+
font: inherit;
|
|
2064
|
+
font-size: 12px;
|
|
2065
|
+
color: var(--sol-faint);
|
|
2066
|
+
background: none;
|
|
2067
|
+
border: none;
|
|
2068
|
+
padding: 8px 6px;
|
|
2069
|
+
cursor: pointer;
|
|
2070
|
+
}
|
|
2071
|
+
.cb-onboard__skip:hover {
|
|
2072
|
+
color: var(--sol-ink);
|
|
2073
|
+
}
|
|
2074
|
+
.cb-onboard__skip:focus-visible {
|
|
2075
|
+
outline: 2px solid var(--color-focus);
|
|
2076
|
+
outline-offset: 2px;
|
|
2077
|
+
}
|
|
2078
|
+
.cb-onboard__actions .cb-btn {
|
|
2079
|
+
display: inline-flex;
|
|
2080
|
+
align-items: center;
|
|
2081
|
+
gap: 8px;
|
|
2082
|
+
font-size: 12px;
|
|
2083
|
+
padding: 9px 16px;
|
|
2084
|
+
}
|
|
2085
|
+
.cb-onboard__glyph {
|
|
2086
|
+
flex: none;
|
|
2087
|
+
}
|
|
2088
|
+
@media (max-width: 480px) {
|
|
2089
|
+
.cb-onboard {
|
|
2090
|
+
padding: 12px;
|
|
2091
|
+
align-items: end;
|
|
2092
|
+
}
|
|
2093
|
+
.cb-onboard__body {
|
|
2094
|
+
padding: 20px 16px 4px;
|
|
2095
|
+
}
|
|
2096
|
+
.cb-onboard__lead {
|
|
2097
|
+
font-size: 19px;
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2101
|
+
.cb-onboard__step {
|
|
2102
|
+
animation: none;
|
|
2103
|
+
}
|
|
2104
|
+
}
|
package/sol/support/support.tsx
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* nothing that asks for support ever sits beside an advertisement.
|
|
14
14
|
*/
|
|
15
15
|
import type { CSSProperties } from "react";
|
|
16
|
-
import { isconfigured, supportlinks } from "../../
|
|
16
|
+
import { isconfigured, supportlinks } from "../../monetization";
|
|
17
17
|
import { useContent } from "../../content.ts";
|
|
18
18
|
import { label } from "../../labels";
|
|
19
19
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entrygate of cobaia — the entry gate of the SITE (the flow contract of
|
|
3
|
+
* the reform the family rides: intro → onboarding → entrada): the home
|
|
4
|
+
* anchor consults this component before rendering anything. On mount it
|
|
5
|
+
* reads the first-run state from onboardingstate (the DB-backed accessor —
|
|
6
|
+
* the offline cache answers the fast path, the platform gateway reconciles
|
|
7
|
+
* in the background); while the state is still unknown it renders null (no
|
|
8
|
+
* flash of the home), when the intro is unseen it replaces the route with
|
|
9
|
+
* /intro, when only the onboarding is unseen it replaces with /onboarding,
|
|
10
|
+
* and only a visitor that finished the flow ever sees the home. The gate
|
|
11
|
+
* never writes: marking the stages belongs to the intro and the onboarding
|
|
12
|
+
* pages.
|
|
13
|
+
*/
|
|
14
|
+
import { type ReactNode, useEffect, useState } from "react";
|
|
15
|
+
import { useLocation } from "wouter";
|
|
16
|
+
import { type VisitorOnboarding, readOnboardingState } from "../../onboardingstate.ts";
|
|
17
|
+
|
|
18
|
+
/** the verdict of the gate: still resolving, or the route the visitor belongs to */
|
|
19
|
+
type GateVerdict = "resolving" | "intro" | "onboarding" | "home";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolves the gate verdict from a seen-state snapshot (the flow order:
|
|
23
|
+
* intro first, then onboarding, then the home).
|
|
24
|
+
*
|
|
25
|
+
* @param state the resolved snapshot.
|
|
26
|
+
* @returns the verdict the gate acts on.
|
|
27
|
+
*/
|
|
28
|
+
export function verdictOf(state: VisitorOnboarding): GateVerdict {
|
|
29
|
+
if (!state.introseen) return "intro";
|
|
30
|
+
if (!state.onboardingseen) return "onboarding";
|
|
31
|
+
return "home";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The entry gate wrapping the home: null while resolving, a replace-navigation
|
|
36
|
+
* to the missing first-run stage, the home content once the flow is done.
|
|
37
|
+
*
|
|
38
|
+
* @param props the home children rendered behind the gate.
|
|
39
|
+
* @returns the gated home element.
|
|
40
|
+
*/
|
|
41
|
+
export function EntryGate({ children }: { children: ReactNode }) {
|
|
42
|
+
const [, navigate] = useLocation();
|
|
43
|
+
const [verdict, setVerdict] = useState<GateVerdict>("resolving");
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
let live = true;
|
|
47
|
+
void readOnboardingState().then((state) => {
|
|
48
|
+
if (live) setVerdict((current) => (current === "home" ? current : verdictOf(state)));
|
|
49
|
+
});
|
|
50
|
+
return () => {
|
|
51
|
+
live = false;
|
|
52
|
+
};
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (verdict === "intro") navigate("/intro", { replace: true });
|
|
57
|
+
else if (verdict === "onboarding") navigate("/onboarding", { replace: true });
|
|
58
|
+
}, [navigate, verdict]);
|
|
59
|
+
|
|
60
|
+
if (verdict !== "home") return null;
|
|
61
|
+
return <>{children}</>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export default EntryGate;
|
|
@@ -13,7 +13,7 @@ import type { CSSProperties } from "react";
|
|
|
13
13
|
import { useContent } from "../../content.ts";
|
|
14
14
|
import { familyurl } from "../../familyurl";
|
|
15
15
|
import { type LabelKey, label } from "../../labels";
|
|
16
|
-
import { roster } from "../../
|
|
16
|
+
import { roster } from "../../monetization.ts";
|
|
17
17
|
|
|
18
18
|
/** the site doors of the footer row, in reading order (the labels ride the
|
|
19
19
|
* shell foot keys — the same words the chrome's footer carries). */
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import type { CSSProperties } from "react";
|
|
17
17
|
import { Link } from "wouter";
|
|
18
|
-
import { checkoutlinks, type checkoutplan, isconfigured } from "../../
|
|
18
|
+
import { checkoutlinks, type checkoutplan, isconfigured } from "../../monetization";
|
|
19
19
|
import { useContent } from "../../content.ts";
|
|
20
20
|
import { label, labelfill } from "../../labels";
|
|
21
21
|
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* automated traffic ever touches an ad surface.
|
|
16
16
|
*/
|
|
17
17
|
import type { CSSProperties } from "react";
|
|
18
|
-
import { ADS_EMPTY, ADS_LABEL, adsslotfor, adsslotlive } from "../../
|
|
18
|
+
import { ADS_EMPTY, ADS_LABEL, adsslotfor, adsslotlive } from "../../monetization.ts";
|
|
19
19
|
import { labelfill } from "../../labels";
|
|
20
20
|
|
|
21
21
|
type AdSlotProps = {
|
package/sol/wingets/index.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* lives exactly once (here) instead of repeating across every page —
|
|
6
6
|
* the components stay complete independent files beside this surface.
|
|
7
7
|
*/
|
|
8
|
-
export
|
|
8
|
+
export { type familymember, roster } from "../../monetization.ts";
|
|
9
|
+
export * from "./HomeEntrygate.tsx";
|
|
10
|
+
export { default as EntryGate } from "./HomeEntrygate.tsx";
|
|
9
11
|
export * from "./IntroFooter.tsx";
|
|
10
12
|
export { default as IntroFooter } from "./IntroFooter.tsx";
|
|
11
13
|
export * from "./IntroPlans.tsx";
|
package/tauri.conf.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// # adsconfig.test — the honest ad slot registry of the family site.
|
|
2
|
-
// Run: pnpm vitest run
|
|
2
|
+
// Run: pnpm vitest run test/adsconfig.test.ts
|
|
3
3
|
// The suite watches the promise the ad slot makes: one reserved slot per
|
|
4
4
|
// content page (home, apps, blog), every slot id shipping EMPTY (no ad
|
|
5
5
|
// network script, no id, no key is ever committed — the loader enters after
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// aspect-ratio shapes the containers reserve so the page never shifts.
|
|
8
8
|
import assert from "node:assert/strict";
|
|
9
9
|
import { describe, expect, it } from "vitest";
|
|
10
|
-
import { ADS_EMPTY, ADS_LABEL, ADSSLOTS, adsslotfor, adsslotlive } from "../
|
|
10
|
+
import { ADS_EMPTY, ADS_LABEL, ADSSLOTS, adsslotfor, adsslotlive } from "../monetization.ts";
|
|
11
11
|
|
|
12
12
|
describe("the ad slot registry", () => {
|
|
13
13
|
it("reserves one slot per content page and nothing else", () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// # checkoutlinks.test — the billing configuration of the family site.
|
|
2
|
-
// Run: pnpm vitest run
|
|
2
|
+
// Run: pnpm vitest run test/checkoutlinks.test.ts
|
|
3
3
|
// The suite watches the promise the pricing page makes: the three plans
|
|
4
4
|
// exist with the placeholder prices (US$0 / 5 / 19 a month), every checkout
|
|
5
5
|
// href ships EMPTY so the interface renders the honest empty state instead
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// the billing table cites, so a card never renders a bare key.
|
|
11
11
|
import assert from "node:assert/strict";
|
|
12
12
|
import { describe, expect, it } from "vitest";
|
|
13
|
-
import { checkoutlinks, isconfigured, planbyid, supportlinks } from "../
|
|
13
|
+
import { checkoutlinks, isconfigured, planbyid, supportlinks } from "../monetization.ts";
|
|
14
14
|
import { LABEL_MANIFEST } from "../labels.ts";
|
|
15
15
|
|
|
16
16
|
describe("checkout links", () => {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// # entryflow.test — the construction-flow contract of the cobaia: the
|
|
2
|
+
// standardized family law (intro → onboarding → entrada) the owner ordered
|
|
3
|
+
// and the db-backed content law behind it. Run: pnpm vitest run
|
|
4
|
+
// test/entryflow.test.ts
|
|
5
|
+
//
|
|
6
|
+
// The suite watches three promises:
|
|
7
|
+
// 1. the flow — the intro mounts before the onboarding, the onboarding
|
|
8
|
+
// before the gated home (the entrada), and the onboarding page marks
|
|
9
|
+
// its stage through the shared onboardingstate accessor, never a local
|
|
10
|
+
// flag of its own;
|
|
11
|
+
// 2. the db — the content_rows table exists in the runtime schema, the
|
|
12
|
+
// boot seed ingests the manifest, and the rows read back carry the
|
|
13
|
+
// kinds the pages render (posts, roster, plans — no hardcoded row
|
|
14
|
+
// lives in a face);
|
|
15
|
+
// 3. the copy — every onboarding key the frames cite answers from the
|
|
16
|
+
// label manifest (a hardcoded string inside a widget is a defect).
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { describe, expect, it } from "vitest";
|
|
20
|
+
import { LABEL_MANIFEST, type LabelKey } from "../labels.ts";
|
|
21
|
+
import { CONTENTKINDS, seedPosts, seedRoster, seedPlans } from "../content.ts";
|
|
22
|
+
|
|
23
|
+
function read(path: string): string {
|
|
24
|
+
return readFileSync(new URL(path, import.meta.url), "utf8");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SOL = read("../sol/Sol.tsx");
|
|
28
|
+
const HOME = read("../sol/home/home.tsx");
|
|
29
|
+
const INTRO = read("../sol/intro/intro.tsx");
|
|
30
|
+
const ONBOARDING = read("../sol/onboarding/onboarding.tsx");
|
|
31
|
+
const GATE = read("../sol/wingets/HomeEntrygate.tsx");
|
|
32
|
+
const STATE = read("../onboardingstate.ts");
|
|
33
|
+
const DB = read("../db.ts");
|
|
34
|
+
const CSS = read("../sol/sol.css");
|
|
35
|
+
|
|
36
|
+
describe("the entry flow contract (intro → onboarding → entrada)", () => {
|
|
37
|
+
it("mounts the flow before the fundamentals — intro first, onboarding second, the gated home third", () => {
|
|
38
|
+
expect(SOL).toMatch(/path="\/intro"/);
|
|
39
|
+
expect(SOL).toMatch(/path="\/onboarding"/);
|
|
40
|
+
expect(SOL.indexOf('"/intro"')).toBeLessThan(SOL.indexOf('"/onboarding"'));
|
|
41
|
+
expect(SOL.indexOf('"/onboarding"')).toBeLessThan(SOL.indexOf('"/"'));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("rides the entrada behind the entry gate — the home renders only through the gate", () => {
|
|
45
|
+
expect(HOME).toMatch(/EntryGate/);
|
|
46
|
+
expect(HOME).toMatch(/<HomeSurface \/>/);
|
|
47
|
+
expect(GATE).toMatch(/navigate\("\/intro"/);
|
|
48
|
+
expect(GATE).toMatch(/navigate\("\/onboarding"/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("marks the stages through the shared db-backed accessor, never a local flag", () => {
|
|
52
|
+
expect(INTRO).toMatch(/markOnboardingStage\("intro"\)/);
|
|
53
|
+
expect(ONBOARDING).toMatch(/markOnboardingStage\("onboarding"\)/);
|
|
54
|
+
expect(STATE).toMatch(/cobaia\.onboarding\.seen/);
|
|
55
|
+
expect(STATE).toMatch(/\/api\/v1\/onboarding/);
|
|
56
|
+
expect(ONBOARDING).not.toMatch(/localStorage/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("answers every onboarding key the frames cite from the label manifest", () => {
|
|
60
|
+
const keys = [
|
|
61
|
+
"onboarding.aria",
|
|
62
|
+
"onboarding.title",
|
|
63
|
+
"onboarding.skip",
|
|
64
|
+
"onboarding.back",
|
|
65
|
+
"onboarding.next",
|
|
66
|
+
"onboarding.enterCta",
|
|
67
|
+
"onboarding.steps.is.eyebrow",
|
|
68
|
+
"onboarding.steps.is.title",
|
|
69
|
+
"onboarding.steps.is.text",
|
|
70
|
+
"onboarding.steps.does.eyebrow",
|
|
71
|
+
"onboarding.steps.does.title",
|
|
72
|
+
"onboarding.steps.does.text",
|
|
73
|
+
"onboarding.steps.enter.eyebrow",
|
|
74
|
+
"onboarding.steps.enter.title",
|
|
75
|
+
"onboarding.steps.enter.text",
|
|
76
|
+
"onboarding.facts.plans",
|
|
77
|
+
"onboarding.facts.support",
|
|
78
|
+
"onboarding.facts.ads",
|
|
79
|
+
] as const satisfies readonly LabelKey[];
|
|
80
|
+
for (const key of keys) {
|
|
81
|
+
assert.ok(
|
|
82
|
+
typeof LABEL_MANIFEST[key] === "string" && LABEL_MANIFEST[key].length > 0,
|
|
83
|
+
`${key} must answer from the manifest`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("styles the frames responsively with the reduced-motion stop", () => {
|
|
89
|
+
expect(CSS).toMatch(/\.cb-onboard \{/);
|
|
90
|
+
expect(CSS).toMatch(/@media \(max-width: 480px\)/);
|
|
91
|
+
expect(CSS).toMatch(/prefers-reduced-motion: reduce/);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("the content law (the rows live in the db, the manifest is the seed)", () => {
|
|
96
|
+
it("carries the content_rows table in the runtime schema and the seed ingestion", () => {
|
|
97
|
+
expect(DB).toMatch(/CREATE TABLE IF NOT EXISTS content_rows/);
|
|
98
|
+
expect(DB).toMatch(/seedContentTable/);
|
|
99
|
+
expect(DB).toMatch(/readContentRows/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("seeds every kind the pages render — no face answers a fixed table of its own", () => {
|
|
103
|
+
assert.deepEqual([...CONTENTKINDS], [
|
|
104
|
+
"posts",
|
|
105
|
+
"roster",
|
|
106
|
+
"plans",
|
|
107
|
+
"supportlinks",
|
|
108
|
+
"ads.slots",
|
|
109
|
+
"ads.label",
|
|
110
|
+
"ads.empty",
|
|
111
|
+
]);
|
|
112
|
+
assert.ok(seedPosts.length >= 3, "the blog seed carries the family posts");
|
|
113
|
+
assert.ok(seedRoster.length === 10, "the roster seed carries the ten family members");
|
|
114
|
+
assert.ok(seedPlans.length === 3, "the plans seed carries the three honest plans");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("keeps the faces reading through the typed doors — no hardcoded row in a page", () => {
|
|
118
|
+
expect(HOME).toMatch(/from "\.\.\/\.\.\/monetization\.ts"/);
|
|
119
|
+
expect(HOME).not.toMatch(/const ROSTER/);
|
|
120
|
+
expect(read("../sol/apps/apps.tsx")).not.toMatch(/const ROSTER/);
|
|
121
|
+
expect(read("../sol/pricing/pricing.tsx")).not.toMatch(/const PLANS/);
|
|
122
|
+
expect(read("../sol/blog/blog.tsx")).not.toMatch(/const POSTS/);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// # familyurl.test — the address book contract of the family deploy units.
|
|
2
|
-
// Run: pnpm vitest run
|
|
2
|
+
// Run: pnpm vitest run test/familyurl.test.ts
|
|
3
3
|
// The suite watches the promise the directory and the footer make: every
|
|
4
4
|
// family member resolves one level above this application, always with the
|
|
5
5
|
// trailing slash, and pasted segments or stray slashes land on the same
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// # posts.test — the seed blog of the family site.
|
|
2
|
-
// Run: pnpm vitest run
|
|
2
|
+
// Run: pnpm vitest run test/posts.test.ts
|
|
3
3
|
// The suite watches the promise the blog page makes: the index is sorted
|
|
4
4
|
// newest first no matter the array order, the slugs are unique and url-safe,
|
|
5
5
|
// every seed post carries a real ~300 word body (the ad networks approve
|
|
6
6
|
// content, not containers) and the honest word counter agrees with itself.
|
|
7
7
|
import assert from "node:assert/strict";
|
|
8
8
|
import { describe, expect, it } from "vitest";
|
|
9
|
-
import { postindex, posts, postwords } from "../
|
|
9
|
+
import { postindex, posts, postwords } from "../monetization.ts";
|
|
10
10
|
|
|
11
11
|
// the content lane answers the same rows the seed carries (wave of the
|
|
12
12
|
// content law): the const retired, the accessor serves the identical data
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// modality id pins the seat for the platform's join to validate at
|
|
10
10
|
// dispatch — this module never judges engine vocabulary). runnable with
|
|
11
11
|
// vitest:
|
|
12
|
-
// pnpm vitest run
|
|
12
|
+
// pnpm vitest run test/sandboxmodality.test.ts
|
|
13
13
|
import assert from "node:assert/strict";
|
|
14
14
|
import { lanesof } from "@wenathlan/saddle/workloadlaw";
|
|
15
15
|
import { describe, it } from "vitest";
|
package/vitest.config.ts
CHANGED
|
@@ -32,7 +32,7 @@ export default defineConfig({
|
|
|
32
32
|
},
|
|
33
33
|
},
|
|
34
34
|
test: {
|
|
35
|
-
include: ["
|
|
35
|
+
include: ["test/*.test.ts"],
|
|
36
36
|
environment: "node",
|
|
37
37
|
/* the timeout stays five seconds on every native runner and scales through the env the emulated arm64 container leg sets (a qemu-interpreted second answers the same green suite many times slower), so an emulated runner never fails a test a native runner passed */
|
|
38
38
|
testTimeout: Number(process.env.COBAIA_TEST_TIMEOUT_MS ?? 5000),
|
package/adsconfig.ts
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* adsconfig.ts — the honest ad slot registry of the family site, pure data
|
|
3
|
-
* at the app root. The family site carries ONE reserved ad container per
|
|
4
|
-
* content page (home, the app directory, the blog index), always at the end
|
|
5
|
-
* of the content, always labeled "advertisement", always a fixed aspect
|
|
6
|
-
* ratio so the page never shifts when a real unit arrives.
|
|
7
|
-
*
|
|
8
|
-
* THE SCRIPT RULE: no ad network script lives in this repository. The
|
|
9
|
-
* AdSense (or equivalent) loader is injected AFTER the network approves the
|
|
10
|
-
* site, through the deploy environment only. Until then the slot ids stay
|
|
11
|
-
* empty and the container renders the honest empty note. No network call,
|
|
12
|
-
* no click encouragement, no automated traffic ever touches an ad surface —
|
|
13
|
-
* invalid traffic is the one thing that kills both the ad account and the
|
|
14
|
-
* domain reputation, so the family does not do it.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
/** one reserved ad slot: the config id and the reserved shape. */
|
|
18
|
-
export type adsslot = {
|
|
19
|
-
/** the ad network slot id — empty until the owner configures it after approval */
|
|
20
|
-
slotid: string;
|
|
21
|
-
/** the display format the reserved box mimics */
|
|
22
|
-
format: string;
|
|
23
|
-
/** the css aspect-ratio value the container reserves (never shifts layout) */
|
|
24
|
-
ratio: string;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
/** the fixed label every ad container renders, visible and honest. */
|
|
28
|
-
export const ADS_LABEL = "advertisement";
|
|
29
|
-
|
|
30
|
-
/** the honest note the container renders while no slot id is configured. */
|
|
31
|
-
export const ADS_EMPTY = "ad slot not configured yet";
|
|
32
|
-
|
|
33
|
-
/** the reserved slots per content page, keyed by the page folder name. */
|
|
34
|
-
export const ADSSLOTS: Readonly<Record<string, adsslot>> = {
|
|
35
|
-
home: { slotid: "", format: "display", ratio: "728 / 90" },
|
|
36
|
-
apps: { slotid: "", format: "in-feed", ratio: "3 / 1" },
|
|
37
|
-
blog: { slotid: "", format: "in-article", ratio: "3 / 1" },
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Reads the reserved slot of one content page.
|
|
42
|
-
*
|
|
43
|
-
* @param page the page folder name (home, apps, blog).
|
|
44
|
-
* @returns the slot, or undefined when the page carries no slot.
|
|
45
|
-
*/
|
|
46
|
-
export function adsslotfor(page: string): adsslot | undefined {
|
|
47
|
-
return ADSSLOTS[page];
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Answers whether a slot is configured with a real network id. An empty
|
|
52
|
-
* (or whitespace) slot id means the container stays an honest placeholder.
|
|
53
|
-
*
|
|
54
|
-
* @param slot the reserved slot entry.
|
|
55
|
-
* @returns true only when the slot id carries a value.
|
|
56
|
-
*/
|
|
57
|
-
export function adsslotlive(slot: adsslot | undefined): boolean {
|
|
58
|
-
return slot !== undefined && slot.slotid.trim().length > 0;
|
|
59
|
-
}
|
package/checkoutlinks.ts
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* checkoutlinks.ts — the typed accessor of the billing configuration of
|
|
3
|
-
* the family site: the thin door the pricing page, the support page and
|
|
4
|
-
* the intro plans zone import, answering the plan table and the support
|
|
5
|
-
* channels of the content lane (content.ts — the manifest; db.ts — the
|
|
6
|
-
* content_rows table; the platform gateway — GET /api/v1/ui/content). The
|
|
7
|
-
* data never lives here anymore: checkoutlinks() and supportlinks() answer
|
|
8
|
-
* the live rows the platform served, the manifest seed otherwise (the
|
|
9
|
-
* uistore chain). The payment links themselves are configuration, never
|
|
10
|
-
* code: the owner pins the checkout hrefs (Stripe Payment Links or Paddle
|
|
11
|
-
* overlays) into the store once billing goes live. Until then every href
|
|
12
|
-
* ships empty and the interface renders the honest empty state — "checkout
|
|
13
|
-
* not configured yet" — instead of pretending.
|
|
14
|
-
*
|
|
15
|
-
* The words never ride here: every plan sentence (name, billing period,
|
|
16
|
-
* blurb, features) answers from the label store (labels.ts — the manifest
|
|
17
|
-
* the seed carries into the site database), so the pricing cards and the
|
|
18
|
-
* intro plans zone read their prose through the typed accessor and this
|
|
19
|
-
* lane keeps only the billing values — ids, prices and links.
|
|
20
|
-
*
|
|
21
|
-
* No network call ever happens here: the links are plain hrefs the visitor
|
|
22
|
-
* chooses to follow, no key is committed (not even a placeholder that looks
|
|
23
|
-
* real) and no script is loaded. The prices are placeholders (US$0 / 5 / 19
|
|
24
|
-
* per month) until the billing platform decision lands.
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
import {
|
|
28
|
-
type checkoutplan,
|
|
29
|
-
contenttable,
|
|
30
|
-
contentvalue,
|
|
31
|
-
seedPlans,
|
|
32
|
-
seedSupportLinks,
|
|
33
|
-
type supportlinks as supportlinkstable,
|
|
34
|
-
} from "./content.ts";
|
|
35
|
-
|
|
36
|
-
export type { checkoutplan, supportlinkstable as supportlinks };
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* The typed accessor of the pricing table: the live db rows when the
|
|
40
|
-
* platform answered, the manifest seed otherwise — in display order (the
|
|
41
|
-
* prose answers from the label store; only the billing values ride this
|
|
42
|
-
* lane).
|
|
43
|
-
*
|
|
44
|
-
* @returns the plans of the /pricing page.
|
|
45
|
-
*/
|
|
46
|
-
export function checkoutlinks(): readonly checkoutplan[] {
|
|
47
|
-
return contenttable("plans", seedPlans);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* The typed accessor of the support channels: the live db row when the
|
|
52
|
-
* platform answered, the manifest seed otherwise — the same rule as the
|
|
53
|
-
* plans: hrefs stay empty until the owner configures them.
|
|
54
|
-
*
|
|
55
|
-
* @returns the support channels of the /support page.
|
|
56
|
-
*/
|
|
57
|
-
export function supportlinks(): Readonly<supportlinkstable> {
|
|
58
|
-
return contentvalue("supportlinks", seedSupportLinks);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Answers whether a configured link is live. An empty (or whitespace) href
|
|
63
|
-
* means the feature is not configured yet and the interface must say so.
|
|
64
|
-
*
|
|
65
|
-
* @param href the configured link href.
|
|
66
|
-
* @returns true only when the href carries a real target.
|
|
67
|
-
*/
|
|
68
|
-
export function isconfigured(href: string): boolean {
|
|
69
|
-
return href.trim().length > 0;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Finds one plan of the table by its stable id.
|
|
74
|
-
*
|
|
75
|
-
* @param id the plan id (free, pro, studio).
|
|
76
|
-
* @returns the plan, or undefined when no plan carries the id.
|
|
77
|
-
*/
|
|
78
|
-
export function planbyid(id: string): checkoutplan | undefined {
|
|
79
|
-
return checkoutlinks().find((plan) => plan.id === id);
|
|
80
|
-
}
|