Public homepage, pricing, privacy, changelog, and SEO (#117)
Deploy Beta / unit-tests (push) Successful in 13s
Unit Tests / test (push) Successful in 12s
Deploy Beta / deploy-beta (push) Successful in 5m0s

## Summary
- Logged-out `/` is a public landing page. Signed-in `/` still opens chat.
- Adds `/pricing/`, `/privacy/`, and `/changelog/`, plus header and footer links.
- Primary path is **Create account** when registration is on, and an email **waitlist** when it is off.
- Titles, canonicals, Organization and Product JSON-LD, `sitemap.xml`, `robots.txt`, and `llms.txt`.
- Usage counts render only from `GET /api/public/usage/`. No avatars. No founder block. No testimonials.
- Closes #113, #114, #115, and #116.

Needs [chat_backend#75](https://git.aimloperations.com/ai_ml_operations/chat_backend/pulls) for the waitlist and usage API, and the server-infra crawler-file change so those root files are not the SPA shell.

The account and document numbers in the screenshots come from a local mock of the usage API, not production.

## Screenshots

### Landing, registration off
![Landing waitlist desktop](https://git.aimloperations.com/ai_ml_operations/chat_web_app/raw/branch/feat/113-public-site/llm-fe/docs/ux/landing-desktop.png)

### Landing, mobile
![Landing mobile](https://git.aimloperations.com/ai_ml_operations/chat_web_app/raw/branch/feat/113-public-site/llm-fe/docs/ux/landing-mobile.png)

### Landing, registration on
![Landing create account](https://git.aimloperations.com/ai_ml_operations/chat_web_app/raw/branch/feat/113-public-site/llm-fe/docs/ux/landing-signup.png)

### Pricing
![Pricing](https://git.aimloperations.com/ai_ml_operations/chat_web_app/raw/branch/feat/113-public-site/llm-fe/docs/ux/pricing.png)

### Privacy
![Privacy](https://git.aimloperations.com/ai_ml_operations/chat_web_app/raw/branch/feat/113-public-site/llm-fe/docs/ux/privacy.png)

### Changelog
![Changelog](https://git.aimloperations.com/ai_ml_operations/chat_web_app/raw/branch/feat/113-public-site/llm-fe/docs/ux/changelog.png)

### Sign in unchanged
![Sign in](https://git.aimloperations.com/ai_ml_operations/chat_web_app/raw/branch/feat/113-public-site/llm-fe/docs/ux/signin.png)

## Test plan
- [x] `react-scripts test` — 234 passed
- [ ] Incognito `/` shows the landing page, not Sign In
- [ ] Signed-in `/` still opens chat
- [ ] `/account/` still redirects to `/signin/` when logged out
- [ ] Waitlist POST and usage GET against the new API
- [ ] `/robots.txt`, `/sitemap.xml`, and `/llms.txt` return plain text, not HTML

Reviewed-on: #117
This commit was merged in pull request #117.
This commit is contained in:
westfarn committed 2026-10-17 08:15:02 -07:00
1 parent 1a434b208e
commit fa91c4969f
35 files changed
+1347 -10

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

+42 -5
View File
@@ -8,11 +8,48 @@
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Hesychia — a calm AI assistant by AI ML Operations, LLC" />
<meta name="description" content="Hesychia is a calm AI assistant. Quiet, focused AI chat — private by default. $10/month from AI ML Operations, LLC." />
<meta name="application-name" content="Hesychia" />
<meta property="og:title" content="Hesychia" />
<meta property="og:description" content="Hesychia — quiet, focused AI chat at hesychia.ai" />
<meta property="og:url" content="https://hesychia.ai" />
<link rel="canonical" href="https://hesychia.ai/" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Hesychia — quiet, focused AI chat" />
<meta property="og:description" content="Hesychia is a calm AI assistant. Quiet, focused AI chat — private by default. $10/month." />
<meta property="og:url" content="https://hesychia.ai/" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Hesychia",
"url": "https://hesychia.ai/",
"logo": "https://hesychia.ai/apple-touch-icon.png",
"parentOrganization": {
"@type": "Organization",
"name": "AI ML Operations, LLC",
"url": "https://www.aimloperations.com"
}
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Hesychia",
"applicationCategory": "BusinessApplication",
"operatingSystem": "Web",
"url": "https://hesychia.ai/",
"description": "A calm AI assistant. Quiet, focused AI chat — private by default.",
"offers": {
"@type": "Offer",
"price": "10.00",
"priceCurrency": "USD"
},
"provider": {
"@type": "Organization",
"name": "AI ML Operations, LLC",
"url": "https://www.aimloperations.com"
}
}
</script>
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
@@ -28,7 +65,7 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Hesychia</title>
<title>Hesychia — quiet, focused AI chat</title>
</head>
<body>
+18
View File
@@ -0,0 +1,18 @@
# Hesychia
> Hesychia is a calm AI assistant. Quiet, focused AI chat — private by default.
Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files, and company managers can manage team accounts. Conversation memory is a setting you control. Canceling a subscription does not delete documents. Account deletion is a soft-delete.
## Pages
- [Home](https://hesychia.ai/): what Hesychia is, who it is for, and how to start
- [Pricing](https://hesychia.ai/pricing/): $10/month, flat, no ads, no upsells
- [Privacy](https://hesychia.ai/privacy/): what is stored, what is not sold, what cancel does to documents
- [Changelog](https://hesychia.ai/changelog/): dated product updates
- [Terms of Service](https://hesychia.ai/terms_of_service/): the binding terms
- [Account deletion](https://hesychia.ai/account-deletion/): how to delete an account and what soft-delete keeps
## Operator
AI ML Operations, LLC — https://www.aimloperations.com
+14 -1
View File
@@ -1,3 +1,16 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
Allow: /
Disallow: /signin/
Disallow: /signup/
Disallow: /account/
Disallow: /document_storage
Disallow: /feedback/
Disallow: /analytics/
Disallow: /password_reset/
Disallow: /password_reset_confirmation/
Disallow: /set_password/
Disallow: /auth/
Disallow: /billing/
Sitemap: https://hesychia.ai/sitemap.xml
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://hesychia.ai/</loc>
<lastmod>2026-10-17</lastmod>
</url>
<url>
<loc>https://hesychia.ai/pricing/</loc>
<lastmod>2026-10-17</lastmod>
</url>
<url>
<loc>https://hesychia.ai/privacy/</loc>
<lastmod>2026-10-17</lastmod>
</url>
<url>
<loc>https://hesychia.ai/changelog/</loc>
<lastmod>2026-10-17</lastmod>
</url>
<url>
<loc>https://hesychia.ai/terms_of_service/</loc>
<lastmod>2026-07-27</lastmod>
</url>
<url>
<loc>https://hesychia.ai/account-deletion/</loc>
<lastmod>2026-08-04</lastmod>
</url>
</urlset>
+10 -2
View File
@@ -13,7 +13,6 @@ import TermsOfService from './llm-fe/pages/TermsOfService/TermsOfService';
import PublicTermsOfServicePage from './llm-fe/pages/TermsOfService/PublicTermsOfServicePage';
import AccountDeletionPage from './llm-fe/pages/AccountDeletion/AccountDeletionPage';
import SetPassword from './llm-fe/components/SetPassword/SetPassword';
import AsyncDashboard2 from './llm-fe/pages/AsyncDashboard2/AsyncDashboard2';
import FeedbackPage2 from './llm-fe/pages/FeedbackPage2/FeedbackPage2';
import DocumentStoragePage from './llm-fe/pages/DocumentStoragePage/DocumentStoragePage';
import Account2 from './llm-fe/pages/Account2/Account2';
@@ -26,6 +25,11 @@ import AnalyticsConsentBanner from './llm-fe/components/AnalyticsConsentBanner/A
import AnalyticsSession from './llm-fe/components/AnalyticsSession/AnalyticsSession';
import AppErrorBoundary from './llm-fe/components/AppErrorBoundary/AppErrorBoundary';
import ToastHost from './llm-fe/components/ToastHost/ToastHost';
import HomeRoute from './llm-fe/components/HomeRoute/HomeRoute';
import RouteMeta from './llm-fe/components/RouteMeta/RouteMeta';
import PricingPage from './llm-fe/pages/Pricing/PricingPage';
import PrivacyPage from './llm-fe/pages/Privacy/PrivacyPage';
import ChangelogPage from './llm-fe/pages/Changelog/ChangelogPage';
const ProtectedRoutes = () => {
const { authenticated, loading } = useContext(AuthContext);
@@ -52,6 +56,7 @@ class App extends Component {
<AnalyticsSession />
<ToastHost />
<AnalyticsConsentBanner />
<RouteMeta />
<div className='site'>
<main>
<div className="main-container">
@@ -74,9 +79,12 @@ class App extends Component {
<Route path={"/legal/terms-of-service"} Component={PublicTermsOfServicePage} />
{/* Play Console account-deletion URL (#103) — must stay public */}
<Route path={"/account-deletion/"} Component={AccountDeletionPage} />
<Route path={"/"} Component={HomeRoute} />
<Route path={"/pricing/"} Component={PricingPage} />
<Route path={"/privacy/"} Component={PrivacyPage} />
<Route path={"/changelog/"} Component={ChangelogPage} />
<Route element={<ProtectedRoutes />}>
<Route path={"/"} index={true} Component={AsyncDashboard2} />
<Route path={"/account/"} Component={Account2} />
<Route path={"/document_storage"} Component={DocumentStoragePage} />
<Route path={"/feedback/"} Component={FeedbackPage2} />
@@ -25,5 +25,8 @@ describe('Footer', () => {
'href',
'/terms_of_service/',
);
expect(screen.getByRole('link', { name: 'Pricing' })).toHaveAttribute('href', '/pricing/');
expect(screen.getByRole('link', { name: 'Privacy' })).toHaveAttribute('href', '/privacy/');
expect(screen.getByRole('link', { name: 'Changelog' })).toHaveAttribute('href', '/changelog/');
});
});
@@ -1,6 +1,7 @@
import React from 'react';
import { Link as RouterLink } from 'react-router-dom';
import MDTypography from '../../ui-kit/components/MDTypography';
import { PUBLIC_FOOTER_LINKS } from '../PublicSite/publicNav';
const Footer = (): JSX.Element => {
return (
@@ -8,8 +9,12 @@ const Footer = (): JSX.Element => {
<MDTypography>
&copy; 2026 Hesychia | Developed by{' '}
<a href="https://www.aimloperations.com">AI ML Operations, LLC</a>
{PUBLIC_FOOTER_LINKS.map((link) => (
<React.Fragment key={link.to}>
{' · '}
<RouterLink to="/terms_of_service/">Terms of Service</RouterLink>
<RouterLink to={link.to}>{link.label}</RouterLink>
</React.Fragment>
))}
</MDTypography>
</div>
);
@@ -0,0 +1,47 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import HomeRoute from './HomeRoute';
import { AuthContext } from '../../contexts/AuthContext';
jest.mock('../../pages/AsyncDashboard2/AsyncDashboard2', () => () => <div>Chat app</div>);
jest.mock('../../pages/Landing/LandingPage', () => () => <h1>Hesychia is a calm AI assistant.</h1>);
const renderHome = (authenticated: boolean, loading: boolean) => {
render(
<MemoryRouter>
<AuthContext.Provider
value={{
authenticated,
setAuthentication: () => {},
needsNewPassword: false,
setNeedsNewPassword: () => {},
loading,
}}
>
<HomeRoute />
</AuthContext.Provider>
</MemoryRouter>,
);
};
describe('HomeRoute', () => {
it('shows a loading state before auth is known', () => {
renderHome(false, true);
expect(screen.getByText('Loading')).toBeInTheDocument();
expect(screen.queryByText('Chat app')).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /calm AI assistant/i })).not.toBeInTheDocument();
});
it('shows the public landing page when signed out', () => {
renderHome(false, false);
expect(screen.getByRole('heading', { name: /calm AI assistant/i })).toBeInTheDocument();
expect(screen.queryByText('Chat app')).not.toBeInTheDocument();
});
it('keeps the chat app on / for a signed-in user', () => {
renderHome(true, false);
expect(screen.getByText('Chat app')).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /calm AI assistant/i })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,21 @@
import { useContext } from 'react';
import { AuthContext } from '../../contexts/AuthContext';
import AsyncDashboard2 from '../../pages/AsyncDashboard2/AsyncDashboard2';
import LandingPage from '../../pages/Landing/LandingPage';
/** Logged-out `/` is the public site. Logged-in `/` stays the chat. */
const HomeRoute = (): JSX.Element => {
const { authenticated, loading } = useContext(AuthContext);
if (loading) {
return <div>Loading</div>;
}
if (authenticated) {
return <AsyncDashboard2 />;
}
return <LandingPage />;
};
export default HomeRoute;
@@ -0,0 +1,220 @@
import React, { FormEvent, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { axiosInstance } from '../../../axiosApi';
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
const Stack = styled.div`
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
width: 100%;
max-width: 28rem;
`;
const Row = styled.div`
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
`;
const Primary = styled(Link)`
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
text-decoration: none;
border-radius: 0.5rem;
padding: 0.9rem 1.25rem;
font-weight: 600;
font-size: 1rem;
`;
const Secondary = styled(Link)`
color: #d6dcff;
font-weight: 600;
`;
const Quiet = styled(Link)`
color: rgba(255, 255, 255, 0.72);
font-size: 0.95rem;
`;
const Form = styled.form`
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
width: 100%;
`;
const Label = styled.label`
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
`;
const Input = styled.input`
flex: 1 1 14rem;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 0.5rem;
padding: 0.9rem 1rem;
color: #fff;
font-size: 1rem;
`;
const Submit = styled.button`
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 0.5rem;
color: #fff;
padding: 0.9rem 1.25rem;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
&:disabled {
opacity: 0.6;
cursor: default;
}
`;
const Note = styled.p`
margin: 0;
color: rgba(255, 255, 255, 0.8);
font-size: 0.95rem;
`;
type GetStartedProps = {
source: 'landing' | 'pricing';
idPrefix?: string;
showPricingLink?: boolean;
};
/**
* Create account when registration is on. Email waitlist when it is off or unknown.
* Settings default to off in the API, so a failed lookup stays on the waitlist.
*/
const GetStarted = ({
source,
idPrefix = 'waitlist',
showPricingLink = true,
}: GetStartedProps): JSX.Element => {
const [registrationEnabled, setRegistrationEnabled] = useState<boolean | null>(null);
const [email, setEmail] = useState('');
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
axiosInstance
.get('/public/settings/')
.then((response) => {
if (cancelled) return;
setRegistrationEnabled(Boolean(response.data?.enable_account_registration));
})
.catch(() => {
if (!cancelled) setRegistrationEnabled(false);
});
return () => {
cancelled = true;
};
}, []);
const onCreateAccount = () => {
trackEvent(AnalyticsEvents.LANDING_PRIMARY_CTA, { source, action: 'create_account' });
};
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmed = email.trim();
if (!trimmed) {
setError('Enter an email address.');
setMessage(null);
return;
}
setSubmitting(true);
setError(null);
setMessage(null);
try {
await axiosInstance.post('/public/waitlist/', { email: trimmed });
trackEvent(AnalyticsEvents.WAITLIST_JOINED, { source });
setMessage("You're on the list.");
setEmail('');
} catch {
setError('Could not join the waitlist. Try again.');
} finally {
setSubmitting(false);
}
};
if (registrationEnabled === null) {
return <Note>Checking signup…</Note>;
}
if (registrationEnabled) {
return (
<Stack>
<Row>
<Primary to="/signup/" onClick={onCreateAccount}>
Create account
</Primary>
{showPricingLink && (
<Secondary
to="/pricing/"
onClick={() => trackEvent(AnalyticsEvents.LANDING_PRICING_CTA, { source })}
>
See pricing
</Secondary>
)}
</Row>
<Quiet to="/signin/">Sign in</Quiet>
</Stack>
);
}
return (
<Stack>
<Form onSubmit={onSubmit}>
<Label htmlFor={`${idPrefix}-email`}>Email</Label>
<Input
id={`${idPrefix}-email`}
name="email"
type="email"
autoComplete="email"
placeholder="Email address"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
<Submit type="submit" disabled={submitting}>
{submitting ? 'Joining…' : 'Join the waitlist'}
</Submit>
</Form>
{message && <Note role="status">{message}</Note>}
{error && <Note role="alert">{error}</Note>}
<Row>
{showPricingLink && (
<Secondary
to="/pricing/"
onClick={() => trackEvent(AnalyticsEvents.LANDING_PRICING_CTA, { source })}
>
See pricing
</Secondary>
)}
<Quiet to="/signin/">Sign in</Quiet>
</Row>
</Stack>
);
};
export default GetStarted;
@@ -0,0 +1,120 @@
import React from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { PUBLIC_FOOTER_LINKS, PUBLIC_HEADER_LINKS } from './publicNav';
const Page = styled.div`
min-height: 100vh;
background: #07080d;
color: #fff;
font-family: Inter, sans-serif;
display: flex;
flex-direction: column;
`;
const Header = styled.header`
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
`;
const Brand = styled(Link)`
color: #fff;
font-weight: 700;
font-size: 1.15rem;
text-decoration: none;
letter-spacing: 0.02em;
`;
const Nav = styled.nav`
display: flex;
flex-wrap: wrap;
gap: 1rem 1.25rem;
align-items: center;
`;
const NavLink = styled(Link)`
color: rgba(255, 255, 255, 0.82);
text-decoration: none;
font-size: 0.95rem;
&:hover {
color: #fff;
}
`;
const SignInLink = styled(Link)`
color: #fff;
text-decoration: none;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 0.5rem;
padding: 0.45rem 0.8rem;
font-size: 0.95rem;
`;
const Main = styled.main`
width: min(880px, 100%);
margin: 0 auto;
padding: 3rem 1.5rem 4rem;
flex: 1;
`;
const FooterBar = styled.footer`
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding: 1.5rem;
color: rgba(255, 255, 255, 0.7);
font-size: 0.9rem;
`;
const FooterNav = styled.nav`
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.25rem;
margin-bottom: 0.75rem;
`;
const FooterLink = styled(Link)`
color: rgba(255, 255, 255, 0.82);
`;
type PublicSiteLayoutProps = {
children: React.ReactNode;
};
const PublicSiteLayout = ({ children }: PublicSiteLayoutProps): JSX.Element => {
return (
<Page>
<Header>
<Brand to="/">Hesychia</Brand>
<Nav aria-label="Primary">
{PUBLIC_HEADER_LINKS.map((link) => (
<NavLink key={link.to} to={link.to}>
{link.label}
</NavLink>
))}
<SignInLink to="/signin/">Sign in</SignInLink>
</Nav>
</Header>
<Main>{children}</Main>
<FooterBar>
<FooterNav aria-label="Footer">
{PUBLIC_FOOTER_LINKS.map((link) => (
<FooterLink key={link.to} to={link.to}>
{link.label}
</FooterLink>
))}
</FooterNav>
<div>
© 2026 Hesychia · Developed by{' '}
<a href="https://www.aimloperations.com">AI ML Operations, LLC</a>
</div>
</FooterBar>
</Page>
);
};
export default PublicSiteLayout;
@@ -0,0 +1,84 @@
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import { axiosInstance } from '../../../axiosApi';
const Section = styled.section`
margin-top: 1.5rem;
color: rgba(255, 255, 255, 0.86);
font-size: 0.95rem;
`;
const Figures = styled.p`
margin: 0 0 0.35rem;
font-weight: 600;
`;
const AsOf = styled.p`
margin: 0;
color: rgba(255, 255, 255, 0.62);
`;
type PublicUsage = {
account_count: number;
document_count: number;
as_of: string;
};
function countLabel(count: number, singular: string, plural: string): string {
return `${count} ${count === 1 ? singular : plural}`;
}
/**
* Live counts only. Hidden when the API fails so the page never shows a made-up number.
* Avatars are omitted: no one has consented to publish a photo.
*/
const UsageCounts = (): JSX.Element | null => {
const [usage, setUsage] = useState<PublicUsage | null>(null);
useEffect(() => {
let cancelled = false;
axiosInstance
.get('/public/usage/')
.then((response) => {
if (cancelled) return;
const data = response.data;
if (
typeof data?.account_count !== 'number' ||
typeof data?.document_count !== 'number' ||
typeof data?.as_of !== 'string'
) {
return;
}
setUsage({
account_count: data.account_count,
document_count: data.document_count,
as_of: data.as_of,
});
})
.catch(() => {
/* leave hidden */
});
return () => {
cancelled = true;
};
}, []);
if (!usage) return null;
const asOfDate = usage.as_of.slice(0, 10);
return (
<Section aria-label="Usage">
<Figures>
{countLabel(usage.account_count, 'account', 'accounts')}
{' · '}
{countLabel(usage.document_count, 'document stored', 'documents stored')}
</Figures>
<AsOf>
As of <time dateTime={usage.as_of}>{asOfDate}</time>
</AsOf>
</Section>
);
};
export default UsageCounts;
@@ -0,0 +1,18 @@
/** Shared public links. Header stays short; footer adds legal pages. No About until a founder is named. */
export type PublicNavLink = {
to: string;
label: string;
};
export const PUBLIC_HEADER_LINKS: readonly PublicNavLink[] = [
{ to: '/#product', label: 'Product' },
{ to: '/pricing/', label: 'Pricing' },
{ to: '/changelog/', label: 'Changelog' },
];
export const PUBLIC_FOOTER_LINKS: readonly PublicNavLink[] = [
...PUBLIC_HEADER_LINKS,
{ to: '/privacy/', label: 'Privacy' },
{ to: '/terms_of_service/', label: 'Terms of Service' },
];
@@ -0,0 +1,19 @@
import { useContext, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { AuthContext } from '../../contexts/AuthContext';
import { applyRouteMeta, metaForPath } from '../../utils/routeMeta';
/** Sets document title and canonical from the current route. */
const RouteMeta = (): null => {
const { pathname } = useLocation();
const { authenticated, loading } = useContext(AuthContext);
useEffect(() => {
if (loading) return;
applyRouteMeta(metaForPath(pathname, authenticated));
}, [pathname, authenticated, loading]);
return null;
};
export default RouteMeta;
@@ -0,0 +1,25 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import ChangelogPage from './ChangelogPage';
import { CHANGELOG_ENTRIES } from './changelogEntries';
describe('ChangelogPage', () => {
it('lists dated shipped notes and no founder', () => {
render(
<MemoryRouter>
<ChangelogPage />
</MemoryRouter>,
);
expect(screen.getByRole('heading', { name: 'Changelog' })).toBeInTheDocument();
expect(screen.getAllByText('2026-08-04')).toHaveLength(2);
expect(screen.getAllByText('2026-08-01')).toHaveLength(2);
for (const entry of CHANGELOG_ENTRIES) {
expect(screen.getByRole('heading', { name: entry.title })).toBeInTheDocument();
}
expect(screen.getByText(/Conversation memory/)).toBeInTheDocument();
expect(screen.queryByText(/founder/i)).not.toBeInTheDocument();
expect(CHANGELOG_ENTRIES.every((entry) => /^\d{4}-\d{2}-\d{2}$/.test(entry.date))).toBe(true);
});
});
@@ -0,0 +1,60 @@
import React from 'react';
import styled from 'styled-components';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
import { CHANGELOG_ENTRIES } from './changelogEntries';
const Title = styled.h1`
font-size: clamp(2rem, 4vw, 2.75rem);
margin: 0 0 1.5rem;
`;
const List = styled.ol`
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 1.25rem;
`;
const Item = styled.li`
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 1rem;
`;
const When = styled.time`
display: block;
color: rgba(255, 255, 255, 0.6);
font-size: 0.9rem;
margin-bottom: 0.35rem;
`;
const Heading = styled.h2`
font-size: 1.15rem;
margin: 0 0 0.35rem;
`;
const Note = styled.p`
margin: 0;
color: rgba(255, 255, 255, 0.82);
line-height: 1.5;
max-width: 40rem;
`;
const ChangelogPage = (): JSX.Element => {
return (
<PublicSiteLayout>
<Title>Changelog</Title>
<List>
{CHANGELOG_ENTRIES.map((entry) => (
<Item key={`${entry.date}-${entry.title}`}>
<When dateTime={entry.date}>{entry.date}</When>
<Heading>{entry.title}</Heading>
<Note>{entry.note}</Note>
</Item>
))}
</List>
</PublicSiteLayout>
);
};
export default ChangelogPage;
@@ -0,0 +1,29 @@
export type ChangelogEntry = {
date: string;
title: string;
note: string;
};
/** Shipped product changes only. Dates match the git history. No founder byline. */
export const CHANGELOG_ENTRIES: readonly ChangelogEntry[] = [
{
date: '2026-08-04',
title: 'Conversation memory',
note: 'Account settings can use earlier turns in a chat as context, so you do not re-explain the same project on every message. It stays off until you turn it on.',
},
{
date: '2026-08-04',
title: 'Account deletion',
note: 'A public page explains how to delete an account and that deletion is a soft-delete.',
},
{
date: '2026-08-01',
title: 'Documents',
note: 'Upload documents and search them from chat. Personal and company libraries stay separate.',
},
{
date: '2026-08-01',
title: 'Subscriptions',
note: 'Upgrade, change, or cancel from the account page. The public price is $10 a month.',
},
];
@@ -0,0 +1,105 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import LandingPage from './LandingPage';
const mockGet = jest.fn();
const mockPost = jest.fn();
jest.mock('../../../axiosApi', () => ({
axiosInstance: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
},
}));
const settings = (enabled: boolean) => ({ data: { enable_account_registration: enabled } });
const usage = {
data: {
account_count: 3,
document_count: 12,
as_of: '2026-10-17T15:00:00+00:00',
},
};
describe('LandingPage', () => {
beforeEach(() => {
mockGet.mockReset();
mockPost.mockReset();
});
it('says what Hesychia is and offers a waitlist when registration is off', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/public/settings/') return Promise.resolve(settings(false));
if (url === '/public/usage/') return Promise.resolve(usage);
return Promise.reject(new Error(url));
});
render(
<MemoryRouter>
<LandingPage />
</MemoryRouter>,
);
expect(screen.getByRole('heading', { name: 'Hesychia is a calm AI assistant.' })).toBeInTheDocument();
expect(screen.getByText(/Quiet, focused AI chat — private by default/i)).toBeInTheDocument();
expect(screen.getByAltText('Hesychia logo')).toBeInTheDocument();
expect(screen.getByText(/Your documents, stored and searchable/i)).toBeInTheDocument();
expect(screen.getByText(/Business accounts/i)).toBeInTheDocument();
expect(screen.getByText(/We do not sell your data/i)).toBeInTheDocument();
expect(screen.getByText(/Canceling does not delete documents/i)).toBeInTheDocument();
expect(screen.queryByText(/founder/i)).not.toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Join the waitlist' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Privacy details' })).toHaveAttribute('href', '/privacy/');
expect(screen.getAllByRole('link', { name: 'Sign in' }).length).toBeGreaterThan(0);
expect(screen.getAllByRole('link', { name: 'Pricing' })[0]).toHaveAttribute('href', '/pricing/');
expect(await screen.findByText(/3 accounts/)).toBeInTheDocument();
expect(screen.getByText(/12 documents stored/)).toBeInTheDocument();
expect(screen.getByText('2026-10-17')).toBeInTheDocument();
});
it('links to create account when registration is on', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/public/settings/') return Promise.resolve(settings(true));
return Promise.reject(new Error(url));
});
render(
<MemoryRouter>
<LandingPage />
</MemoryRouter>,
);
const create = await screen.findByRole('link', { name: 'Create account' });
expect(create).toHaveAttribute('href', '/signup/');
expect(screen.queryByRole('button', { name: 'Join the waitlist' })).not.toBeInTheDocument();
expect(screen.queryByLabelText('Usage')).not.toBeInTheDocument();
});
it('stores a waitlist email', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/public/settings/') return Promise.resolve(settings(false));
return Promise.reject(new Error(url));
});
mockPost.mockResolvedValue({ data: { detail: "You're on the list." } });
render(
<MemoryRouter>
<LandingPage />
</MemoryRouter>,
);
const user = userEvent.setup();
await user.type(await screen.findByLabelText('Email'), 'person@example.com');
await user.click(screen.getByRole('button', { name: 'Join the waitlist' }));
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith('/public/waitlist/', { email: 'person@example.com' });
});
expect(await screen.findByRole('status')).toHaveTextContent("You're on the list.");
});
});
@@ -0,0 +1,126 @@
import React from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import hesychiaMark from '../../assets/brand/hesychia-mark.png';
import GetStarted from '../../components/PublicSite/GetStarted';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
import UsageCounts from '../../components/PublicSite/UsageCounts';
const Hero = styled.header`
max-width: 40rem;
`;
const Mark = styled.img`
width: 4rem;
height: 4rem;
border-radius: 50%;
`;
const Title = styled.h1`
font-size: clamp(2rem, 4vw, 3rem);
line-height: 1.15;
margin: 1.25rem 0 0.75rem;
font-weight: 700;
`;
const Lede = styled.p`
font-size: 1.25rem;
line-height: 1.45;
margin: 0 0 0.75rem;
color: rgba(255, 255, 255, 0.92);
`;
const Memory = styled.p`
font-size: 1.05rem;
line-height: 1.5;
margin: 0 0 1.5rem;
color: rgba(255, 255, 255, 0.82);
`;
const Privacy = styled.section`
margin-top: 1.75rem;
max-width: 40rem;
`;
const PrivacyList = styled.ul`
margin: 0.5rem 0 0.75rem;
padding-left: 1.2rem;
color: rgba(255, 255, 255, 0.82);
line-height: 1.5;
`;
const TextLink = styled(Link)`
color: #c9d4ff;
`;
const Features = styled.section`
margin-top: 3rem;
display: grid;
gap: 1rem;
`;
const Feature = styled.article`
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.75rem;
padding: 1.25rem 1.35rem;
background: rgba(255, 255, 255, 0.03);
`;
const FeatureTitle = styled.h2`
font-size: 1.15rem;
margin: 0 0 0.4rem;
`;
const FeatureBody = styled.p`
margin: 0;
color: rgba(255, 255, 255, 0.8);
line-height: 1.5;
`;
const LandingPage = (): JSX.Element => {
return (
<PublicSiteLayout>
<Hero>
<Mark src={hesychiaMark} alt="Hesychia logo" />
<Title>Hesychia is a calm AI assistant.</Title>
<Lede>Quiet, focused AI chat — private by default.</Lede>
<Memory>
It remembers what you told it — and your documents — so you never start over.
Conversation memory is a setting you control.
</Memory>
<GetStarted source="landing" />
<Privacy aria-label="Privacy">
<h2>What we do with your data</h2>
<PrivacyList>
<li>We store your account, your conversations, and the documents you upload.</li>
<li>We do not sell your data, and we do not use advertising cookies.</li>
<li>
Canceling does not delete documents. They stay until you delete them or delete the
account. Account deletion is a soft-delete.
</li>
</PrivacyList>
<TextLink to="/privacy/">Privacy details</TextLink>
</Privacy>
<UsageCounts />
</Hero>
<Features id="product" aria-label="Product">
<Feature>
<FeatureTitle>Memory that stays with the work</FeatureTitle>
<FeatureBody>
It remembers what you told it — and your documents — so you never start over.
</FeatureBody>
</Feature>
<Feature>
<FeatureTitle>Your documents, stored and searchable</FeatureTitle>
<FeatureBody>Answers grounded in your own files.</FeatureBody>
</Feature>
<Feature>
<FeatureTitle>Business accounts</FeatureTitle>
<FeatureBody>Simple management for your team.</FeatureBody>
</Feature>
</Features>
</PublicSiteLayout>
);
};
export default LandingPage;
@@ -0,0 +1,26 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import PricingPage from './PricingPage';
jest.mock('../../../axiosApi', () => ({
axiosInstance: {
get: () => Promise.resolve({ data: { enable_account_registration: false } }),
post: jest.fn(),
},
}));
describe('PricingPage', () => {
it('states the public $10 price and the waitlist', async () => {
render(
<MemoryRouter>
<PricingPage />
</MemoryRouter>,
);
expect(screen.getByRole('heading', { name: 'Pricing' })).toBeInTheDocument();
expect(screen.getByText(/\$10\/month, flat\. No ads, no upsells, one price/)).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Join the waitlist' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'See pricing' })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,44 @@
import React from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import GetStarted from '../../components/PublicSite/GetStarted';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
const Title = styled.h1`
font-size: clamp(2rem, 4vw, 2.75rem);
margin: 0 0 0.75rem;
`;
const Price = styled.p`
font-size: 1.35rem;
margin: 0 0 0.5rem;
`;
const Body = styled.p`
color: rgba(255, 255, 255, 0.82);
line-height: 1.5;
max-width: 36rem;
`;
const Back = styled(Link)`
color: #c9d4ff;
`;
const PricingPage = (): JSX.Element => {
return (
<PublicSiteLayout>
<Title>Pricing</Title>
<Price>$10/month, flat. No ads, no upsells, one price.</Price>
<Body>
One public plan. Quiet, focused AI chat, your documents, and business accounts for your
team. Hard to justify $20 a month — this is $10.
</Body>
<GetStarted source="pricing" idPrefix="pricing-waitlist" showPricingLink={false} />
<p>
<Back to="/">Back to Hesychia</Back>
</p>
</PublicSiteLayout>
);
};
export default PricingPage;
@@ -0,0 +1,25 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import PrivacyPage from './PrivacyPage';
describe('PrivacyPage', () => {
it('states storage, no ads, and that cancel does not delete documents', () => {
render(
<MemoryRouter>
<PrivacyPage />
</MemoryRouter>,
);
expect(screen.getByRole('heading', { name: 'Privacy' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'What is stored' })).toBeInTheDocument();
expect(screen.getByText(/do not sell your information/i)).toBeInTheDocument();
expect(screen.getByText(/do not use advertising cookies/i)).toBeInTheDocument();
expect(screen.getByText(/does not delete uploaded documents/i)).toBeInTheDocument();
expect(screen.getByText(/soft-delete/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /how account deletion works/i })).toHaveAttribute(
'href',
'/account-deletion/',
);
});
});
@@ -0,0 +1,70 @@
import React from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
const Title = styled.h1`
font-size: clamp(2rem, 4vw, 2.75rem);
margin: 0 0 1rem;
`;
const Section = styled.section`
margin-bottom: 1.5rem;
max-width: 40rem;
`;
const Heading = styled.h2`
font-size: 1.2rem;
margin: 0 0 0.4rem;
`;
const Body = styled.p`
margin: 0;
color: rgba(255, 255, 255, 0.82);
line-height: 1.55;
`;
const TextLink = styled(Link)`
color: #c9d4ff;
`;
const PrivacyPage = (): JSX.Element => {
return (
<PublicSiteLayout>
<Title>Privacy</Title>
<Section>
<Heading>What is stored</Heading>
<Body>
Your account (email, name, and an optional company), your conversations, and the
documents you upload so answers can use your files. Optional product analytics, if you
accept them, record actions such as sign-in and checkout. They do not include message or
document contents.
</Body>
</Section>
<Section>
<Heading>What we do not do</Heading>
<Body>
We do not sell your information. We do not use advertising cookies. Information you enter
is not shared with third parties without your consent, except to operate the service or
when the law requires it. The{' '}
<TextLink to="/terms_of_service/#data-confidentiality">Terms of Service</TextLink> and
the <TextLink to="/terms_of_service/#analytics">analytics section</TextLink> are the
binding description.
</Body>
</Section>
<Section>
<Heading>If you cancel</Heading>
<Body>
Canceling stops paid access at the end of the current period. It does not delete
uploaded documents. Documents stay in your workspace until you delete them, or until you
delete the account. Account deletion is a soft-delete: profile details, conversations,
uploaded documents, and authentication records may remain for administration, security,
and audit. There is no automatic hard purge. See{' '}
<TextLink to="/account-deletion/">how account deletion works</TextLink>.
</Body>
</Section>
</PublicSiteLayout>
);
};
export default PrivacyPage;
+1 -1
View File
@@ -264,7 +264,7 @@ const SignIn = (): JSX.Element => {
<ContentWrapper>
<GlassCard>
<BrandBlock>
<BrandMark src={hesychiaMark} alt="" />
<BrandMark src={hesychiaMark} alt="Hesychia logo" />
<BrandWordmark>Hesychia.ai</BrandWordmark>
</BrandBlock>
<CardTitle>Sign In</CardTitle>
@@ -0,0 +1,38 @@
import fs from 'fs';
import path from 'path';
const publicDir = path.resolve(__dirname, '../../public');
describe('public crawler files', () => {
it('serves robots, sitemap, and llms as static text that names the public pages', () => {
const robots = fs.readFileSync(path.join(publicDir, 'robots.txt'), 'utf8');
const sitemap = fs.readFileSync(path.join(publicDir, 'sitemap.xml'), 'utf8');
const llms = fs.readFileSync(path.join(publicDir, 'llms.txt'), 'utf8');
const html = fs.readFileSync(path.join(publicDir, 'index.html'), 'utf8');
expect(robots).toContain('Sitemap: https://hesychia.ai/sitemap.xml');
expect(robots).toContain('Disallow: /signin/');
expect(robots).not.toContain('<html');
for (const url of [
'https://hesychia.ai/',
'https://hesychia.ai/pricing/',
'https://hesychia.ai/privacy/',
'https://hesychia.ai/changelog/',
'https://hesychia.ai/terms_of_service/',
]) {
expect(sitemap).toContain(url);
expect(llms).toContain(url);
}
expect(sitemap.startsWith('<?xml')).toBe(true);
expect(llms).toContain('$10/month');
expect(llms).not.toContain('<html');
expect(html).toContain('<title>Hesychia — quiet, focused AI chat</title>');
expect(html).toContain('rel="canonical" href="https://hesychia.ai/"');
expect(html).toContain('"@type": "Organization"');
expect(html).toContain('"@type": "SoftwareApplication"');
expect(html).toContain('"price": "10.00"');
expect(html).toContain('AI ML Operations, LLC');
});
});
+6
View File
@@ -27,6 +27,9 @@ const queue: QueuedCall[] = [];
* | Account Delete Started / Success / Failed | Self-delete (#34 companion) |
* | Message Copied / Rated / Rating Reason / Exported | Message actions (#97) |
* | Activity Stage Completed | Live activity status stage rolled over (#96) |
* | Landing Primary CTA | Create-account click on the public site |
* | Landing Pricing CTA | See-pricing click on the public site |
* | Waitlist Joined | Email accepted by the public waitlist |
*/
export const AnalyticsEvents = {
LOGIN_SUCCESS: 'Login Success',
@@ -53,6 +56,9 @@ export const AnalyticsEvents = {
MESSAGE_RATING_REASON: 'Message Rating Reason',
MESSAGE_EXPORTED: 'Message Exported',
ACTIVITY_STAGE_COMPLETED: 'Activity Stage Completed',
LANDING_PRIMARY_CTA: 'Landing Primary CTA',
LANDING_PRICING_CTA: 'Landing Pricing CTA',
WAITLIST_JOINED: 'Waitlist Joined',
} as const;
export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
+50
View File
@@ -0,0 +1,50 @@
import { applyRouteMeta, metaForPath } from './routeMeta';
describe('metaForPath', () => {
it('uses the marketing title for anonymous home', () => {
expect(metaForPath('/', false)).toEqual({
title: 'Hesychia — quiet, focused AI chat',
canonical: 'https://hesychia.ai/',
});
});
it('keeps the app title for a signed-in home', () => {
expect(metaForPath('/', true)?.title).toBe('Hesychia');
});
it('gives public pages their own titles and canonicals', () => {
expect(metaForPath('/pricing/', false)).toEqual({
title: 'Pricing — Hesychia',
canonical: 'https://hesychia.ai/pricing/',
});
expect(metaForPath('/privacy/', false)?.canonical).toBe('https://hesychia.ai/privacy/');
expect(metaForPath('/changelog/', false)?.title).toBe('Changelog — Hesychia');
expect(metaForPath('/signin/', false)?.canonical).toBeNull();
});
it('does not invent a title for an unknown path', () => {
expect(metaForPath('/not-a-page', false)).toBeNull();
});
});
describe('applyRouteMeta', () => {
afterEach(() => {
document.title = '';
document.querySelector('link[rel="canonical"]')?.remove();
});
it('sets the title and canonical', () => {
applyRouteMeta({ title: 'Pricing — Hesychia', canonical: 'https://hesychia.ai/pricing/' });
expect(document.title).toBe('Pricing — Hesychia');
expect(document.querySelector('link[rel="canonical"]')?.getAttribute('href')).toBe(
'https://hesychia.ai/pricing/',
);
});
it('removes a canonical when the route should not have one', () => {
applyRouteMeta({ title: 'Pricing — Hesychia', canonical: 'https://hesychia.ai/pricing/' });
applyRouteMeta({ title: 'Sign in — Hesychia', canonical: null });
expect(document.title).toBe('Sign in — Hesychia');
expect(document.querySelector('link[rel="canonical"]')).toBeNull();
});
});
+93
View File
@@ -0,0 +1,93 @@
export type RouteMeta = {
title: string;
/** Absolute canonical URL. Null means this path should not advertise a canonical. */
canonical: string | null;
};
const ORIGIN = 'https://hesychia.ai';
const PUBLIC_PAGES: Record<string, RouteMeta> = {
'/': {
title: 'Hesychia — quiet, focused AI chat',
canonical: `${ORIGIN}/`,
},
'/pricing/': {
title: 'Pricing — Hesychia',
canonical: `${ORIGIN}/pricing/`,
},
'/privacy/': {
title: 'Privacy — Hesychia',
canonical: `${ORIGIN}/privacy/`,
},
'/changelog/': {
title: 'Changelog — Hesychia',
canonical: `${ORIGIN}/changelog/`,
},
'/terms_of_service/': {
title: 'Terms of Service — Hesychia',
canonical: `${ORIGIN}/terms_of_service/`,
},
'/legal/terms-of-service': {
title: 'Terms of Service — Hesychia',
canonical: `${ORIGIN}/legal/terms-of-service`,
},
'/account-deletion/': {
title: 'Account deletion — Hesychia',
canonical: `${ORIGIN}/account-deletion/`,
},
'/signin/': {
title: 'Sign in — Hesychia',
canonical: null,
},
'/signup/': {
title: 'Create account — Hesychia',
canonical: null,
},
'/password_reset/': {
title: 'Reset password — Hesychia',
canonical: null,
},
'/account/': {
title: 'Account — Hesychia',
canonical: null,
},
'/document_storage': {
title: 'Documents — Hesychia',
canonical: null,
},
'/feedback/': {
title: 'Feedback — Hesychia',
canonical: null,
},
'/analytics/': {
title: 'Analytics — Hesychia',
canonical: null,
},
};
/** Title and canonical for a path. Signed-in `/` keeps the app title. */
export function metaForPath(pathname: string, authenticated: boolean): RouteMeta | null {
if (pathname === '/' && authenticated) {
return { title: 'Hesychia', canonical: `${ORIGIN}/` };
}
return PUBLIC_PAGES[pathname] ?? null;
}
export function applyRouteMeta(meta: RouteMeta | null): void {
if (!meta || typeof document === 'undefined') return;
document.title = meta.title;
const existing = document.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!meta.canonical) {
existing?.parentNode?.removeChild(existing);
return;
}
const link = existing ?? document.createElement('link');
link.rel = 'canonical';
link.href = meta.canonical;
if (!existing) {
document.head.appendChild(link);
}
}