style: Update default theme

This commit is contained in:
2026-05-25 16:51:44 +02:00
parent 155fe20862
commit 1259645706
23 changed files with 1206 additions and 630 deletions

View File

@@ -17,21 +17,24 @@ Owns the public-facing Angular 19 marketing site — landing pages, screenshots,
|------|------------|------------------|
| **Marketing site** | The Angular 19 app under `website/`, served separately from the product client. | "landing page" (it has multiple pages) |
| **Release manifest** | The release-metadata JSON the marketing site links to for download buttons; produced by `tools/generate-release-manifest.js` and published by Gitea Workflows. | "version manifest" |
| **IIS SSR bundle** | The built `website/dist/toju-website/` output containing Angular's `browser/`, `server/`, and root `web.config` for iisnode hosting. | "static-only website build" |
## Relationships
- The **Marketing site** links to release artifacts produced by the Gitea Workflows under `.gitea/workflows/release-draft.yml` and `publish-draft-release.yml`.
- `.gitea/workflows/deploy-web-apps.yml` deploys the **IIS SSR bundle** by mirroring the full `website/dist/toju-website/` folder to IIS.
- It does **not** consume the signaling server, the product client, or shared kernel types — independent codebase.
## Boundaries / IO
- **Exposes:** the public website bundle, deployed by `.gitea/workflows/deploy-web-apps.yml`.
- **Exposes:** the public website SSR bundle, deployed by `.gitea/workflows/deploy-web-apps.yml`.
- **Consumes:** the release manifest URL and download links; static assets under `website/src/images/`.
## Invariants
- The marketing site has its own `package.json` and its own Angular version — do **not** hoist its dependencies into the root workspace.
- It must remain functional with no backend (static deploy); any dynamic behavior should fail gracefully.
- It must not depend on the product runtime or signaling server; SSR and the `/api/releases` proxy are website-local Node/IIS concerns.
- `npm run build` must leave `public/web.config` at `dist/toju-website/web.config` so IIS can route requests to `server/server.mjs`.
## Flagged ambiguities

View File

@@ -10,9 +10,10 @@ Angular 19 marketing site for MetoYou / Toju. This package is separate from the
## Commands
- `npm run start` starts the local dev server and uses `proxy.conf.json`.
- `npm run build` builds the site to `dist/toju-website` with the configured SSR/prerender setup.
- `npm run build` builds the site to `dist/toju-website` with the configured SSR/prerender setup and copies `public/web.config` to the release root for IIS.
- `npm run watch` rebuilds in development mode.
- `npm run test` runs the Karma test suite.
- `npm run test:design` runs lightweight Node guards for the homepage design direction and IIS SSR release config.
- `npm run serve:ssr:toju-website` serves the built SSR output.
## Structure
@@ -22,6 +23,8 @@ Angular 19 marketing site for MetoYou / Toju. This package is separate from the
| `src/app/` | Website pages, sections, and shared UI |
| `src/images/` | Marketing images copied to `/images` during build |
| `public/` | Static public assets |
| `public/web.config` | IIS/iisnode entry point for the Angular SSR server |
| `tools/copy-iis-web-config.mjs` | Post-build step that places `web.config` beside `browser/` and `server/` in the release output |
| `proxy.conf.json` | Local development proxy configuration |
| `angular.json` | Angular build, serve, SSR, prerender, and test targets |
@@ -29,4 +32,5 @@ Angular 19 marketing site for MetoYou / Toju. This package is separate from the
- The website is its own Angular workspace and is not installed by the root `npm install`.
- Build output in `dist/toju-website/` is generated.
- IIS deployments publish the full `dist/toju-website/` folder so `server/server.mjs`, `browser/`, and root `web.config` stay together.
- Keep website code isolated from `toju-app/`, `electron/`, and `server/`.

View File

@@ -50,7 +50,10 @@
"assets": [
{
"glob": "**/*",
"input": "public"
"input": "public",
"ignore": [
"web.config"
]
},
{
"glob": "**/*",
@@ -130,7 +133,10 @@
"assets": [
{
"glob": "**/*",
"input": "public"
"input": "public",
"ignore": [
"web.config"
]
},
{
"glob": "**/*",

View File

@@ -5,8 +5,10 @@
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"postbuild": "node tools/copy-iis-web-config.mjs",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"test:design": "node --test tools/website-design.test.mjs tools/iis-release-config.test.mjs",
"serve:ssr:toju-website": "node dist/toju-website/server/server.mjs"
},
"private": true,

View File

@@ -1,6 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<system.webServer>
<handlers>
<add name="iisnode" path="server/server.mjs" verb="*" modules="iisnode" />
</handlers>
<iisnode node_env="production" />
<staticContent>
<remove fileExtension=".wasm" />
<remove fileExtension=".webmanifest" />
@@ -9,13 +13,13 @@
</staticContent>
<rewrite>
<rules>
<rule name="Angular Routes" stopProcessing="true">
<rule name="Release API proxy" stopProcessing="true">
<match url="^api/releases$" />
<action type="Rewrite" url="server/server.mjs" />
</rule>
<rule name="Angular SSR" stopProcessing="true">
<match url=".*" />
<conditions logicalGrouping="MatchAll">
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
</conditions>
<action type="Rewrite" url="/index.html" />
<action type="Rewrite" url="server/server.mjs" />
</rule>
</rules>
</rewrite>

View File

@@ -17,9 +17,7 @@
/>
<span class="text-xl font-bold text-foreground">{{ 'common.brand' | translate }}</span>
</span>
<span
class="ml-2 text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-purple-500/20 text-purple-400 border border-purple-500/30 uppercase tracking-wider"
>
<span class="ml-2 text-[10px] font-semibold px-1.5 py-0.5 rounded-sm bg-primary/15 text-primary border border-primary/25 uppercase tracking-wider">
{{ 'components.header.beta' | translate }}
</span>
</a>
@@ -78,7 +76,7 @@
href="https://web.toju.app/"
target="_blank"
rel="noopener"
class="inline-flex items-center gap-2 px-5 py-2 rounded-lg bg-gradient-to-r from-purple-600 to-violet-600 text-white text-sm font-medium hover:from-purple-500 hover:to-violet-500 transition-all shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40"
class="inline-flex items-center gap-2 px-5 py-2 rounded-md bg-primary text-primary-foreground text-sm font-semibold hover:bg-accent transition-all"
>
{{ 'components.header.useWebVersion' | translate }}
<svg
@@ -175,7 +173,7 @@
href="https://web.toju.app/"
target="_blank"
rel="noopener"
class="inline-flex items-center gap-2 px-5 py-2 rounded-lg bg-gradient-to-r from-purple-600 to-violet-600 text-white text-sm font-medium"
class="inline-flex items-center gap-2 px-5 py-2 rounded-md bg-primary text-primary-foreground text-sm font-semibold"
>
{{ 'components.header.useWebVersion' | translate }}
</a>

View File

@@ -1,538 +1,184 @@
<!-- Hero -->
<section class="relative min-h-screen flex items-center justify-center overflow-hidden">
<!-- Gradient orbs -->
<div
[appParallax]="0.15"
class="absolute top-1/4 -left-32 w-96 h-96 bg-purple-600/20 rounded-full blur-[128px] animate-float"
></div>
<div
[appParallax]="0.25"
class="absolute bottom-1/4 -right-32 w-80 h-80 bg-violet-500/15 rounded-full blur-[100px] animate-float"
style="animation-delay: -3s"
></div>
<section class="home-shell">
<div class="hero-grid">
<div class="hero-copy section-fade">
<p class="eyebrow">{{ 'pages.home.hero.badge' | translate }}</p>
<h1>
<span>{{ 'pages.home.hero.titleLine1' | translate }}</span>
<span>{{ 'pages.home.hero.titleLine2' | translate }}</span>
</h1>
<p class="hero-description">{{ 'pages.home.hero.description' | translate }}</p>
<div class="relative z-10 container mx-auto px-6 text-center pt-32 pb-20">
<div
class="inline-flex items-center gap-2 px-4 py-1.5 rounded-full border border-purple-500/30 bg-purple-500/10 text-purple-400 text-sm font-medium mb-8 animate-fade-in"
>
<span class="w-2 h-2 rounded-full bg-purple-400 animate-pulse"></span>
{{ 'pages.home.hero.badge' | translate }}
</div>
<div class="hero-actions">
@if (downloadUrl()) {
<a
[href]="downloadUrl()"
class="button-primary"
>
{{ 'common.actions.downloadFor' | translate:{ os: getDetectedOsLabel() } }}
<span aria-hidden="true">{{ detectedOS().icon }}</span>
</a>
} @else {
<a
routerLink="/downloads"
class="button-primary"
>
{{ 'common.actions.downloadBrand' | translate }}
</a>
}
<h1 class="text-5xl md:text-7xl lg:text-8xl font-extrabold tracking-tight mb-6 animate-fade-in-up">
<span class="text-foreground">{{ 'pages.home.hero.titleLine1' | translate }}</span><br />
<span class="gradient-text">{{ 'pages.home.hero.titleLine2' | translate }}</span>
</h1>
<p
class="text-lg md:text-xl text-muted-foreground max-w-2xl mx-auto mb-10 leading-relaxed animate-fade-in-up"
style="animation-delay: 0.2s"
>
{{ 'pages.home.hero.description' | translate }}
</p>
<!-- CTA Buttons -->
<div
class="flex flex-col sm:flex-row items-center justify-center gap-4 mb-6 animate-fade-in-up"
style="animation-delay: 0.4s"
>
@if (downloadUrl()) {
<a
[href]="downloadUrl()"
class="group inline-flex items-center gap-3 px-8 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold text-lg hover:from-purple-500 hover:to-violet-500 transition-all shadow-2xl shadow-purple-500/25 hover:shadow-purple-500/40 animate-glow-pulse"
>
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
{{ 'common.actions.downloadFor' | translate:{ os: getDetectedOsLabel() } }}
<span class="text-sm opacity-75">{{ detectedOS().icon }}</span>
</a>
} @else {
<a
routerLink="/downloads"
class="group inline-flex items-center gap-3 px-8 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold text-lg hover:from-purple-500 hover:to-violet-500 transition-all shadow-2xl shadow-purple-500/25 hover:shadow-purple-500/40"
>
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
{{ 'common.actions.downloadBrand' | translate }}
</a>
}
<a
href="https://web.toju.app/"
target="_blank"
rel="noopener"
class="inline-flex items-center gap-2 px-8 py-4 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium text-lg hover:bg-card hover:border-purple-500/30 transition-all backdrop-blur-sm"
>
{{ 'common.actions.openInBrowser' | translate }}
<svg
class="w-5 h-5 opacity-60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</div>
@if (latestVersion()) {
<p
class="text-xs text-muted-foreground/60 animate-fade-in"
style="animation-delay: 0.6s"
>
{{ 'pages.home.hero.version' | translate:{ version: latestVersion() } }} ·
<a
routerLink="/downloads"
class="underline hover:text-muted-foreground transition-colors"
>{{ 'pages.home.hero.allPlatforms' | translate }}</a
>
</p>
}
<!-- Scroll indicator -->
<div class="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce">
<svg
class="w-6 h-6 text-muted-foreground/40"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
</div>
</div>
</section>
<app-ad-slot />
<!-- Features Grid -->
<section class="relative py-32">
<div class="container mx-auto px-6">
<div class="text-center mb-20 section-fade">
<h2 class="text-3xl md:text-5xl font-bold text-foreground mb-4">
{{ 'pages.home.features.titleLine1' | translate }}<br />
<span class="gradient-text">{{ 'pages.home.features.titleLine2' | translate }}</span>
</h2>
<p class="text-muted-foreground text-lg max-w-xl mx-auto">{{ 'pages.home.features.description' | translate }}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Voice Calls -->
<div
class="section-fade group relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 hover:border-purple-500/30 hover:bg-card/50 transition-all duration-300"
>
<div class="w-12 h-12 rounded-xl bg-purple-500/10 flex items-center justify-center mb-6 group-hover:bg-purple-500/20 transition-colors">
<svg
class="w-6 h-6 text-purple-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
/>
</svg>
</div>
<h3 class="text-xl font-semibold text-foreground mb-3">{{ 'pages.home.features.items.voiceCalls.title' | translate }}</h3>
<p class="text-muted-foreground leading-relaxed">
{{ 'pages.home.features.items.voiceCalls.description' | translate }}
</p>
</div>
<!-- Screen Sharing -->
<div
class="section-fade group relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 hover:border-purple-500/30 hover:bg-card/50 transition-all duration-300"
style="transition-delay: 0.1s"
>
<div class="w-12 h-12 rounded-xl bg-violet-500/10 flex items-center justify-center mb-6 group-hover:bg-violet-500/20 transition-colors">
<svg
class="w-6 h-6 text-violet-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<h3 class="text-xl font-semibold text-foreground mb-3">{{ 'pages.home.features.items.screenSharing.title' | translate }}</h3>
<p class="text-muted-foreground leading-relaxed">
{{ 'pages.home.features.items.screenSharing.description' | translate }}
</p>
</div>
<!-- File Sharing -->
<div
class="section-fade group relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 hover:border-purple-500/30 hover:bg-card/50 transition-all duration-300"
style="transition-delay: 0.2s"
>
<div class="w-12 h-12 rounded-xl bg-pink-500/10 flex items-center justify-center mb-6 group-hover:bg-pink-500/20 transition-colors">
<svg
class="w-6 h-6 text-pink-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
</div>
<h3 class="text-xl font-semibold text-foreground mb-3">{{ 'pages.home.features.items.fileSharing.title' | translate }}</h3>
<p class="text-muted-foreground leading-relaxed">
{{ 'pages.home.features.items.fileSharing.description' | translate }}
</p>
</div>
<!-- Privacy -->
<div
class="section-fade group relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 hover:border-purple-500/30 hover:bg-card/50 transition-all duration-300"
>
<div class="w-12 h-12 rounded-xl bg-emerald-500/10 flex items-center justify-center mb-6 group-hover:bg-emerald-500/20 transition-colors">
<svg
class="w-6 h-6 text-emerald-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
<h3 class="text-xl font-semibold text-foreground mb-3">{{ 'pages.home.features.items.privacy.title' | translate }}</h3>
<p class="text-muted-foreground leading-relaxed">
{{ 'pages.home.features.items.privacy.description' | translate }}
</p>
</div>
<!-- Open Source -->
<div
class="section-fade group relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 hover:border-purple-500/30 hover:bg-card/50 transition-all duration-300"
style="transition-delay: 0.1s"
>
<div class="w-12 h-12 rounded-xl bg-blue-500/10 flex items-center justify-center mb-6 group-hover:bg-blue-500/20 transition-colors">
<svg
class="w-6 h-6 text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
/>
</svg>
</div>
<h3 class="text-xl font-semibold text-foreground mb-3">{{ 'pages.home.features.items.openSource.title' | translate }}</h3>
<p class="text-muted-foreground leading-relaxed">
{{ 'pages.home.features.items.openSource.description' | translate }}
</p>
</div>
<!-- Free -->
<div
class="section-fade group relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 hover:border-purple-500/30 hover:bg-card/50 transition-all duration-300"
style="transition-delay: 0.2s"
>
<div class="w-12 h-12 rounded-xl bg-amber-500/10 flex items-center justify-center mb-6 group-hover:bg-amber-500/20 transition-colors">
<svg
class="w-6 h-6 text-amber-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
</div>
<h3 class="text-xl font-semibold text-foreground mb-3">{{ 'pages.home.features.items.free.title' | translate }}</h3>
<p class="text-muted-foreground leading-relaxed">
{{ 'pages.home.features.items.free.description' | translate }}
</p>
</div>
</div>
</div>
</section>
<app-ad-slot />
<!-- Gaming Section -->
<section class="relative py-32">
<div class="absolute inset-0 bg-gradient-to-b from-transparent via-purple-950/10 to-transparent"></div>
<div class="relative container mx-auto px-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
<div class="section-fade">
<div
class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 text-sm font-medium mb-6"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{{ 'pages.home.gaming.badge' | translate }}
</div>
<h2 class="text-3xl md:text-5xl font-bold text-foreground mb-6">
{{ 'pages.home.gaming.titleLine1' | translate }}<br />
<span class="gradient-text">{{ 'pages.home.gaming.titleLine2' | translate }}</span>
</h2>
<p class="text-lg text-muted-foreground leading-relaxed mb-8">
{{ 'pages.home.gaming.description' | translate }}
</p>
<ul class="space-y-4">
<li class="flex items-center gap-3 text-muted-foreground">
<svg
class="w-5 h-5 text-purple-400 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span>{{ 'pages.home.gaming.bullets.lowLatency' | translate }}</span>
</li>
<li class="flex items-center gap-3 text-muted-foreground">
<svg
class="w-5 h-5 text-purple-400 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span>{{ 'pages.home.gaming.bullets.noiseSuppression' | translate }}</span>
</li>
<li class="flex items-center gap-3 text-muted-foreground">
<svg
class="w-5 h-5 text-purple-400 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span>{{ 'pages.home.gaming.bullets.screenShare' | translate }}</span>
</li>
<li class="flex items-center gap-3 text-muted-foreground">
<svg
class="w-5 h-5 text-purple-400 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span>{{ 'pages.home.gaming.bullets.fileTransfers' | translate }}</span>
</li>
</ul>
</div>
<div class="section-fade relative">
<div class="relative rounded-2xl overflow-hidden border border-border/30 bg-card/30 backdrop-blur-sm aspect-video">
<img
ngSrc="/images/screenshots/screenshare_gaming.png"
fill
priority
[attr.alt]="'pages.home.gaming.imageAlt' | translate"
class="object-cover"
/>
<div class="absolute inset-0 bg-gradient-to-t from-background/80 via-transparent to-transparent"></div>
<div class="absolute bottom-4 left-4 text-sm text-muted-foreground/60">{{ 'pages.home.gaming.caption' | translate }}</div>
</div>
<!-- Glow effect -->
<div class="absolute -inset-4 bg-purple-600/5 rounded-3xl blur-2xl -z-10"></div>
</div>
</div>
</div>
</section>
<app-ad-slot />
<!-- Self-hostable Section -->
<section class="relative py-32">
<div class="container mx-auto px-6">
<div class="section-fade max-w-3xl mx-auto text-center">
<div
class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-sm font-medium mb-6"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"
/>
</svg>
{{ 'pages.home.selfHostable.badge' | translate }}
</div>
<h2 class="text-3xl md:text-5xl font-bold text-foreground mb-6">
{{ 'pages.home.selfHostable.titleLine1' | translate }}<br />
<span class="gradient-text">{{ 'pages.home.selfHostable.titleLine2' | translate }}</span>
</h2>
<p class="text-lg text-muted-foreground leading-relaxed mb-8">
{{ 'pages.home.selfHostable.description' | translate }}
</p>
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
<a
routerLink="/what-is-toju"
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium hover:border-purple-500/30 transition-all"
>
{{ 'common.actions.learnHowItWorks' | translate }}
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</a>
<a
href="https://git.azaaxin.com/myxelium/Toju"
href="https://web.toju.app/"
target="_blank"
rel="noopener"
class="inline-flex items-center gap-2 px-6 py-3 text-muted-foreground hover:text-foreground transition-colors text-sm"
class="button-secondary"
>
<img
src="/images/gitea.png"
alt=""
width="16"
height="16"
class="w-4 h-4 object-contain"
/>
{{ 'common.actions.viewSourceCode' | translate }}
{{ 'common.actions.openInBrowser' | translate }}
</a>
</div>
@if (latestVersion()) {
<p class="release-note">
{{ 'pages.home.hero.version' | translate:{ version: latestVersion() } }}
<a routerLink="/downloads">{{ 'pages.home.hero.allPlatforms' | translate }}</a>
</p>
}
</div>
<div class="hero-product section-fade">
<img
ngSrc="/images/screenshots/screenshot_main.png"
width="1320"
height="860"
priority
[attr.alt]="'pages.gallery.featured.imageAlt' | translate"
/>
<div class="product-note">
<strong>{{ 'pages.home.features.items.privacy.title' | translate }}</strong>
<span>{{ 'pages.home.features.items.privacy.description' | translate }}</span>
</div>
</div>
</div>
</section>
<!-- CTA Banner -->
<section class="relative py-24">
<div class="absolute inset-0 bg-gradient-to-r from-purple-950/20 via-violet-950/30 to-purple-950/20"></div>
<div class="relative container mx-auto px-6 text-center section-fade">
<h2 class="text-3xl md:text-4xl font-bold text-foreground mb-4">{{ 'pages.home.cta.title' | translate }}</h2>
<p class="text-muted-foreground text-lg mb-8 max-w-lg mx-auto">{{ 'pages.home.cta.description' | translate }}</p>
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
@if (downloadUrl()) {
<a
[href]="downloadUrl()"
class="inline-flex items-center gap-2 px-8 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold hover:from-purple-500 hover:to-violet-500 transition-all shadow-2xl shadow-purple-500/25"
>
{{ 'common.actions.downloadFor' | translate:{ os: getDetectedOsLabel() } }}
</a>
} @else {
<a
routerLink="/downloads"
class="inline-flex items-center gap-2 px-8 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold hover:from-purple-500 hover:to-violet-500 transition-all shadow-2xl shadow-purple-500/25"
>
{{ 'common.actions.downloadBrand' | translate }}
</a>
}
<a
href="https://web.toju.app/"
target="_blank"
rel="noopener"
class="inline-flex items-center gap-2 px-8 py-4 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium hover:border-purple-500/30 transition-all"
>
{{ 'common.actions.tryInBrowser' | translate }}
</a>
</div>
<app-ad-slot />
<section class="feature-editorial">
<div class="section-heading section-fade">
<p class="eyebrow">{{ 'pages.home.features.titleLine1' | translate }}</p>
<h2>{{ 'pages.home.features.titleLine2' | translate }}</h2>
<p>{{ 'pages.home.features.description' | translate }}</p>
</div>
<div class="feature-layout">
<article class="feature-panel feature-panel-large section-fade">
<span>01</span>
<h3>{{ 'pages.home.features.items.voiceCalls.title' | translate }}</h3>
<p>{{ 'pages.home.features.items.voiceCalls.description' | translate }}</p>
</article>
<article class="feature-panel section-fade">
<span>02</span>
<h3>{{ 'pages.home.features.items.screenSharing.title' | translate }}</h3>
<p>{{ 'pages.home.features.items.screenSharing.description' | translate }}</p>
</article>
<article class="feature-panel section-fade">
<span>03</span>
<h3>{{ 'pages.home.features.items.fileSharing.title' | translate }}</h3>
<p>{{ 'pages.home.features.items.fileSharing.description' | translate }}</p>
</article>
<article class="feature-panel feature-panel-wide section-fade">
<span>04</span>
<h3>{{ 'pages.home.features.items.openSource.title' | translate }}</h3>
<p>{{ 'pages.home.features.items.openSource.description' | translate }}</p>
</article>
<article class="feature-panel feature-panel-quiet section-fade">
<span>05</span>
<h3>{{ 'pages.home.features.items.free.title' | translate }}</h3>
<p>{{ 'pages.home.features.items.free.description' | translate }}</p>
</article>
</div>
</section>
<app-ad-slot />
<section class="proof-section">
<div class="proof-image section-fade">
<img
ngSrc="/images/screenshots/screenshare_gaming.png"
width="1280"
height="720"
[attr.alt]="'pages.home.gaming.imageAlt' | translate"
/>
</div>
<div class="proof-copy section-fade">
<p class="eyebrow">{{ 'pages.home.gaming.badge' | translate }}</p>
<h2>
{{ 'pages.home.gaming.titleLine1' | translate }}
{{ 'pages.home.gaming.titleLine2' | translate }}
</h2>
<p>{{ 'pages.home.gaming.description' | translate }}</p>
<ul>
<li>{{ 'pages.home.gaming.bullets.lowLatency' | translate }}</li>
<li>{{ 'pages.home.gaming.bullets.noiseSuppression' | translate }}</li>
<li>{{ 'pages.home.gaming.bullets.screenShare' | translate }}</li>
<li>{{ 'pages.home.gaming.bullets.fileTransfers' | translate }}</li>
</ul>
</div>
</section>
<app-ad-slot />
<section class="host-section section-fade">
<div>
<p class="eyebrow">{{ 'pages.home.selfHostable.badge' | translate }}</p>
<h2>
{{ 'pages.home.selfHostable.titleLine1' | translate }}
{{ 'pages.home.selfHostable.titleLine2' | translate }}
</h2>
</div>
<div class="host-section-copy">
<p>{{ 'pages.home.selfHostable.description' | translate }}</p>
<a
routerLink="/what-is-toju"
class="text-link"
>
{{ 'common.actions.learnHowItWorks' | translate }}
</a>
</div>
</section>
<section class="closing-cta section-fade">
<p class="eyebrow">Toju</p>
<h2>{{ 'pages.home.cta.title' | translate }}</h2>
<p>{{ 'pages.home.cta.description' | translate }}</p>
<div class="hero-actions">
@if (downloadUrl()) {
<a
[href]="downloadUrl()"
class="button-primary"
>
{{ 'common.actions.downloadFor' | translate:{ os: getDetectedOsLabel() } }}
</a>
} @else {
<a
routerLink="/downloads"
class="button-primary"
>
{{ 'common.actions.downloadBrand' | translate }}
</a>
}
<a
href="https://web.toju.app/"
target="_blank"
rel="noopener"
class="button-secondary"
>
{{ 'common.actions.tryInBrowser' | translate }}
</a>
</div>
</section>

View File

@@ -0,0 +1,341 @@
:host {
display: block;
}
.home-shell,
.feature-editorial,
.proof-section,
.host-section,
.closing-cta {
width: min(100% - 2rem, 1240px);
margin: 0 auto;
}
.home-shell {
min-height: 100dvh;
padding: 9rem 0 5rem;
}
.hero-grid {
display: grid;
grid-template-columns: minmax(0, 0.78fr) minmax(22rem, 1fr);
gap: clamp(2rem, 5vw, 5.5rem);
align-items: center;
}
.hero-copy h1,
.section-heading h2,
.proof-copy h2,
.host-section h2,
.closing-cta h2 {
margin: 0;
color: hsl(var(--foreground));
font-size: clamp(3.5rem, 8vw, 7.5rem);
line-height: 0.92;
font-weight: 800;
letter-spacing: 0;
text-wrap: balance;
}
.hero-copy h1 span {
display: block;
}
.eyebrow {
margin: 0 0 1.25rem;
color: hsl(var(--primary));
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hero-description,
.section-heading p,
.proof-copy p,
.host-section p,
.closing-cta p,
.feature-panel p {
color: hsl(var(--muted-foreground));
line-height: 1.75;
text-wrap: pretty;
}
.hero-description {
max-width: 35rem;
margin: 2rem 0 0;
font-size: clamp(1.05rem, 2vw, 1.35rem);
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
margin-top: 2rem;
}
.button-primary,
.button-secondary,
.text-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 3rem;
border-radius: 0.45rem;
font-weight: 700;
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease, color 180ms ease;
}
.button-primary {
padding: 0.85rem 1.2rem;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
box-shadow: 0 1rem 2.5rem hsl(var(--primary) / 0.16);
}
.button-secondary {
padding: 0.85rem 1.1rem;
border: 1px solid hsl(var(--border));
color: hsl(var(--foreground));
background: hsl(var(--card) / 0.74);
}
.button-primary:hover,
.button-secondary:hover,
.text-link:hover {
transform: translateY(-2px);
}
.button-primary:active,
.button-secondary:active,
.text-link:active {
transform: translateY(1px);
}
.release-note {
margin-top: 1.25rem;
color: hsl(var(--muted-foreground));
font-size: 0.84rem;
}
.release-note a,
.text-link {
margin-left: 0.75rem;
color: hsl(var(--primary));
}
.hero-product {
position: relative;
overflow: hidden;
border: 1px solid hsl(var(--border));
border-radius: 0.85rem;
background: linear-gradient(180deg, hsl(var(--card)), hsl(var(--background)));
box-shadow: 0 2rem 5rem hsl(0 0% 0% / 0.28);
}
.hero-product img,
.proof-image img {
display: block;
width: 100%;
height: auto;
}
.product-note {
position: absolute;
right: 1rem;
bottom: 1rem;
width: min(22rem, calc(100% - 2rem));
padding: 1rem;
border: 1px solid hsl(var(--border));
border-radius: 0.65rem;
background: hsl(var(--background) / 0.84);
backdrop-filter: blur(18px);
}
.product-note strong,
.product-note span {
display: block;
}
.product-note span {
margin-top: 0.35rem;
color: hsl(var(--muted-foreground));
font-size: 0.86rem;
line-height: 1.5;
}
.feature-editorial,
.proof-section,
.host-section,
.closing-cta {
padding: 6rem 0;
}
.section-heading {
max-width: 54rem;
margin-bottom: 2.75rem;
}
.section-heading h2,
.proof-copy h2,
.host-section h2,
.closing-cta h2 {
font-size: clamp(2.4rem, 5vw, 5.25rem);
line-height: 1;
}
.section-heading p {
max-width: 36rem;
margin: 1rem 0 0;
font-size: 1.08rem;
}
.feature-layout {
display: grid;
grid-template-columns: 1.15fr 0.85fr 0.85fr;
gap: 1rem;
}
.feature-panel {
min-height: 18rem;
padding: clamp(1.25rem, 3vw, 2rem);
border: 1px solid hsl(var(--border));
border-radius: 0.7rem;
background: hsl(var(--card));
}
.feature-panel-large {
grid-row: span 2;
min-height: 37rem;
background: hsl(var(--primary) / 0.12);
}
.feature-panel-wide {
grid-column: span 2;
}
.feature-panel-quiet {
background: transparent;
}
.feature-panel span {
color: hsl(var(--primary));
font-family: var(--font-mono);
font-size: 0.82rem;
font-variant-numeric: tabular-nums;
}
.feature-panel h3 {
margin: 3rem 0 0.8rem;
color: hsl(var(--foreground));
font-size: clamp(1.4rem, 3vw, 2.6rem);
line-height: 1.06;
text-wrap: balance;
}
.proof-section {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(20rem, 0.7fr);
gap: clamp(2rem, 5vw, 5rem);
align-items: center;
}
.proof-image {
overflow: hidden;
border: 1px solid hsl(var(--border));
border-radius: 0.8rem;
background: hsl(var(--card));
}
.proof-copy ul {
display: grid;
gap: 0.75rem;
padding: 0;
margin: 1.5rem 0 0;
list-style: none;
}
.proof-copy li {
padding-left: 1rem;
border-left: 2px solid hsl(var(--primary));
color: hsl(var(--muted-foreground));
line-height: 1.55;
}
.host-section {
display: grid;
grid-template-columns: minmax(0, 0.82fr) minmax(0, 1fr);
gap: clamp(2rem, 6vw, 5rem);
align-items: start;
border-top: 1px solid hsl(var(--border));
border-bottom: 1px solid hsl(var(--border));
}
.host-section p {
margin: 0;
}
.host-section-copy {
display: grid;
gap: 1.2rem;
max-width: 38rem;
padding-top: 0.25rem;
}
.host-section-copy .text-link {
justify-self: start;
margin-left: 0;
}
.closing-cta {
text-align: left;
}
.closing-cta p:not(.eyebrow) {
max-width: 34rem;
}
@media (max-width: 980px) {
.hero-grid,
.proof-section,
.host-section {
grid-template-columns: 1fr;
}
.feature-layout {
grid-template-columns: 1fr 1fr;
}
.feature-panel-large,
.feature-panel-wide {
grid-column: span 2;
min-height: 20rem;
}
}
@media (max-width: 640px) {
.home-shell {
padding-top: 7rem;
}
.feature-layout {
grid-template-columns: 1fr;
}
.feature-panel-large,
.feature-panel-wide {
grid-column: auto;
}
.product-note {
position: static;
width: auto;
border-width: 1px 0 0;
border-radius: 0;
}
.button-primary,
.button-secondary {
width: 100%;
}
}

View File

@@ -14,7 +14,6 @@ import { ReleaseService, DetectedOS } from '../../services/release.service';
import { SeoService } from '../../services/seo.service';
import { ScrollAnimationService } from '../../services/scroll-animation.service';
import { AdSlotComponent } from '../../components/ad-slot/ad-slot.component';
import { ParallaxDirective } from '../../directives/parallax.directive';
@Component({
selector: 'app-home',
@@ -23,10 +22,10 @@ import { ParallaxDirective } from '../../directives/parallax.directive';
NgOptimizedImage,
TranslateModule,
RouterLink,
AdSlotComponent,
ParallaxDirective
AdSlotComponent
],
templateUrl: './home.component.html'
templateUrl: './home.component.html',
styleUrl: './home.component.scss'
})
export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
readonly detectedOS = signal<DetectedOS>({

View File

@@ -1,4 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Outfit:wght@400;500;600;700;800&display=swap');
@tailwind base;
@tailwind components;
@@ -6,22 +6,24 @@
@layer base {
:root {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 6%;
--card-foreground: 0 0% 98%;
--primary: 262.1 83.3% 57.8%;
--primary-foreground: 210 20% 98%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 262.1 83.3% 57.8%;
--accent-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 262.1 83.3% 57.8%;
--radius: 0.75rem;
--background: 210 18% 7%;
--foreground: 42 33% 94%;
--card: 210 17% 10%;
--card-foreground: 42 33% 94%;
--primary: 154 49% 55%;
--primary-foreground: 210 18% 7%;
--secondary: 210 14% 15%;
--secondary-foreground: 42 33% 94%;
--muted: 210 14% 15%;
--muted-foreground: 42 13% 67%;
--accent: 38 64% 61%;
--accent-foreground: 210 18% 7%;
--border: 210 13% 22%;
--input: 210 13% 22%;
--ring: 154 49% 55%;
--radius: 0.6rem;
--font-sans: 'Outfit', system-ui, sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
}
* {
@@ -33,8 +35,29 @@
}
body {
@apply bg-background text-foreground font-sans antialiased;
@apply bg-background text-foreground antialiased;
font-family: var(--font-sans);
font-feature-settings: 'rlig' 1, 'calt' 1;
background:
radial-gradient(circle at top left, hsl(var(--primary) / 0.08), transparent 26rem),
linear-gradient(180deg, hsl(210 18% 8%), hsl(var(--background)) 34rem);
}
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 100;
opacity: 0.035;
background-image: linear-gradient(90deg, hsl(var(--foreground)) 1px, transparent 1px), linear-gradient(0deg, hsl(var(--foreground)) 1px, transparent 1px);
background-size: 22px 22px;
}
a,
button {
outline-color: hsl(var(--ring));
outline-offset: 3px;
}
}
@@ -57,12 +80,12 @@
/* Utility classes */
.glass {
backdrop-filter: blur(16px) saturate(180%);
background: hsl(var(--background) / 0.7);
border: 1px solid hsl(var(--border) / 0.3);
background: hsl(var(--background) / 0.82);
border-bottom: 1px solid hsl(var(--border));
}
.gradient-text {
background: linear-gradient(135deg, hsl(var(--primary)), hsl(280 90% 70%), hsl(320 80% 65%));
background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--accent)));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -76,7 +99,7 @@
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, hsl(var(--primary)), hsl(280 90% 70%), transparent);
background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--accent)), transparent);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;

View File

@@ -44,8 +44,8 @@ module.exports = {
sm: 'calc(var(--radius) - 4px)',
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
sans: ['Outfit', 'system-ui', '-apple-system', 'sans-serif'],
mono: ['IBM Plex Mono', 'Fira Code', 'monospace'],
},
animation: {
'fade-in': 'fadeIn 0.6s ease-out forwards',

View File

@@ -0,0 +1,10 @@
import { copyFileSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const websiteRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const source = resolve(websiteRoot, 'public/web.config');
const destination = resolve(websiteRoot, 'dist/toju-website/web.config');
mkdirSync(dirname(destination), { recursive: true });
copyFileSync(source, destination);

View File

@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
const webConfig = readFileSync(new URL('../public/web.config', import.meta.url), 'utf8');
const deployScript = readFileSync(new URL('../../tools/deploy-web-apps.ps1', import.meta.url), 'utf8');
const angularConfig = readFileSync(new URL('../angular.json', import.meta.url), 'utf8');
test('website web.config routes IIS requests through the Angular SSR server', () => {
assert.match(webConfig, /iisnode/i);
assert.match(webConfig, /server\.mjs/);
assert.match(webConfig, /node_env/i);
assert.match(webConfig, /api\/releases/i);
});
test('IIS deployment publishes the full SSR output instead of only browser assets', () => {
assert.match(deployScript, /website\\dist\\toju-website(?!\\browser)/);
});
test('web.config is kept at the SSR release root, not copied as a browser asset', () => {
assert.match(angularConfig, /"ignore":\s*\[\s*"web\.config"\s*\]/);
});

View File

@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
const homeTemplate = readFileSync(new URL('../src/app/pages/home/home.component.html', import.meta.url), 'utf8');
const homeStyles = readFileSync(new URL('../src/app/pages/home/home.component.scss', import.meta.url), 'utf8');
const styles = readFileSync(new URL('../src/styles.scss', import.meta.url), 'utf8');
test('home page avoids common AI-generated visual patterns', () => {
const bannedPatterns = [
/Gradient orbs/i,
/gradient-text/,
/from-purple-600\s+to-violet-600/,
/lg:grid-cols-3/,
/blur-\[128px\]/
];
for (const pattern of bannedPatterns) {
assert.doesNotMatch(homeTemplate, pattern);
}
});
test('global palette uses a restrained product-led brand system', () => {
assert.doesNotMatch(styles, /Inter:wght/);
assert.doesNotMatch(styles, /hsl\(280\s+90%\s+70%\)/);
assert.match(styles, /--primary:\s*\d+\s+\d+%\s+\d+%/);
});
test('hero screenshot does not add duplicate app chrome', () => {
assert.doesNotMatch(homeTemplate, /window-bar/);
assert.doesNotMatch(homeStyles, /\.window-bar/);
});
test('self-hostable section keeps title and copy in separate readable columns', () => {
assert.doesNotMatch(homeStyles, /\.host-section\s*{[^}]*align-items:\s*end/s);
assert.match(homeStyles, /\.host-section\s*{[^}]*align-items:\s*start/s);
assert.match(homeStyles, /\.host-section-copy/);
});