@wenathlan/cobaia 0.0.0-stage → 2.0.155
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 +45 -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 +1944 -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/_headers +4 -0
- package/_redirects +1 -0
- package/adsconfig.ts +59 -0
- package/biome.jsonc +134 -0
- package/bun.lock +2331 -0
- package/capacitor.config.ts +51 -0
- package/checkoutlinks.ts +80 -0
- package/content.ts +425 -0
- package/db.ts +219 -0
- package/devthink.toml +5 -0
- package/docs/.gitkeep +0 -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 +200 -4
- package/pnpm-workspace.yaml +82 -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/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
package/App.tsx
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App — the global anchor (layer 1 of the anchor architecture) and the only
|
|
3
|
+
* TSX outside the cobaia theme. cobaia is the family site: the front door
|
|
4
|
+
* of the DevThink family — the pricing, the support page, the app directory
|
|
5
|
+
* and the update blog for the ten deployable applications. The site is a
|
|
6
|
+
* plain CONTENT surface: no onboarding, no entry gate — the home renders
|
|
7
|
+
* directly and every page is public; the intro (/intro) is the one bare
|
|
8
|
+
* presentation page the family grammar gives every app, mounted full-bleed
|
|
9
|
+
* under the family fullscreen law. The root manages the theme as a single
|
|
10
|
+
* file: it imports only the theme anchor (Sol/Sol.tsx — the file named
|
|
11
|
+
* after the theme folder, beside Sol/sol.css), the theme stylesheet and the
|
|
12
|
+
* root logics, wraps the anchor in the wouter router (the subpath the pages
|
|
13
|
+
* deploy owns answers through the declared base, the same mount grammar
|
|
14
|
+
* every sibling carries) and renders the theme into the root element —
|
|
15
|
+
* the site never booted without this mount (the blank cobaia defect).
|
|
16
|
+
* Every page and component lives behind the anchor chain (App → Sol → the
|
|
17
|
+
* page anchors → the loose components); nothing is imported directly.
|
|
18
|
+
*/
|
|
19
|
+
import { createRoot } from "react-dom/client";
|
|
20
|
+
import { Router as WouterRouter } from "wouter";
|
|
21
|
+
import Sol from "./Sol/Sol";
|
|
22
|
+
import "@fontsource-variable/geist-mono";
|
|
23
|
+
import "@fontsource-variable/inter";
|
|
24
|
+
import "./Sol/sol.css";
|
|
25
|
+
import { seedLabels } from "./labels";
|
|
26
|
+
|
|
27
|
+
// the copy law rides the boot: the seed refreshes the label store rows from
|
|
28
|
+
// the manifest (the browser cache mirrors the site db rows) before the theme
|
|
29
|
+
// renders — best effort, the manifest always answers the wingets.
|
|
30
|
+
if (typeof window !== "undefined") void seedLabels().catch(() => {});
|
|
31
|
+
|
|
32
|
+
function routerBase(): string {
|
|
33
|
+
// the base rides the BUILD declaration only (the sibling grammar): the
|
|
34
|
+
// pages subpath owns /devthink/cobaia/, and that declaration arrives
|
|
35
|
+
// through the vite base — deriving it from the runtime path made every
|
|
36
|
+
// deep link look like a deployment subfolder
|
|
37
|
+
const declaredBase = import.meta.env.BASE_URL;
|
|
38
|
+
if (declaredBase !== "/" && declaredBase !== "./") return declaredBase.replace(/\/$/, "");
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function App() {
|
|
43
|
+
return (
|
|
44
|
+
<WouterRouter base={routerBase()}>
|
|
45
|
+
<Sol />
|
|
46
|
+
</WouterRouter>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const root = document.getElementById("root");
|
|
51
|
+
if (!root) throw new Error("cobaia root element is missing.");
|
|
52
|
+
createRoot(root).render(<App />);
|
package/Dockerfile
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# cobaia 2.0.134 — THE SITE CONTAINER FILE
|
|
2
|
+
#
|
|
3
|
+
# The family container doctrine in the sol shape: one versioned static
|
|
4
|
+
# container per site. The sol theme builds inside the container — stage one
|
|
5
|
+
# installs the locked pnpm tree and runs the vite build, stage two serves
|
|
6
|
+
# the built theme through the unprivileged nginx image. The version rides
|
|
7
|
+
# the ARG the metadata lockstep stamps and the OCI labels declare — the
|
|
8
|
+
# registry lanes pass it at build time, nothing is hardcoded beside the
|
|
9
|
+
# stamp.
|
|
10
|
+
|
|
11
|
+
FROM node:26-alpine@sha256:40cd011b55415bc894e45b02fdd5a5dfde056921b7d23a64c74f80497af48797 AS solbuild
|
|
12
|
+
|
|
13
|
+
ARG COBAIA_VERSION=2.0.134
|
|
14
|
+
|
|
15
|
+
WORKDIR /src
|
|
16
|
+
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
|
17
|
+
RUN apk add --no-cache python3 make g++ \
|
|
18
|
+
&& npm install -g --no-fund --no-audit pnpm@12.3.4 \
|
|
19
|
+
&& pnpm install --frozen-lockfile
|
|
20
|
+
COPY . .
|
|
21
|
+
RUN pnpm build
|
|
22
|
+
|
|
23
|
+
FROM nginxinc/nginx-unprivileged:1.29-alpine@sha256:0c79d56aee561a1d81c63f00eee5fb5fe29279560cdc55e91425133104c7fbe6
|
|
24
|
+
|
|
25
|
+
ARG COBAIA_VERSION=2.0.134
|
|
26
|
+
|
|
27
|
+
LABEL org.opencontainers.image.title="cobaia" \
|
|
28
|
+
org.opencontainers.image.description="cobaia — the monetization surface: the ads, the plans and the conversion, kept apart from the main sites." \
|
|
29
|
+
org.opencontainers.image.version="${COBAIA_VERSION}" \
|
|
30
|
+
org.opencontainers.image.licenses="GPL-3.0-only" \
|
|
31
|
+
org.opencontainers.image.source="https://github.com/wenathlan/devthink" \
|
|
32
|
+
org.opencontainers.image.documentation="https://github.com/wenathlan/devthink/tree/main/cobaia"
|
|
33
|
+
|
|
34
|
+
COPY --from=solbuild /src/ /usr/share/nginx/html/
|
|
35
|
+
|
|
36
|
+
# the explicit non-root runtime user (uid 101 is the nginx-unprivileged
|
|
37
|
+
# default): the scan gate refuses an image without a pinned unprivileged
|
|
38
|
+
# user, so the declaration is spelled out instead of inherited.
|
|
39
|
+
USER 101
|
|
40
|
+
|
|
41
|
+
# the unprivileged image answers 8080; the healthcheck probes the served
|
|
42
|
+
# root the same way the pages lane does.
|
|
43
|
+
EXPOSE 8080
|
|
44
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
|
45
|
+
CMD ["wget", "-qO-", "http://127.0.0.1:8080/"]
|
package/Sol/Sol.tsx
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sol theme anchor — layer 2 of the anchor architecture.
|
|
3
|
+
* The file carrying the theme folder's own name (Sol/Sol.tsx, beside
|
|
4
|
+
* Sol/sol.css) is the path manager of the pages: it imports one anchor per
|
|
5
|
+
* page folder (`folder/folder.tsx`, each beside the loose components its
|
|
6
|
+
* folder keeps) and mounts every route of the theme. App.tsx consumes only
|
|
7
|
+
* this file and the theme stylesheet; no page component is ever imported
|
|
8
|
+
* outside this layer. When a theme folder changes its name (Moon, Aqua),
|
|
9
|
+
* this file follows the new name and App.tsx keeps importing the anchor by
|
|
10
|
+
* the folder path.
|
|
11
|
+
*
|
|
12
|
+
* The cobaia is a CONTENT site (the monetization surface of the family): no
|
|
13
|
+
* onboarding, no entry gate — every content route is a page of the site, and
|
|
14
|
+
* the home renders directly on the bare root. The intro (/intro) is the one
|
|
15
|
+
* public presentation page of the family grammar (every app carries one) and
|
|
16
|
+
* mounts BARE — outside the window chrome, like the sibling sites mount
|
|
17
|
+
* theirs; the shell's close-caption door relaunches the site at it. Every
|
|
18
|
+
* content route renders as the page body of the ONE shared shell (the header
|
|
19
|
+
* and the footer); every page is freely reachable, and the catch-all lands
|
|
20
|
+
* on the 404 anchor.
|
|
21
|
+
*/
|
|
22
|
+
import type { ReactNode } from "react";
|
|
23
|
+
import { Route, Switch } from "wouter";
|
|
24
|
+
import AppsAnchor from "./apps/apps";
|
|
25
|
+
import BlogAnchor from "./blog/blog";
|
|
26
|
+
import HomeAnchor from "./home/home";
|
|
27
|
+
import IntroAnchor from "./intro/intro";
|
|
28
|
+
import LegalAnchor from "./legal/legal";
|
|
29
|
+
import NotFoundAnchor from "./notfound/notfound";
|
|
30
|
+
import PricingAnchor from "./pricing/pricing";
|
|
31
|
+
import { Shell } from "./shell/Shell";
|
|
32
|
+
import SupportAnchor from "./support/support";
|
|
33
|
+
|
|
34
|
+
/** the content pages: the site chrome around one page anchor */
|
|
35
|
+
function Content({ children }: { children: ReactNode }) {
|
|
36
|
+
return <Shell>{children}</Shell>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** the unrouted addresses: the 404 answer of the cobaia, inside the site
|
|
40
|
+
* chrome (the theme catch-all) */
|
|
41
|
+
function Unrouted() {
|
|
42
|
+
return (
|
|
43
|
+
<Shell>
|
|
44
|
+
<NotFoundAnchor />
|
|
45
|
+
</Shell>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Mounts the theme surface through the page anchors: the bare intro first
|
|
50
|
+
* (the public presentation, outside the chrome, like the siblings mount
|
|
51
|
+
* theirs), then the content pages of the site — the home on the bare root,
|
|
52
|
+
* the five public sections, and the catch-all last. No entry flow: the site
|
|
53
|
+
* is content end to end. */
|
|
54
|
+
export default function Sol() {
|
|
55
|
+
return (
|
|
56
|
+
<Switch>
|
|
57
|
+
<Route path="/intro" component={IntroAnchor} />
|
|
58
|
+
<Route path="/">
|
|
59
|
+
<Content>
|
|
60
|
+
<HomeAnchor />
|
|
61
|
+
</Content>
|
|
62
|
+
</Route>
|
|
63
|
+
<Route path="/apps">
|
|
64
|
+
<Content>
|
|
65
|
+
<AppsAnchor />
|
|
66
|
+
</Content>
|
|
67
|
+
</Route>
|
|
68
|
+
<Route path="/pricing">
|
|
69
|
+
<Content>
|
|
70
|
+
<PricingAnchor />
|
|
71
|
+
</Content>
|
|
72
|
+
</Route>
|
|
73
|
+
<Route path="/support">
|
|
74
|
+
<Content>
|
|
75
|
+
<SupportAnchor />
|
|
76
|
+
</Content>
|
|
77
|
+
</Route>
|
|
78
|
+
<Route path="/blog">
|
|
79
|
+
<Content>
|
|
80
|
+
<BlogAnchor />
|
|
81
|
+
</Content>
|
|
82
|
+
</Route>
|
|
83
|
+
<Route path="/legal">
|
|
84
|
+
<Content>
|
|
85
|
+
<LegalAnchor />
|
|
86
|
+
</Content>
|
|
87
|
+
</Route>
|
|
88
|
+
<Route component={Unrouted} />
|
|
89
|
+
</Switch>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* apps 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 (the roster data), mounts the
|
|
5
|
+
* page and re-exports the public component surface. Only the theme anchor
|
|
6
|
+
* (Sol/Sol.tsx) consumes this file.
|
|
7
|
+
*
|
|
8
|
+
* The app directory of the cobaia content site: the ten deploy units of the
|
|
9
|
+
* family, each card carrying the honest one-line role and the deep link
|
|
10
|
+
* through the familyurl contract (../slug/ — the family redirects both
|
|
11
|
+
* ways). The accents come from the spec §12 table mirrored in the roster;
|
|
12
|
+
* the roles are the ones each app declares in its own devthink.toml. The
|
|
13
|
+
* in-feed ad slot closes the feed at the end of the content.
|
|
14
|
+
*/
|
|
15
|
+
import type { CSSProperties } from "react";
|
|
16
|
+
import { useContent } from "../../content.ts";
|
|
17
|
+
import { familyurl } from "../../familyurl";
|
|
18
|
+
import { label, labelfill } from "../../labels";
|
|
19
|
+
import { roster } from "../../roster.ts";
|
|
20
|
+
import { AdSlot } from "../wingets";
|
|
21
|
+
|
|
22
|
+
export function Apps() {
|
|
23
|
+
useContent();
|
|
24
|
+
return (
|
|
25
|
+
<div className="cb-page">
|
|
26
|
+
<section className="cb-hero" aria-label={label("apps.aria.hero")}>
|
|
27
|
+
<p className="cb-eyebrow cb-in" style={{ "--cb-d": 0 } as CSSProperties}>
|
|
28
|
+
{label("apps.eyebrow")}
|
|
29
|
+
</p>
|
|
30
|
+
<h1 className="cb-title cb-in" style={{ "--cb-d": 1 } as CSSProperties}>
|
|
31
|
+
{label("apps.title")}
|
|
32
|
+
</h1>
|
|
33
|
+
<p className="cb-lede cb-in" style={{ "--cb-d": 2 } as CSSProperties}>
|
|
34
|
+
{label("apps.lede")}
|
|
35
|
+
</p>
|
|
36
|
+
</section>
|
|
37
|
+
|
|
38
|
+
<section className="cb-section" aria-label={label("apps.section.rosterAria")}>
|
|
39
|
+
<div className="cb-appgrid">
|
|
40
|
+
{roster().map((member, index) => (
|
|
41
|
+
<a
|
|
42
|
+
key={member.slug}
|
|
43
|
+
className="cb-appcard cb-in"
|
|
44
|
+
href={familyurl(member.slug)}
|
|
45
|
+
target="_blank"
|
|
46
|
+
rel="noreferrer"
|
|
47
|
+
style={{ "--cb-dot": member.accent, "--cb-d": Math.min(index + 1, 6) } as CSSProperties}
|
|
48
|
+
>
|
|
49
|
+
<span className="cb-apphead">
|
|
50
|
+
<span className="cb-appdot" aria-hidden="true" />
|
|
51
|
+
<span className="cb-appname">{member.slug}</span>
|
|
52
|
+
</span>
|
|
53
|
+
<span className="cb-approle">{member.role}</span>
|
|
54
|
+
<span className="cb-appopen">{labelfill("apps.card.open", { slug: member.slug })}</span>
|
|
55
|
+
</a>
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
</section>
|
|
59
|
+
|
|
60
|
+
{/* the one reserved ad slot of the page: after the feed it belongs to */}
|
|
61
|
+
<AdSlot page="apps" />
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export default Apps;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* blog 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 update blog of the cobaia content site: the sorted index (the pure
|
|
9
|
+
* posts module at the app root owns the posts and the ordering) and the full
|
|
10
|
+
* bodies rendered below as real articles — the in-article surface of the
|
|
11
|
+
* site. Every post title wears the trylle display type (the clamp, the
|
|
12
|
+
* -0.02em track) so the articles open like the site hero does. The tree
|
|
13
|
+
* rule keeps this one page (no post subfolders), so every post anchors in
|
|
14
|
+
* place and the index links jump to the article.
|
|
15
|
+
*/
|
|
16
|
+
import type { CSSProperties } from "react";
|
|
17
|
+
import { useContent } from "../../content.ts";
|
|
18
|
+
import { label, labelfill } from "../../labels";
|
|
19
|
+
import { type blogpost, postindex, postwords } from "../../posts";
|
|
20
|
+
import { AdSlot } from "../wingets";
|
|
21
|
+
|
|
22
|
+
/** the meta line of one post: the date and the honest word count. */
|
|
23
|
+
function PostMeta({ post }: { post: blogpost }) {
|
|
24
|
+
return (
|
|
25
|
+
<p className="cb-postdate">
|
|
26
|
+
{labelfill("blog.post.meta", {
|
|
27
|
+
date: post.date,
|
|
28
|
+
words: String(postwords(post)),
|
|
29
|
+
tags: post.tags.join(" / "),
|
|
30
|
+
})}
|
|
31
|
+
</p>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function Blog() {
|
|
36
|
+
useContent();
|
|
37
|
+
const posts = postindex();
|
|
38
|
+
return (
|
|
39
|
+
<div className="cb-page">
|
|
40
|
+
<section className="cb-hero" aria-label={label("blog.aria.hero")}>
|
|
41
|
+
<p className="cb-eyebrow cb-in" style={{ "--cb-d": 0 } as CSSProperties}>
|
|
42
|
+
{label("blog.eyebrow")}
|
|
43
|
+
</p>
|
|
44
|
+
<h1 className="cb-title cb-in" style={{ "--cb-d": 1 } as CSSProperties}>
|
|
45
|
+
{label("blog.title")}
|
|
46
|
+
</h1>
|
|
47
|
+
<p className="cb-lede cb-in" style={{ "--cb-d": 2 } as CSSProperties}>
|
|
48
|
+
{label("blog.lede")}
|
|
49
|
+
</p>
|
|
50
|
+
</section>
|
|
51
|
+
|
|
52
|
+
<section className="cb-section" aria-label={label("blog.section.indexAria")}>
|
|
53
|
+
<ul className="cb-postlist">
|
|
54
|
+
{posts.map((post, index) => (
|
|
55
|
+
<li key={post.slug} className="cb-postrow cb-in" style={{ "--cb-d": index + 1 } as CSSProperties}>
|
|
56
|
+
<PostMeta post={post} />
|
|
57
|
+
<a className="cb-postlink" href={`#${post.slug}`}>
|
|
58
|
+
{post.title}
|
|
59
|
+
</a>
|
|
60
|
+
<p className="cb-postexcerpt">{post.excerpt}</p>
|
|
61
|
+
</li>
|
|
62
|
+
))}
|
|
63
|
+
</ul>
|
|
64
|
+
</section>
|
|
65
|
+
|
|
66
|
+
<section className="cb-section" aria-label={label("blog.section.fullAria")}>
|
|
67
|
+
{posts.map((post) => (
|
|
68
|
+
<article key={post.slug} id={post.slug} className="cb-article" aria-label={post.title}>
|
|
69
|
+
<h2 className="cb-posttitle">{post.title}</h2>
|
|
70
|
+
<PostMeta post={post} />
|
|
71
|
+
<div className="cb-articlebody">
|
|
72
|
+
{post.body.map((paragraph) => (
|
|
73
|
+
<p key={paragraph}>{paragraph}</p>
|
|
74
|
+
))}
|
|
75
|
+
</div>
|
|
76
|
+
</article>
|
|
77
|
+
))}
|
|
78
|
+
</section>
|
|
79
|
+
|
|
80
|
+
{/* the one reserved ad slot of the page, after the articles */}
|
|
81
|
+
<AdSlot page="blog" />
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export default Blog;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* home 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 landing of the cobaia content site: what the devthink family is, said
|
|
9
|
+
* directly — ten deployable applications on one engine, shipped as static
|
|
10
|
+
* sites, tracked nowhere. Left-aligned hero (no centered hero + pill), the
|
|
11
|
+
* three how-it-works steps on hairline seams (01 engine · 02 static · 03
|
|
12
|
+
* honest), a four-card preview of the directory leading to /apps, the
|
|
13
|
+
* support strip, and the one reserved ad slot at the end of the content.
|
|
14
|
+
* No entry gate and no first-run flow: cobaia is a content site — the home
|
|
15
|
+
* renders directly, every page is public.
|
|
16
|
+
* Styled entirely by Sol/sol.css.
|
|
17
|
+
*/
|
|
18
|
+
import type { CSSProperties } from "react";
|
|
19
|
+
import { Link } from "wouter";
|
|
20
|
+
import { useContent } from "../../content.ts";
|
|
21
|
+
import { familyurl } from "../../familyurl";
|
|
22
|
+
import { type LabelKey, label, labelfill } from "../../labels";
|
|
23
|
+
import { roster } from "../../roster.ts";
|
|
24
|
+
import { AdSlot, BrandMark } from "../wingets";
|
|
25
|
+
|
|
26
|
+
/** the featured deploy units of the landing preview (the full directory
|
|
27
|
+
* lives on /apps): the OS, the engine, the store and one studio — read
|
|
28
|
+
* from the one roster table so the preview can never drift from /apps. */
|
|
29
|
+
const FEATUREDSLUGS: readonly string[] = ["devthink", "saddle", "vault", "cadria"];
|
|
30
|
+
/** the featured rows answer the accessor at render time (the content
|
|
31
|
+
* lane law): the seed answers the first paint and the useContent mount
|
|
32
|
+
* refreshes the derivation when the live rows land. */
|
|
33
|
+
const featured = () => roster().filter((member) => FEATUREDSLUGS.includes(member.slug));
|
|
34
|
+
|
|
35
|
+
/** the three steps of the landing: what the family is made of — the text
|
|
36
|
+
* rides the label accessor (the store keeps the sentence). */
|
|
37
|
+
const STEPS: readonly { n: LabelKey; title: LabelKey; text: LabelKey }[] = [
|
|
38
|
+
{ n: "home.steps.engine.n", title: "home.steps.engine.title", text: "home.steps.engine.text" },
|
|
39
|
+
{ n: "home.steps.static.n", title: "home.steps.static.title", text: "home.steps.static.text" },
|
|
40
|
+
{ n: "home.steps.honest.n", title: "home.steps.honest.title", text: "home.steps.honest.text" },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export function Home() {
|
|
44
|
+
useContent();
|
|
45
|
+
return (
|
|
46
|
+
<div className="cb-page">
|
|
47
|
+
{/* the hero: the family said plainly */}
|
|
48
|
+
<section className="cb-hero" aria-label={label("home.aria.hero")}>
|
|
49
|
+
<div className="cb-in" style={{ "--cb-d": 0 } as CSSProperties}>
|
|
50
|
+
<BrandMark size={52} />
|
|
51
|
+
</div>
|
|
52
|
+
<p className="cb-eyebrow cb-in" style={{ "--cb-d": 1 } as CSSProperties}>
|
|
53
|
+
{label("home.eyebrow")}
|
|
54
|
+
</p>
|
|
55
|
+
<h1 className="cb-title cb-in" style={{ "--cb-d": 2 } as CSSProperties}>
|
|
56
|
+
{label("home.title")}
|
|
57
|
+
</h1>
|
|
58
|
+
<p className="cb-lede cb-in" style={{ "--cb-d": 3 } as CSSProperties}>
|
|
59
|
+
{label("home.lede")}
|
|
60
|
+
</p>
|
|
61
|
+
<div className="cb-btnrow cb-in" style={{ "--cb-d": 4 } as CSSProperties}>
|
|
62
|
+
<Link className="cb-btn" href="/pricing">
|
|
63
|
+
{label("home.action.pricing")}
|
|
64
|
+
</Link>
|
|
65
|
+
<Link className="cb-btn cb-btn--ghost" href="/apps">
|
|
66
|
+
{label("home.action.apps")}
|
|
67
|
+
</Link>
|
|
68
|
+
</div>
|
|
69
|
+
</section>
|
|
70
|
+
|
|
71
|
+
{/* the three steps, on hairline seams */}
|
|
72
|
+
<section className="cb-section" aria-label={label("home.section.stepsAria")}>
|
|
73
|
+
<div className="cb-secthead cb-in" style={{ "--cb-d": 0 } as CSSProperties}>
|
|
74
|
+
<h2 className="cb-secttitle">{label("home.section.stepsTitle")}</h2>
|
|
75
|
+
<p className="cb-sectlede">{label("home.section.stepsLede")}</p>
|
|
76
|
+
</div>
|
|
77
|
+
<div className="cb-grid cb-grid--3">
|
|
78
|
+
{STEPS.map((step, index) => (
|
|
79
|
+
<div key={step.n} className="cb-card cb-in" style={{ "--cb-d": index + 1 } as CSSProperties}>
|
|
80
|
+
<p className="cb-cardmeta">{label(step.n)}</p>
|
|
81
|
+
<h3 className="cb-cardtitle">{label(step.title)}</h3>
|
|
82
|
+
<p className="cb-cardtext">{label(step.text)}</p>
|
|
83
|
+
</div>
|
|
84
|
+
))}
|
|
85
|
+
</div>
|
|
86
|
+
</section>
|
|
87
|
+
|
|
88
|
+
{/* the directory preview: four of the ten, the rest on /apps */}
|
|
89
|
+
<section className="cb-section" aria-label={label("home.section.previewAria")}>
|
|
90
|
+
<div className="cb-secthead cb-in" style={{ "--cb-d": 0 } as CSSProperties}>
|
|
91
|
+
<h2 className="cb-secttitle">{label("home.section.previewTitle")}</h2>
|
|
92
|
+
<p className="cb-sectlede">
|
|
93
|
+
{label("home.section.previewLede")}{" "}
|
|
94
|
+
<Link className="cb-link" href="/apps">
|
|
95
|
+
{label("home.section.previewLink")}
|
|
96
|
+
</Link>
|
|
97
|
+
{label("home.section.previewTail")}
|
|
98
|
+
</p>
|
|
99
|
+
</div>
|
|
100
|
+
<div className="cb-appgrid">
|
|
101
|
+
{featured().map((app, index) => (
|
|
102
|
+
<a
|
|
103
|
+
key={app.slug}
|
|
104
|
+
className="cb-appcard cb-in"
|
|
105
|
+
href={familyurl(app.slug)}
|
|
106
|
+
target="_blank"
|
|
107
|
+
rel="noreferrer"
|
|
108
|
+
style={{ "--cb-dot": app.accent, "--cb-d": index + 1 } as CSSProperties}
|
|
109
|
+
>
|
|
110
|
+
<span className="cb-apphead">
|
|
111
|
+
<span className="cb-appdot" aria-hidden="true" />
|
|
112
|
+
<span className="cb-appname">{app.slug}</span>
|
|
113
|
+
</span>
|
|
114
|
+
<span className="cb-approle">{app.role}</span>
|
|
115
|
+
<span className="cb-appopen">{labelfill("home.appcard.open", { slug: app.slug })}</span>
|
|
116
|
+
</a>
|
|
117
|
+
))}
|
|
118
|
+
</div>
|
|
119
|
+
</section>
|
|
120
|
+
|
|
121
|
+
{/* the support strip: the honest ask */}
|
|
122
|
+
<section className="cb-section" aria-label={label("home.section.supportAria")}>
|
|
123
|
+
<div className="cb-secthead cb-in" style={{ "--cb-d": 0 } as CSSProperties}>
|
|
124
|
+
<h2 className="cb-secttitle">{label("home.section.supportTitle")}</h2>
|
|
125
|
+
<p className="cb-sectlede">{label("home.section.supportLede")}</p>
|
|
126
|
+
</div>
|
|
127
|
+
<div className="cb-btnrow cb-in" style={{ "--cb-d": 1 } as CSSProperties}>
|
|
128
|
+
<Link className="cb-btn cb-btn--ghost" href="/support">
|
|
129
|
+
{label("home.action.support")}
|
|
130
|
+
</Link>
|
|
131
|
+
</div>
|
|
132
|
+
</section>
|
|
133
|
+
|
|
134
|
+
{/* the one reserved ad slot of the page, at the end of the content */}
|
|
135
|
+
<AdSlot page="home" />
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export default Home;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* intro page anchor — layer 3 of the anchor architecture. The file carrying
|
|
3
|
+
* the folder's own name is the path manager of the page: it imports the
|
|
4
|
+
* loose components beside it, mounts the page and re-exports the public
|
|
5
|
+
* component surface. Only the theme anchor (Sol/Sol.tsx) consumes this file.
|
|
6
|
+
*
|
|
7
|
+
* The PRESENTATION page of cobaia — the family grammar's last seat (every
|
|
8
|
+
* app of the family carries one): the page mounts BARE at /intro, no window
|
|
9
|
+
* chrome, riding the site voice, not the frame. The akash splash opens over
|
|
10
|
+
* the landing (the mark spring on the overshoot curve, the name rise, the
|
|
11
|
+
* role fade; click/tap/key skip; reduced motion never plays; the no storage
|
|
12
|
+
* law holds — no seen flag, the splash plays per visit and writes nothing),
|
|
13
|
+
* then the landing answers: the hero says what the surface is (the
|
|
14
|
+
* monetization face kept apart so the main sites stay clean), the plans
|
|
15
|
+
* zone renders the real offer from the one checkoutlinks table (the not
|
|
16
|
+
* configured state stays honest), the doors panes open the app directory
|
|
17
|
+
* and the legal page, and the footer carries the doors (the site row, the
|
|
18
|
+
* family row, the legal line). The entrance rides the cb-in stagger under
|
|
19
|
+
* the second, the layers ride the surface-ladder bands, the door panes ride
|
|
20
|
+
* the silk law. Public-site rules: zero network calls from the page itself,
|
|
21
|
+
* React state only; zero decorative dots; zero invented numbers and zero
|
|
22
|
+
* fake testimonials — the real offer, read from the data modules.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { CSSProperties } from "react";
|
|
26
|
+
import { useCallback, useEffect, useState } from "react";
|
|
27
|
+
import { Link } from "wouter";
|
|
28
|
+
import { label } from "../../labels";
|
|
29
|
+
import { BrandMark, IntroFooter, IntroPlans, IntroSplash } from "../wingets";
|
|
30
|
+
|
|
31
|
+
/** the lifecycle of the opening act: resolving (the motion preference
|
|
32
|
+
* decides) → playing (the akash floor over the landing) → done (the
|
|
33
|
+
* landing alone). No seen state is consulted — the no storage law. */
|
|
34
|
+
type SplashPhase = "resolving" | "playing" | "done";
|
|
35
|
+
|
|
36
|
+
/** the door glyph of the panes: the inline 10×10 chevron the chrome's
|
|
37
|
+
* caption grammar draws (the app carries no icon dependency). */
|
|
38
|
+
function DoorGlyph() {
|
|
39
|
+
return (
|
|
40
|
+
<svg
|
|
41
|
+
className="cb-intropane__glyph"
|
|
42
|
+
width="10"
|
|
43
|
+
height="10"
|
|
44
|
+
viewBox="0 0 10 10"
|
|
45
|
+
aria-hidden="true"
|
|
46
|
+
focusable="false"
|
|
47
|
+
>
|
|
48
|
+
<path d="M3 0l5 5-5 5" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
|
49
|
+
</svg>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The intro page: the splash plays over the landing, then the bare
|
|
55
|
+
* presentation answers — hero, plans, doors, footer.
|
|
56
|
+
*
|
|
57
|
+
* @returns the intro element (null while the motion preference decides).
|
|
58
|
+
*/
|
|
59
|
+
export function Intro() {
|
|
60
|
+
const [splash, setSplash] = useState<SplashPhase>("resolving");
|
|
61
|
+
|
|
62
|
+
// the splash decision rides the motion preference alone — nothing is read
|
|
63
|
+
// from the visitor machine and nothing is written back (the no storage law)
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
66
|
+
setSplash("done");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
setSplash("playing");
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
/** the slide completed: the splash unmounts, the landing owns the page. */
|
|
73
|
+
const onSplashDone = useCallback(() => {
|
|
74
|
+
setSplash("done");
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
/** the quiet cue: walks the page to the plans band. */
|
|
78
|
+
const cuePlans = useCallback(() => {
|
|
79
|
+
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
80
|
+
document.getElementById("intro-plans")?.scrollIntoView({ behavior: reduced ? "auto" : "smooth", block: "start" });
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
// the phase decision holds the paint: nothing renders while it resolves —
|
|
84
|
+
// no flash of the landing under a splash that will never play
|
|
85
|
+
if (splash === "resolving") return null;
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div className="cb-intro" data-page="intro">
|
|
89
|
+
{/* the skip link rides outside no frame — the bare page answers the
|
|
90
|
+
* viewport directly; the splash sits above it while it plays */}
|
|
91
|
+
<a className="cb-skip" href="#intro-content">
|
|
92
|
+
{label("shell.skip")}
|
|
93
|
+
</a>
|
|
94
|
+
|
|
95
|
+
{/* the akash floor: the opening act rides over the landing until its
|
|
96
|
+
* slide completes; reduced motion never mounts it */}
|
|
97
|
+
{splash === "playing" && <IntroSplash onDone={onSplashDone} />}
|
|
98
|
+
|
|
99
|
+
{/* the intro bar: the wordmark and the two quiet doors — the ONE
|
|
100
|
+
* primary cta lives in the hero */}
|
|
101
|
+
<header className="cb-introbar">
|
|
102
|
+
<div className="cb-introbar__in">
|
|
103
|
+
<span className="cb-introbar__brand">
|
|
104
|
+
<BrandMark size={20} />
|
|
105
|
+
<span className="cb-introbar__word">{label("shell.name")}</span>
|
|
106
|
+
</span>
|
|
107
|
+
<span className="cb-introbar__spacer" />
|
|
108
|
+
<nav className="cb-introbar__nav" aria-label={label("intro.bar.aria")}>
|
|
109
|
+
<Link className="cb-introdoor" href="/apps">
|
|
110
|
+
{label("shell.foot.apps")}
|
|
111
|
+
</Link>
|
|
112
|
+
<Link className="cb-introdoor" href="/pricing">
|
|
113
|
+
{label("shell.foot.pricing")}
|
|
114
|
+
</Link>
|
|
115
|
+
</nav>
|
|
116
|
+
</div>
|
|
117
|
+
</header>
|
|
118
|
+
|
|
119
|
+
<main id="intro-content">
|
|
120
|
+
{/* the hero: what the surface is, said directly */}
|
|
121
|
+
<section className="cb-introhero" aria-label={label("intro.aria.page")}>
|
|
122
|
+
<div className="cb-introwrap">
|
|
123
|
+
<span className="cb-introhero__mark cb-in" style={{ "--cb-d": 0 } as CSSProperties} aria-hidden="true">
|
|
124
|
+
<BrandMark size={64} />
|
|
125
|
+
</span>
|
|
126
|
+
<p className="cb-eyebrow cb-in" style={{ "--cb-d": 1 } as CSSProperties}>
|
|
127
|
+
{label("intro.hero.eyebrow")}
|
|
128
|
+
</p>
|
|
129
|
+
<h1 className="cb-title cb-in" style={{ "--cb-d": 2 } as CSSProperties}>
|
|
130
|
+
{label("intro.hero.title")}
|
|
131
|
+
</h1>
|
|
132
|
+
<p className="cb-lede cb-in" style={{ "--cb-d": 3 } as CSSProperties}>
|
|
133
|
+
{label("intro.hero.lede")}
|
|
134
|
+
</p>
|
|
135
|
+
<div className="cb-btnrow cb-in" style={{ "--cb-d": 4 } as CSSProperties}>
|
|
136
|
+
<Link className="cb-btn" href="/">
|
|
137
|
+
{label("intro.hero.cta")}
|
|
138
|
+
</Link>
|
|
139
|
+
<button type="button" className="cb-btn cb-btn--ghost" onClick={cuePlans}>
|
|
140
|
+
{label("intro.hero.cue")}
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
<p className="cb-note cb-in" style={{ "--cb-d": 5 } as CSSProperties}>
|
|
144
|
+
{label("intro.hero.note")}
|
|
145
|
+
</p>
|
|
146
|
+
</div>
|
|
147
|
+
</section>
|
|
148
|
+
|
|
149
|
+
{/* the plans as they really are (the one checkoutlinks table) */}
|
|
150
|
+
<IntroPlans />
|
|
151
|
+
|
|
152
|
+
{/* the doors: the app directory and the legal page, on silk panes */}
|
|
153
|
+
<section className="cb-introband cb-introband--deep" aria-label={label("intro.doors.aria")}>
|
|
154
|
+
<div className="cb-introwrap">
|
|
155
|
+
<div className="cb-secthead cb-in" style={{ "--cb-d": 0 } as CSSProperties}>
|
|
156
|
+
<p className="cb-eyebrow">{label("intro.doors.eyebrow")}</p>
|
|
157
|
+
<h2 className="cb-secttitle">{label("intro.doors.title")}</h2>
|
|
158
|
+
</div>
|
|
159
|
+
<div className="cb-introdoorgrid">
|
|
160
|
+
<article className="cb-intropane cb-in" style={{ "--cb-d": 1 } as CSSProperties}>
|
|
161
|
+
<p className="cb-cardmeta">{label("intro.doors.apps")}</p>
|
|
162
|
+
<p className="cb-intropane__text">{label("intro.doors.appsText")}</p>
|
|
163
|
+
<Link className="cb-intropane__door" href="/apps">
|
|
164
|
+
{label("intro.doors.appsCta")}
|
|
165
|
+
<DoorGlyph />
|
|
166
|
+
</Link>
|
|
167
|
+
</article>
|
|
168
|
+
<article className="cb-intropane cb-in" style={{ "--cb-d": 2 } as CSSProperties}>
|
|
169
|
+
<p className="cb-cardmeta">{label("intro.doors.legal")}</p>
|
|
170
|
+
<p className="cb-intropane__text">{label("intro.doors.legalText")}</p>
|
|
171
|
+
<Link className="cb-intropane__door" href="/legal">
|
|
172
|
+
{label("intro.doors.legalCta")}
|
|
173
|
+
<DoorGlyph />
|
|
174
|
+
</Link>
|
|
175
|
+
</article>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
</section>
|
|
179
|
+
</main>
|
|
180
|
+
|
|
181
|
+
{/* the footer carries the doors: the site row, the family row, the line */}
|
|
182
|
+
<IntroFooter />
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export default Intro;
|