@wenathlan/cobaia 2.0.165 → 2.0.166
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/Dockerfile +8 -4
- package/build.gradle.kts +3 -3
- package/bun.lock +1619 -342
- package/cobaia.csproj +1 -1
- package/cobaia.gemspec +1 -1
- package/cobaia.java +2 -2
- package/db.ts +87 -3
- package/docs/cobaia.md +31 -0
- package/docs/distributionlanes.md +1 -1
- package/labels.ts +23 -1
- package/onboardingstate.ts +211 -0
- package/package.json +423 -77
- package/pom.xml +3 -3
- package/schema.prisma +23 -0
- package/sol/Sol.tsx +20 -15
- package/sol/home/home.tsx +13 -3
- package/sol/intro/intro.tsx +13 -5
- package/sol/onboarding/onboarding.tsx +146 -0
- package/sol/sol.css +153 -0
- package/sol/wingets/HomeEntrygate.tsx +64 -0
- package/sol/wingets/index.ts +2 -0
- package/tauri.conf.json +1 -1
- package/tests/entryflow.test.ts +124 -0
package/sol/Sol.tsx
CHANGED
|
@@ -9,15 +9,15 @@
|
|
|
9
9
|
* this file follows the new name and App.tsx keeps importing the anchor by
|
|
10
10
|
* the folder path.
|
|
11
11
|
*
|
|
12
|
-
* The cobaia is a CONTENT site (the monetization surface of the family)
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* content route renders as the page body of the ONE shared shell (the
|
|
19
|
-
* and the footer); every page
|
|
20
|
-
* on the 404 anchor.
|
|
12
|
+
* The cobaia is a CONTENT site (the monetization surface of the family)
|
|
13
|
+
* riding the standardized family entry flow: the intro (/intro) is the
|
|
14
|
+
* presentation splash and the onboarding (/onboarding) the first-run
|
|
15
|
+
* frames — both mount BARE, outside the site chrome, like the sibling apps
|
|
16
|
+
* mount theirs — and the home renders behind the entry gate (the entrada:
|
|
17
|
+
* the flow decides, a finished visitor sees the content). Every other
|
|
18
|
+
* content route renders as the page body of the ONE shared shell (the
|
|
19
|
+
* header and the footer); every page stays freely reachable, and the
|
|
20
|
+
* catch-all lands on the 404 anchor.
|
|
21
21
|
*/
|
|
22
22
|
import type { ReactNode } from "react";
|
|
23
23
|
import { Route, Switch } from "wouter";
|
|
@@ -25,9 +25,11 @@ import AppsAnchor from "./apps/apps";
|
|
|
25
25
|
import BlogAnchor from "./blog/blog";
|
|
26
26
|
import HomeAnchor from "./home/home";
|
|
27
27
|
import IntroAnchor from "./intro/intro";
|
|
28
|
+
import OnboardingAnchor from "./onboarding/onboarding";
|
|
28
29
|
import LegalAnchor from "./legal/legal";
|
|
29
30
|
import NotFoundAnchor from "./notfound/notfound";
|
|
30
31
|
import PricingAnchor from "./pricing/pricing";
|
|
32
|
+
import { EntryGate } from "./wingets";
|
|
31
33
|
import { Shell } from "./shell/Shell";
|
|
32
34
|
import SupportAnchor from "./support/support";
|
|
33
35
|
|
|
@@ -46,18 +48,21 @@ function Unrouted() {
|
|
|
46
48
|
);
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
/** Mounts the theme surface through the page anchors: the
|
|
50
|
-
* (the
|
|
51
|
-
*
|
|
52
|
-
* the five public sections, and the catch-all
|
|
53
|
-
*
|
|
51
|
+
/** Mounts the theme surface through the page anchors: the entry flow first
|
|
52
|
+
* (the bare intro splash, then the bare onboarding frames — the family
|
|
53
|
+
* law), then the content pages of the site — the home gated behind the
|
|
54
|
+
* entrada on the bare root, the five public sections, and the catch-all
|
|
55
|
+
* last. */
|
|
54
56
|
export default function Sol() {
|
|
55
57
|
return (
|
|
56
58
|
<Switch>
|
|
57
59
|
<Route path="/intro" component={IntroAnchor} />
|
|
60
|
+
<Route path="/onboarding" component={OnboardingAnchor} />
|
|
58
61
|
<Route path="/">
|
|
59
62
|
<Content>
|
|
60
|
-
<
|
|
63
|
+
<EntryGate>
|
|
64
|
+
<HomeAnchor />
|
|
65
|
+
</EntryGate>
|
|
61
66
|
</Content>
|
|
62
67
|
</Route>
|
|
63
68
|
<Route path="/apps">
|
package/sol/home/home.tsx
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* three how-it-works steps on hairline seams (01 engine · 02 static · 03
|
|
12
12
|
* honest), a four-card preview of the directory leading to /apps, the
|
|
13
13
|
* support strip, and the one reserved ad slot at the end of the content.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* The home rides the entrada: the entry gate consults the first-run flow
|
|
15
|
+
* (intro → onboarding) before the landing shows — the family law every
|
|
16
|
+
* sibling app already answers.
|
|
16
17
|
* Styled entirely by sol/sol.css.
|
|
17
18
|
*/
|
|
18
19
|
import type { CSSProperties } from "react";
|
|
@@ -21,7 +22,7 @@ import { useContent } from "../../content.ts";
|
|
|
21
22
|
import { familyurl } from "../../familyurl";
|
|
22
23
|
import { type LabelKey, label, labelfill } from "../../labels";
|
|
23
24
|
import { roster } from "../../roster.ts";
|
|
24
|
-
import { AdSlot, BrandMark } from "../wingets";
|
|
25
|
+
import { AdSlot, BrandMark, EntryGate } from "../wingets";
|
|
25
26
|
|
|
26
27
|
/** the featured deploy units of the landing preview (the full directory
|
|
27
28
|
* lives on /apps): the OS, the engine, the store and one studio — read
|
|
@@ -41,6 +42,15 @@ const STEPS: readonly { n: LabelKey; title: LabelKey; text: LabelKey }[] = [
|
|
|
41
42
|
];
|
|
42
43
|
|
|
43
44
|
export function Home() {
|
|
45
|
+
return (
|
|
46
|
+
<EntryGate>
|
|
47
|
+
<HomeSurface />
|
|
48
|
+
</EntryGate>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** the home surface the gate reveals: the landing itself. */
|
|
53
|
+
function HomeSurface() {
|
|
44
54
|
useContent();
|
|
45
55
|
return (
|
|
46
56
|
<div className="cb-page">
|
package/sol/intro/intro.tsx
CHANGED
|
@@ -15,9 +15,13 @@
|
|
|
15
15
|
* zone renders the real offer from the one checkoutlinks table (the not
|
|
16
16
|
* configured state stays honest), the doors panes open the app directory
|
|
17
17
|
* and the legal page, and the footer carries the doors (the site row, the
|
|
18
|
-
* family row, the legal line). The
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* family row, the legal line). The visit marks the intro stage seen
|
|
19
|
+
* through the shared onboardingstate accessor (the family entry-flow
|
|
20
|
+
* contract: intro → onboarding → entrada — the stage flag rides the
|
|
21
|
+
* platform db with the localStorage cache as the offline mirror). The
|
|
22
|
+
* entrance rides the cb-in stagger under the second, the layers ride the
|
|
23
|
+
* surface-ladder bands, the door panes ride the silk law. Public-site
|
|
24
|
+
* rules: zero network calls from the page itself beyond the stage flag,
|
|
21
25
|
* React state only; zero decorative dots; zero invented numbers and zero
|
|
22
26
|
* fake testimonials — the real offer, read from the data modules.
|
|
23
27
|
*/
|
|
@@ -26,6 +30,7 @@ import type { CSSProperties } from "react";
|
|
|
26
30
|
import { useCallback, useEffect, useState } from "react";
|
|
27
31
|
import { Link } from "wouter";
|
|
28
32
|
import { label } from "../../labels";
|
|
33
|
+
import { markOnboardingStage } from "../../onboardingstate.ts";
|
|
29
34
|
import { BrandMark, IntroFooter, IntroPlans, IntroSplash } from "../wingets";
|
|
30
35
|
|
|
31
36
|
/** the lifecycle of the opening act: resolving (the motion preference
|
|
@@ -59,9 +64,12 @@ function DoorGlyph() {
|
|
|
59
64
|
export function Intro() {
|
|
60
65
|
const [splash, setSplash] = useState<SplashPhase>("resolving");
|
|
61
66
|
|
|
62
|
-
// the splash decision rides the motion preference
|
|
63
|
-
//
|
|
67
|
+
// the splash decision rides the motion preference, and the visit marks
|
|
68
|
+
// the intro stage seen through the shared accessor (the family flow
|
|
69
|
+
// contract: the db holds the flag, the cache mirrors it offline — the
|
|
70
|
+
// splash never replays for a visitor that finished the flow)
|
|
64
71
|
useEffect(() => {
|
|
72
|
+
void markOnboardingStage("intro");
|
|
65
73
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
66
74
|
setSplash("done");
|
|
67
75
|
return;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* onboarding.tsx — the first-run steps of the ENTRY FLOW (the flow contract
|
|
3
|
+
* of the reform the family rides: intro → onboarding → entrada). Three
|
|
4
|
+
* short frames on the site voice over the deep canvas — what the site IS
|
|
5
|
+
* (the honest role: the family's monetization face, kept apart so the apps
|
|
6
|
+
* stay clean), what it DOES (the real offer read from the one content
|
|
7
|
+
* lane: the plans, the support rails, the honest ad slots) and the ENTER
|
|
8
|
+
* frame with the hand-over button. The finish AND the skip mark the
|
|
9
|
+
* onboarding stage seen through the shared onboardingstate accessor
|
|
10
|
+
* (DB-backed, cache-mirrored — seeing once is enough; the flow never
|
|
11
|
+
* replays) and navigate to "/", where the entry gate now lets the site
|
|
12
|
+
* take over. The copy rides the label store; no icon dependency (the site
|
|
13
|
+
* grammar draws its own glyphs); reduced motion rides the stylesheet hard
|
|
14
|
+
* stop. No opt-in switches here: the site holds no account and no visitor
|
|
15
|
+
* storage beyond the first-run stage itself.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useState } from "react";
|
|
19
|
+
import { useLocation } from "wouter";
|
|
20
|
+
import { type LabelKey, label } from "../../labels";
|
|
21
|
+
import { markOnboardingStage } from "../../onboardingstate.ts";
|
|
22
|
+
import { BrandMark } from "../wingets";
|
|
23
|
+
|
|
24
|
+
/** the three frames of the first run, in order — the copy rides the store
|
|
25
|
+
* (the id keeps the ladder segments keyed, the key answers the eyebrow). */
|
|
26
|
+
const STEPS: readonly { id: string; eyebrowKey: LabelKey }[] = [
|
|
27
|
+
{ id: "is", eyebrowKey: "onboarding.steps.is.eyebrow" },
|
|
28
|
+
{ id: "does", eyebrowKey: "onboarding.steps.does.eyebrow" },
|
|
29
|
+
{ id: "enter", eyebrowKey: "onboarding.steps.enter.eyebrow" },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/** the does-frame rows: the real offer the site carries, one label each
|
|
33
|
+
* (the plans, the support rails, the honest ad slots — nothing invented). */
|
|
34
|
+
const FACTS: readonly LabelKey[] = ["onboarding.facts.plans", "onboarding.facts.support", "onboarding.facts.ads"];
|
|
35
|
+
|
|
36
|
+
/** the chevron glyph of the pager actions: the inline 10×10 chevron the
|
|
37
|
+
* site grammar draws (no icon dependency rides the content site). */
|
|
38
|
+
function StepGlyph({ back }: { back?: boolean }) {
|
|
39
|
+
return (
|
|
40
|
+
<svg
|
|
41
|
+
className="cb-onboard__glyph"
|
|
42
|
+
width={10}
|
|
43
|
+
height={10}
|
|
44
|
+
viewBox="0 0 10 10"
|
|
45
|
+
aria-hidden="true"
|
|
46
|
+
focusable="false"
|
|
47
|
+
style={back ? { transform: "rotate(180deg)" } : undefined}
|
|
48
|
+
>
|
|
49
|
+
<path d="M3 0l5 5-5 5" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
|
50
|
+
</svg>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The onboarding page: a three-step first-run dialog that hands the
|
|
56
|
+
* surface to the home of the site. Finish and skip both mark the
|
|
57
|
+
* onboarding stage seen and navigate to the gated home.
|
|
58
|
+
*
|
|
59
|
+
* @returns the onboarding element.
|
|
60
|
+
*/
|
|
61
|
+
export default function Onboarding() {
|
|
62
|
+
const [, navigate] = useLocation();
|
|
63
|
+
const [step, setStep] = useState(0);
|
|
64
|
+
const last = step === STEPS.length - 1;
|
|
65
|
+
|
|
66
|
+
/** marks the stage seen and hands the surface to the home (the entry gate lets it through). */
|
|
67
|
+
const finish = (): void => {
|
|
68
|
+
void markOnboardingStage("onboarding").then(() => navigate("/"));
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="cb-onboard" data-page="onboarding">
|
|
73
|
+
<section className="cb-onboard__card" aria-label={label("onboarding.aria")}>
|
|
74
|
+
<header className="cb-onboard__head">
|
|
75
|
+
<BrandMark size={18} />
|
|
76
|
+
<span className="cb-onboard__title">{label("onboarding.title")}</span>
|
|
77
|
+
</header>
|
|
78
|
+
|
|
79
|
+
<div className="cb-onboard__body">
|
|
80
|
+
{step === 0 && (
|
|
81
|
+
<article className="cb-onboard__step" key="is">
|
|
82
|
+
<p className="cb-eyebrow">{label(STEPS[0].eyebrowKey)}</p>
|
|
83
|
+
<h2 className="cb-onboard__lead">{label("onboarding.steps.is.title")}</h2>
|
|
84
|
+
<p className="cb-onboard__lede">{label("onboarding.steps.is.text")}</p>
|
|
85
|
+
</article>
|
|
86
|
+
)}
|
|
87
|
+
{step === 1 && (
|
|
88
|
+
<article className="cb-onboard__step" key="does">
|
|
89
|
+
<p className="cb-eyebrow">{label(STEPS[1].eyebrowKey)}</p>
|
|
90
|
+
<h2 className="cb-onboard__lead">{label("onboarding.steps.does.title")}</h2>
|
|
91
|
+
<p className="cb-onboard__lede">{label("onboarding.steps.does.text")}</p>
|
|
92
|
+
<ul className="cb-onboard__facts">
|
|
93
|
+
{FACTS.map((key) => (
|
|
94
|
+
<li key={key} className="cb-onboard__fact">
|
|
95
|
+
{label(key)}
|
|
96
|
+
</li>
|
|
97
|
+
))}
|
|
98
|
+
</ul>
|
|
99
|
+
</article>
|
|
100
|
+
)}
|
|
101
|
+
{step === 2 && (
|
|
102
|
+
<article className="cb-onboard__step" key="enter">
|
|
103
|
+
<p className="cb-eyebrow">{label(STEPS[2].eyebrowKey)}</p>
|
|
104
|
+
<h2 className="cb-onboard__lead">{label("onboarding.steps.enter.title")}</h2>
|
|
105
|
+
<p className="cb-onboard__lede">{label("onboarding.steps.enter.text")}</p>
|
|
106
|
+
</article>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<footer className="cb-onboard__foot">
|
|
111
|
+
<div className="cb-onboard__ladder" aria-hidden="true">
|
|
112
|
+
{STEPS.map((frame) => (
|
|
113
|
+
<span
|
|
114
|
+
key={frame.id}
|
|
115
|
+
className="cb-onboard__segrun"
|
|
116
|
+
data-active={STEPS[step].id === frame.id ? "true" : undefined}
|
|
117
|
+
/>
|
|
118
|
+
))}
|
|
119
|
+
</div>
|
|
120
|
+
<div className="cb-onboard__actions">
|
|
121
|
+
<button type="button" className="cb-onboard__skip" onClick={finish}>
|
|
122
|
+
{label("onboarding.skip")}
|
|
123
|
+
</button>
|
|
124
|
+
{step > 0 && (
|
|
125
|
+
<button type="button" className="cb-btn cb-btn--ghost" onClick={() => setStep((value) => value - 1)}>
|
|
126
|
+
<StepGlyph back />
|
|
127
|
+
{label("onboarding.back")}
|
|
128
|
+
</button>
|
|
129
|
+
)}
|
|
130
|
+
{last ? (
|
|
131
|
+
<button type="button" className="cb-btn" onClick={finish}>
|
|
132
|
+
{label("onboarding.enterCta")}
|
|
133
|
+
<StepGlyph />
|
|
134
|
+
</button>
|
|
135
|
+
) : (
|
|
136
|
+
<button type="button" className="cb-btn" onClick={() => setStep((value) => value + 1)}>
|
|
137
|
+
{label("onboarding.next")}
|
|
138
|
+
<StepGlyph />
|
|
139
|
+
</button>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
</footer>
|
|
143
|
+
</section>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
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
|
+
}
|
|
@@ -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;
|
package/sol/wingets/index.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* the components stay complete independent files beside this surface.
|
|
7
7
|
*/
|
|
8
8
|
export * from "../../roster.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
|
@@ -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
|
+
// tests/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 "\.\.\/\.\.\/roster\.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
|
+
});
|