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

@@ -25,6 +25,13 @@ Durable rules for AI agents working on this project. Read this file at session s
## Lessons ## Lessons
### Do not add fake chrome around screenshots [website] [design]
- **Trigger:** wrapping a real product screenshot in decorative titlebar/window chrome or placing oversized marketing headings beside copy without checking overlap.
- **Rule:** use the screenshot's existing frame when it already includes app chrome, and top-align large heading/copy columns with explicit readable widths.
- **Why:** duplicated chrome makes CTA/product previews look broken, and bottom-aligned large headings can cover accompanying text on the marketing site.
- **Example:** `website/src/app/pages/home/home.component.html` should render the screenshot directly; `host-section` should use top-aligned heading and `.host-section-copy` columns.
### Verify lint exits 0 before claiming done [verification] ### Verify lint exits 0 before claiming done [verification]
- **Trigger:** about to report a task as complete after running tests but skipping ESLint. - **Trigger:** about to report a task as complete after running tests but skipping ESLint.

View File

@@ -0,0 +1,165 @@
{
"meta": {
"name": "Toju Website Dark",
"version": "1.0.0",
"description": "Website-inspired dark app theme using the charcoal, green, and amber palette from the public Toju site."
},
"css": "/* Website-inspired app shell surfaces */\napp-chat-messages .chat-layout,\napp-dm-chat .chat-layout {\n background-image: linear-gradient(180deg, hsl(var(--workspace-background) / 0.96), hsl(var(--background)) 38rem) !important;\n}\n\napp-chat-message-list > div {\n background: transparent !important;\n}\n\napp-chat-message-item > div[data-message-id] {\n margin: 8px 0 !important;\n border: 1px solid hsl(var(--border) / 0.56) !important;\n border-radius: 0.7rem !important;\n background: hsl(var(--card) / 0.72) !important;\n box-shadow: 0 14px 34px rgb(0 0 0 / 0.2) !important;\n backdrop-filter: blur(12px) saturate(120%) !important;\n -webkit-backdrop-filter: blur(12px) saturate(120%) !important;\n}\n\napp-chat-message-item > div[data-message-id]:hover {\n border-color: hsl(var(--primary) / 0.42) !important;\n background: hsl(var(--card) / 0.84) !important;\n}\n\napp-chat-message-composer,\n.chat-bottom-bar {\n border-top: 0 !important;\n background: hsl(var(--background) / 0.84) !important;\n backdrop-filter: blur(16px) saturate(125%) !important;\n -webkit-backdrop-filter: blur(16px) saturate(125%) !important;\n}\n\napp-chat-message-composer textarea,\napp-chat-message-composer [contenteditable=\"true\"] {\n background: hsl(var(--panel-background-alt) / 0.8) !important;\n}\n",
"tokens": {
"colors": {
"background": "210 18% 7%",
"foreground": "42 33% 94%",
"card": "210 17% 10%",
"cardForeground": "42 33% 94%",
"popover": "210 17% 9%",
"popoverForeground": "42 33% 94%",
"primary": "154 49% 55%",
"primaryForeground": "210 18% 7%",
"secondary": "210 14% 15%",
"secondaryForeground": "42 33% 94%",
"muted": "210 14% 15%",
"mutedForeground": "42 13% 67%",
"accent": "38 64% 61%",
"accentForeground": "210 18% 7%",
"destructive": "0 72% 55%",
"destructiveForeground": "0 0% 100%",
"border": "210 13% 22%",
"input": "210 13% 22%",
"ring": "154 49% 55%",
"railBackground": "210 19% 6%",
"workspaceBackground": "210 18% 8%",
"panelBackground": "210 17% 10%",
"panelBackgroundAlt": "210 14% 13%",
"titleBarBackground": "210 19% 6%",
"surfaceHighlight": "154 49% 55%",
"surfaceHighlightAlt": "38 64% 61%"
},
"spacing": {},
"radii": {
"radius": "0.6rem",
"surface": "0.85rem",
"pill": "999px"
},
"effects": {
"panelShadow": "0 28px 64px rgba(0, 0, 0, 0.34)",
"softShadow": "0 16px 36px rgba(0, 0, 0, 0.22)",
"glassBlur": "blur(16px) saturate(125%)"
}
},
"layout": {
"serversRail": {
"container": "appShell",
"grid": {
"x": 0,
"y": 0,
"w": 1,
"h": 1
}
},
"appWorkspace": {
"container": "appShell",
"grid": {
"x": 1,
"y": 0,
"w": 19,
"h": 1
}
},
"chatRoomChannelsPanel": {
"container": "roomLayout",
"grid": {
"x": 0,
"y": 0,
"w": 4,
"h": 12
}
},
"chatRoomMainPanel": {
"container": "roomLayout",
"grid": {
"x": 4,
"y": 0,
"w": 12,
"h": 12
}
},
"chatRoomMembersPanel": {
"container": "roomLayout",
"grid": {
"x": 16,
"y": 0,
"w": 4,
"h": 12
}
},
"dmConversationsPanel": {
"container": "dmLayout",
"grid": {
"x": 0,
"y": 0,
"w": 4,
"h": 12
}
},
"dmChatPanel": {
"container": "dmLayout",
"grid": {
"x": 4,
"y": 0,
"w": 16,
"h": 12
}
}
},
"elements": {
"titleBar": {
"border": "0",
"backgroundColor": "hsl(var(--title-bar-background) / 0.88)",
"boxShadow": "0 12px 28px rgba(0, 0, 0, 0.22)",
"backdropFilter": "var(--theme-effect-glass-blur)"
},
"serversRail": {
"border": "0",
"backgroundColor": "hsl(var(--rail-background) / 0.94)",
"boxShadow": "var(--theme-effect-panel-shadow)"
},
"appWorkspace": {
"backgroundColor": "hsl(var(--workspace-background))"
},
"chatRoomChannelsPanel": {
"border": "0",
"backgroundColor": "hsl(var(--panel-background) / 0.86)",
"backdropFilter": "var(--theme-effect-glass-blur)"
},
"chatRoomMembersPanel": {
"border": "0",
"backgroundColor": "hsl(var(--panel-background) / 0.82)",
"backdropFilter": "var(--theme-effect-glass-blur)"
},
"chatRoomMainPanel": {
"backgroundColor": "hsl(var(--workspace-background))"
},
"chatSurface": {
"backgroundColor": "transparent"
},
"chatMessageBubble": {
"border": "1px solid hsl(var(--border) / 0.56)",
"borderRadius": "0.7rem",
"backgroundColor": "hsl(var(--card) / 0.72)",
"boxShadow": "var(--theme-effect-soft-shadow)",
"backdropFilter": "blur(12px) saturate(120%)"
},
"chatComposerBar": {
"border": "0",
"backgroundColor": "hsl(var(--background) / 0.84)",
"backdropFilter": "var(--theme-effect-glass-blur)"
},
"chatComposerInput": {
"border": "1px solid hsl(var(--border) / 0.6)",
"borderRadius": "0.6rem",
"backgroundColor": "hsl(var(--panel-background-alt) / 0.8)",
"color": "hsl(var(--foreground))"
}
},
"animations": {}
}

View File

@@ -18,7 +18,7 @@ theme/
│ ├── constants/ │ ├── constants/
│ │ └── theme-llm-guide.constants.ts LLM prompt context for AI-assisted theme editing │ │ └── theme-llm-guide.constants.ts LLM prompt context for AI-assisted theme editing
│ ├── logic/ │ ├── logic/
│ │ ├── theme-defaults.logic.ts Default theme document, JSON template, legacy detection │ │ ├── theme-defaults.logic.ts Built-in presets, default theme document, JSON template, legacy detection
│ │ ├── theme-registry.logic.ts Static registry of themeable elements and layout containers │ │ ├── theme-registry.logic.ts Static registry of themeable elements and layout containers
│ │ ├── theme-schema.logic.ts Schema field definitions, animation starters, suggested defaults │ │ ├── theme-schema.logic.ts Schema field definitions, animation starters, suggested defaults
│ │ └── theme-validation.logic.ts Theme document validation against registry + schema │ │ └── theme-validation.logic.ts Theme document validation against registry + schema
@@ -42,6 +42,12 @@ theme/
└── index.ts Barrel exports └── index.ts Barrel exports
``` ```
## Built-in presets
`theme-defaults.logic.ts` exports `BUILT_IN_THEME_PRESETS` for themes that ship with the app and do not depend on the Electron saved-theme library. The default preset is `Toju Website Dark`, which mirrors the website palette and removes the previous green radial chat background bubble. The previous app default remains available as `Toju Default Dark` and can be applied from Theme Studio.
The importable artifact at `project-files/themes/toju-website-dark.json` is kept byte-for-byte aligned with `DEFAULT_THEME_JSON` by the ThemeService spec.
## Layer composition ## Layer composition
```mermaid ```mermaid

View File

@@ -1,7 +1,13 @@
import { DOCUMENT } from '@angular/common'; import { DOCUMENT } from '@angular/common';
import { EnvironmentInjector, createEnvironmentInjector } from '@angular/core'; import { EnvironmentInjector, createEnvironmentInjector } from '@angular/core';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { DEFAULT_THEME_JSON, createDefaultThemeDocument } from '../../domain/logic/theme-defaults.logic'; import {
BUILT_IN_THEME_PRESETS,
DEFAULT_THEME_JSON,
createDefaultThemeDocument
} from '../../domain/logic/theme-defaults.logic';
import { validateThemeDocument } from '../../domain/logic/theme-validation.logic'; import { validateThemeDocument } from '../../domain/logic/theme-validation.logic';
import { ThemeService } from './theme.service'; import { ThemeService } from './theme.service';
@@ -30,55 +36,58 @@ describe('ThemeService theme application', () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it('uses the compact Toju dark theme as the built-in default JSON', () => { it('uses the website dark theme as the built-in default JSON', () => {
expect(JSON.parse(DEFAULT_THEME_JSON) as unknown).toEqual({ const defaultTheme = JSON.parse(DEFAULT_THEME_JSON) as Record<string, unknown>;
expect(defaultTheme['css']).toEqual(expect.not.stringContaining('radial-gradient'));
expect(defaultTheme).toEqual(expect.objectContaining({
meta: { meta: {
name: 'Toju Default Dark', name: 'Toju Website Dark',
version: '2.0.0', version: '1.0.0',
description: 'Built-in dark glass theme for the full Toju app shell.' description: 'Website-inspired dark app theme using the charcoal, green, and amber palette from the public Toju site.'
}, },
tokens: { tokens: {
colors: { colors: {
background: '224 28% 7%', background: '210 18% 7%',
foreground: '210 40% 96%', foreground: '42 33% 94%',
card: '224 25% 10%', card: '210 17% 10%',
cardForeground: '210 40% 96%', cardForeground: '42 33% 94%',
popover: '224 26% 9%', popover: '210 17% 9%',
popoverForeground: '210 40% 96%', popoverForeground: '42 33% 94%',
primary: '193 95% 68%', primary: '154 49% 55%',
primaryForeground: '222 47% 11%', primaryForeground: '210 18% 7%',
secondary: '223 19% 16%', secondary: '210 14% 15%',
secondaryForeground: '210 40% 96%', secondaryForeground: '42 33% 94%',
muted: '223 18% 14%', muted: '210 14% 15%',
mutedForeground: '215 20% 70%', mutedForeground: '42 13% 67%',
accent: '218 22% 18%', accent: '38 64% 61%',
accentForeground: '210 40% 98%', accentForeground: '210 18% 7%',
destructive: '0 72% 55%', destructive: '0 72% 55%',
destructiveForeground: '0 0% 100%', destructiveForeground: '0 0% 100%',
border: '222 18% 22%', border: '210 13% 22%',
input: '222 18% 22%', input: '210 13% 22%',
ring: '193 95% 68%', ring: '154 49% 55%',
railBackground: '226 33% 8%', railBackground: '210 19% 6%',
workspaceBackground: '224 30% 9%', workspaceBackground: '210 18% 8%',
panelBackground: '224 24% 11%', panelBackground: '210 17% 10%',
panelBackgroundAlt: '222 22% 13%', panelBackgroundAlt: '210 14% 13%',
titleBarBackground: '226 34% 7%', titleBarBackground: '210 19% 6%',
surfaceHighlight: '193 95% 68%', surfaceHighlight: '154 49% 55%',
surfaceHighlightAlt: '261 82% 72%' surfaceHighlightAlt: '38 64% 61%'
}, },
spacing: {}, spacing: {},
radii: { radii: {
radius: '0.875rem', radius: '0.6rem',
surface: '1.35rem', surface: '0.85rem',
pill: '999px' pill: '999px'
}, },
effects: { effects: {
panelShadow: '0 24px 60px rgba(0, 0, 0, 0.42)', panelShadow: '0 28px 64px rgba(0, 0, 0, 0.34)',
softShadow: '0 14px 36px rgba(0, 0, 0, 0.28)', softShadow: '0 16px 36px rgba(0, 0, 0, 0.22)',
glassBlur: 'blur(18px) saturate(135%)' glassBlur: 'blur(16px) saturate(125%)'
} }
}, },
layout: { layout: expect.objectContaining({
serversRail: { serversRail: {
container: 'appShell', container: 'appShell',
grid: { x: 0, y: 0, w: 1, h: 1 } grid: { x: 0, y: 0, w: 1, h: 1 }
@@ -99,10 +108,76 @@ describe('ThemeService theme application', () => {
container: 'roomLayout', container: 'roomLayout',
grid: { x: 16, y: 0, w: 4, h: 12 } grid: { x: 16, y: 0, w: 4, h: 12 }
} }
} }),
elements: expect.objectContaining({
titleBar: expect.objectContaining({ border: '0' })
}),
animations: {}
}));
});
it('exposes both built-in theme presets and applies the legacy default preset', () => {
expect(BUILT_IN_THEME_PRESETS.map((preset) => preset.theme.meta.name)).toEqual(['Toju Website Dark', 'Toju Default Dark']);
expect(service.activeThemeName()).toBe('Toju Website Dark');
const applied = service.applyBuiltInPreset('Toju Default Dark');
expect(applied).toBe(true);
expect(service.activeThemeName()).toBe('Toju Default Dark');
expect(service.getHostStyles('appRoot')).toMatchObject({
'--background': '224 28% 7%',
'--primary': '193 95% 68%'
}); });
}); });
it('resets to the website dark preset as the new default', () => {
expect(service.applyBuiltInPreset('Toju Default Dark')).toBe(true);
service.resetToDefault();
expect(service.activeThemeName()).toBe('Toju Website Dark');
expect(service.getHostStyles('appRoot')).toMatchObject({
'--background': '210 18% 7%',
'--primary': '154 49% 55%',
'--accent': '38 64% 61%'
});
expect(service.activeThemeText()).not.toContain('radial-gradient');
});
it('keeps the importable website dark artifact aligned with the default preset', () => {
const artifact = JSON.parse(readFileSync(resolve('../project-files/themes/toju-website-dark.json'), 'utf8')) as unknown;
const defaultTheme = JSON.parse(DEFAULT_THEME_JSON) as unknown;
expect(artifact).toEqual(defaultTheme);
});
it('keeps all built-in presets valid', () => {
for (const preset of BUILT_IN_THEME_PRESETS) {
const result = validateThemeDocument(preset.theme);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
}
});
it('applies a CSS-only theme over the new default JSON theme', () => {
const applied = service.applyCssOnlyTheme('.css-only-theme { background: hsl(var(--background)); }');
expect(applied).toBe(true);
expect(service.activeThemeName()).toBe('Toju Website Dark');
expect(service.getLayoutItemStyles('dmChatPanel')).toMatchObject({
gridColumn: '5 / span 16',
gridRow: '1 / span 12'
});
expect(service.getHostStyles('appRoot')).toMatchObject({
'--background': '210 18% 7%'
});
expect(styleElements.some((styleElement) => styleElement.textContent === '.css-only-theme { background: hsl(var(--background)); }')).toBe(true);
});
it('applies a JSON theme with tokens, layout, backgrounds, effects, metadata, and animations', () => { it('applies a JSON theme with tokens, layout, backgrounds, effects, metadata, and animations', () => {
const theme = createCompleteThemeDocument(); const theme = createCompleteThemeDocument();
const loaded = service.loadThemeText(JSON.stringify(theme), 'apply', 'Theme applied.', 'complete JSON theme'); const loaded = service.loadThemeText(JSON.stringify(theme), 'apply', 'Theme applied.', 'complete JSON theme');
@@ -185,23 +260,6 @@ describe('ThemeService theme application', () => {
expect(service.activeThemeText()).toContain('"css"'); expect(service.activeThemeText()).toContain('"css"');
}); });
it('applies a CSS-only theme over the default JSON theme', () => {
const applied = service.applyCssOnlyTheme('.css-only-theme { background: hsl(var(--background)); }');
expect(applied).toBe(true);
expect(service.activeThemeName()).toBe('Toju Default Dark');
expect(service.getLayoutItemStyles('dmChatPanel')).toMatchObject({
gridColumn: '5 / span 16',
gridRow: '1 / span 12'
});
expect(service.getHostStyles('appRoot')).toMatchObject({
'--background': '224 28% 7%'
});
expect(styleElements.some((styleElement) => styleElement.textContent === '.css-only-theme { background: hsl(var(--background)); }')).toBe(true);
});
it('applies CSS over the current JSON draft theme', () => { it('applies CSS over the current JSON draft theme', () => {
const draftTheme = createDefaultThemeDocument(); const draftTheme = createDefaultThemeDocument();
@@ -261,7 +319,7 @@ describe('ThemeService theme application', () => {
} }
}); });
expect(service.activeThemeName()).toBe('Toju Default Dark'); expect(service.activeThemeName()).toBe('Toju Website Dark');
}); });
it('validates the dedicated DM workspace layout container', () => { it('validates the dedicated DM workspace layout container', () => {
@@ -378,6 +436,32 @@ describe('ThemeService theme application', () => {
expect(service.getHostStyles('titleBar')).toEqual({}); expect(service.getHostStyles('titleBar')).toEqual({});
expect(service.activeThemeText()).not.toContain('"elements"'); expect(service.activeThemeText()).not.toContain('"elements"');
}); });
it('loads the website dark saved-theme artifact', () => {
const themeText = readFileSync(resolve('../project-files/themes/toju-website-dark.json'), 'utf8');
const loaded = service.loadThemeText(themeText, 'apply', 'Theme applied.', 'website dark saved theme');
expect(loaded).toBe(true);
expect(service.activeThemeName()).toBe('Toju Website Dark');
expect(service.getHostStyles('appRoot')).toMatchObject({
'--background': '210 18% 7%',
'--primary': '154 49% 55%',
'--accent': '38 64% 61%'
});
expect(service.getHostStyles('titleBar')).toMatchObject({
border: '0',
backgroundColor: 'hsl(var(--title-bar-background) / 0.88)',
backdropFilter: 'var(--theme-effect-glass-blur)'
});
expect(service.getHostStyles('serversRail')).toMatchObject({ border: '0' });
expect(service.getHostStyles('chatRoomChannelsPanel')).toMatchObject({ border: '0' });
expect(service.getHostStyles('chatRoomMembersPanel')).toMatchObject({ border: '0' });
expect(service.getHostStyles('chatComposerBar')).toMatchObject({ border: '0' });
expect(service.activeThemeText()).toContain('Website-inspired app shell surfaces');
});
}); });
interface TestStyleElement { interface TestStyleElement {

View File

@@ -15,6 +15,7 @@ import {
ThemeElementStyles ThemeElementStyles
} from '../../domain/models/theme.model'; } from '../../domain/models/theme.model';
import { import {
BUILT_IN_THEME_PRESETS,
DEFAULT_THEME_JSON, DEFAULT_THEME_JSON,
createDefaultThemeDocument, createDefaultThemeDocument,
createDefaultThemeLayout, createDefaultThemeLayout,
@@ -137,6 +138,7 @@ export class ThemeService {
readonly activeThemeName: Signal<string>; readonly activeThemeName: Signal<string>;
readonly knownAnimationClasses: Signal<string[]>; readonly knownAnimationClasses: Signal<string[]>;
readonly isDraftDirty: Signal<boolean>; readonly isDraftDirty: Signal<boolean>;
readonly builtInPresets = BUILT_IN_THEME_PRESETS;
private readonly documentRef = inject(DOCUMENT); private readonly documentRef = inject(DOCUMENT);
@@ -325,6 +327,26 @@ export class ThemeService {
this.setStatusMessage(reason === 'shortcut' ? 'Theme reset to the default preset by shortcut.' : 'Theme reset to the default preset.'); this.setStatusMessage(reason === 'shortcut' ? 'Theme reset to the default preset by shortcut.' : 'Theme reset to the default preset.');
} }
applyBuiltInPreset(name: string): boolean {
const preset = this.builtInPresets.find((builtInPreset) => builtInPreset.theme.meta.name === name || builtInPreset.key === name);
if (!preset) {
this.setStatusMessage('Built-in theme preset not found.');
return false;
}
const theme = structuredClone(preset.theme);
const result = validateThemeDocument(theme);
if (!result.valid || !result.value) {
this.setStatusMessage(result.errors[0] ?? 'Built-in theme preset could not be applied.');
return false;
}
this.commitTheme(result.value, stringifyTheme(result.value), `${result.value.meta.name} preset applied.`);
return true;
}
handleGlobalShortcut(event: KeyboardEvent): boolean { handleGlobalShortcut(event: KeyboardEvent): boolean {
const usesModifier = event.ctrlKey || event.metaKey; const usesModifier = event.ctrlKey || event.metaKey;

View File

@@ -111,7 +111,12 @@ function allElementsEmpty(elements: Record<string, ThemeElementStyles>): boolean
return Object.values(elements).every((elementStyles) => Object.keys(elementStyles).length === 0); return Object.values(elements).every((elementStyles) => Object.keys(elementStyles).length === 0);
} }
export function createDefaultThemeDocument(): ThemeDocument { export interface BuiltInThemePreset {
key: string;
theme: ThemeDocument;
}
function createLegacyDefaultDarkThemeDocument(): ThemeDocument {
return { return {
css: '', css: '',
meta: { meta: {
@@ -166,6 +171,147 @@ export function createDefaultThemeDocument(): ThemeDocument {
}; };
} }
export function createDefaultThemeDocument(): ThemeDocument {
return {
css: `/* Website-inspired app shell surfaces */
app-chat-messages .chat-layout,
app-dm-chat .chat-layout {
background-image: linear-gradient(180deg, hsl(var(--workspace-background) / 0.96), hsl(var(--background)) 38rem) !important;
}
app-chat-message-list > div {
background: transparent !important;
}
app-chat-message-item > div[data-message-id] {
margin: 8px 0 !important;
border: 1px solid hsl(var(--border) / 0.56) !important;
border-radius: 0.7rem !important;
background: hsl(var(--card) / 0.72) !important;
box-shadow: 0 14px 34px rgb(0 0 0 / 0.2) !important;
backdrop-filter: blur(12px) saturate(120%) !important;
-webkit-backdrop-filter: blur(12px) saturate(120%) !important;
}
app-chat-message-item > div[data-message-id]:hover {
border-color: hsl(var(--primary) / 0.42) !important;
background: hsl(var(--card) / 0.84) !important;
}
app-chat-message-composer,
.chat-bottom-bar {
border-top: 0 !important;
background: hsl(var(--background) / 0.84) !important;
backdrop-filter: blur(16px) saturate(125%) !important;
-webkit-backdrop-filter: blur(16px) saturate(125%) !important;
}
app-chat-message-composer textarea,
app-chat-message-composer [contenteditable="true"] {
background: hsl(var(--panel-background-alt) / 0.8) !important;
}
`,
meta: {
name: 'Toju Website Dark',
version: '1.0.0',
description: 'Website-inspired dark app theme using the charcoal, green, and amber palette from the public Toju site.'
},
tokens: {
colors: {
background: '210 18% 7%',
foreground: '42 33% 94%',
card: '210 17% 10%',
cardForeground: '42 33% 94%',
popover: '210 17% 9%',
popoverForeground: '42 33% 94%',
primary: '154 49% 55%',
primaryForeground: '210 18% 7%',
secondary: '210 14% 15%',
secondaryForeground: '42 33% 94%',
muted: '210 14% 15%',
mutedForeground: '42 13% 67%',
accent: '38 64% 61%',
accentForeground: '210 18% 7%',
destructive: '0 72% 55%',
destructiveForeground: '0 0% 100%',
border: '210 13% 22%',
input: '210 13% 22%',
ring: '154 49% 55%',
railBackground: '210 19% 6%',
workspaceBackground: '210 18% 8%',
panelBackground: '210 17% 10%',
panelBackgroundAlt: '210 14% 13%',
titleBarBackground: '210 19% 6%',
surfaceHighlight: '154 49% 55%',
surfaceHighlightAlt: '38 64% 61%'
},
spacing: {},
radii: {
radius: '0.6rem',
surface: '0.85rem',
pill: '999px'
},
effects: {
panelShadow: '0 28px 64px rgba(0, 0, 0, 0.34)',
softShadow: '0 16px 36px rgba(0, 0, 0, 0.22)',
glassBlur: 'blur(16px) saturate(125%)'
}
},
layout: createDefaultThemeLayout(),
elements: {
titleBar: {
border: '0',
backgroundColor: 'hsl(var(--title-bar-background) / 0.88)',
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.22)',
backdropFilter: 'var(--theme-effect-glass-blur)'
},
serversRail: {
border: '0',
backgroundColor: 'hsl(var(--rail-background) / 0.94)',
boxShadow: 'var(--theme-effect-panel-shadow)'
},
appWorkspace: {
backgroundColor: 'hsl(var(--workspace-background))'
},
chatRoomChannelsPanel: {
border: '0',
backgroundColor: 'hsl(var(--panel-background) / 0.86)',
backdropFilter: 'var(--theme-effect-glass-blur)'
},
chatRoomMembersPanel: {
border: '0',
backgroundColor: 'hsl(var(--panel-background) / 0.82)',
backdropFilter: 'var(--theme-effect-glass-blur)'
},
chatRoomMainPanel: {
backgroundColor: 'hsl(var(--workspace-background))'
},
chatSurface: {
backgroundColor: 'transparent'
},
chatMessageBubble: {
border: '1px solid hsl(var(--border) / 0.56)',
borderRadius: '0.7rem',
backgroundColor: 'hsl(var(--card) / 0.72)',
boxShadow: 'var(--theme-effect-soft-shadow)',
backdropFilter: 'blur(12px) saturate(120%)'
},
chatComposerBar: {
border: '0',
backgroundColor: 'hsl(var(--background) / 0.84)',
backdropFilter: 'var(--theme-effect-glass-blur)'
},
chatComposerInput: {
border: '1px solid hsl(var(--border) / 0.6)',
borderRadius: '0.6rem',
backgroundColor: 'hsl(var(--panel-background-alt) / 0.8)',
color: 'hsl(var(--foreground))'
}
},
animations: {}
};
}
export function isLegacyDefaultThemeDocument(document: ThemeDocument): boolean { export function isLegacyDefaultThemeDocument(document: ThemeDocument): boolean {
return ( return (
document.meta.name === 'Toju Default Theme' && document.meta.name === 'Toju Default Theme' &&
@@ -182,12 +328,18 @@ export function isLegacyDefaultThemeDocument(document: ThemeDocument): boolean {
} }
export const DEFAULT_THEME_DOCUMENT: ThemeDocument = createDefaultThemeDocument(); export const DEFAULT_THEME_DOCUMENT: ThemeDocument = createDefaultThemeDocument();
export const DEFAULT_THEME_JSON = JSON.stringify( export const BUILT_IN_THEME_PRESETS: readonly BuiltInThemePreset[] = [
{ {
meta: DEFAULT_THEME_DOCUMENT.meta, key: 'toju-website-dark',
tokens: DEFAULT_THEME_DOCUMENT.tokens, theme: DEFAULT_THEME_DOCUMENT
layout: DEFAULT_THEME_DOCUMENT.layout
}, },
{
key: 'toju-default-dark',
theme: createLegacyDefaultDarkThemeDocument()
}
];
export const DEFAULT_THEME_JSON = JSON.stringify(
DEFAULT_THEME_DOCUMENT,
null, null,
2 2
); );

View File

@@ -164,6 +164,41 @@
<div class="theme-settings__workspace min-h-0 flex-1"> <div class="theme-settings__workspace min-h-0 flex-1">
<aside class="theme-settings__sidebar"> <aside class="theme-settings__sidebar">
<section class="theme-studio-card p-3.5">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-foreground">Preset Themes</p>
<span class="rounded-full bg-secondary px-2.5 py-1 text-[11px] font-medium text-muted-foreground">
{{ builtInPresets.length }} built in
</span>
</div>
<div class="theme-settings__saved-theme-list mt-4">
@for (preset of builtInPresets; track preset.key) {
<button
type="button"
(click)="applyBuiltInPreset(preset.theme.meta.name)"
class="theme-settings__saved-theme-button"
[class.theme-settings__saved-theme-button--active]="activeTheme().meta.name === preset.theme.meta.name"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-semibold text-foreground">{{ preset.theme.meta.name }}</p>
<p class="mt-1 font-mono text-[11px] text-muted-foreground">{{ preset.key }}</p>
</div>
@if (preset.theme.meta.name === 'Toju Website Dark') {
<span class="rounded-full bg-primary/12 px-2 py-0.5 text-[10px] font-medium text-primary">Default</span>
}
</div>
@if (preset.theme.meta.description) {
<p class="mt-2 text-xs leading-5 text-muted-foreground">{{ preset.theme.meta.description }}</p>
}
</button>
}
</div>
</section>
@if (savedThemesAvailable()) { @if (savedThemesAvailable()) {
<section class="theme-studio-card p-3.5"> <section class="theme-studio-card p-3.5">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">

View File

@@ -62,6 +62,7 @@ export class ThemeSettingsComponent {
readonly isDraftDirty = this.theme.isDraftDirty; readonly isDraftDirty = this.theme.isDraftDirty;
readonly isFullscreen = this.modal.themeStudioFullscreen; readonly isFullscreen = this.modal.themeStudioFullscreen;
readonly activeTheme = this.theme.activeTheme; readonly activeTheme = this.theme.activeTheme;
readonly builtInPresets = this.theme.builtInPresets;
readonly draftTheme = this.theme.draftTheme; readonly draftTheme = this.theme.draftTheme;
readonly THEME_ANIMATION_FIELDS = THEME_ANIMATION_FIELD_HINTS; readonly THEME_ANIMATION_FIELDS = THEME_ANIMATION_FIELD_HINTS;
readonly animationKeys = this.theme.knownAnimationClasses; readonly animationKeys = this.theme.knownAnimationClasses;
@@ -353,6 +354,14 @@ export class ThemeSettingsComponent {
await this.themeLibrary.removeSelectedTheme(); await this.themeLibrary.removeSelectedTheme();
} }
applyBuiltInPreset(name: string): void {
this.theme.applyBuiltInPreset(name);
this.activeWorkspace.set('editor');
this.activeEditorTab.set('json');
this.syncCssOnlyTextFromTheme();
this.focusEditor();
}
restoreDefaultTheme(): void { restoreDefaultTheme(): void {
this.theme.resetToDefault('button'); this.theme.resetToDefault('button');
this.activeWorkspace.set('editor'); this.activeWorkspace.set('editor');

View File

@@ -86,7 +86,7 @@ function Publish-IisSite {
$deployments = @( $deployments = @(
@{ @{
SiteName = 'toju-website' SiteName = 'toju-website'
SourcePath = (Join-Path $RepoRoot 'website\dist\toju-website\browser') SourcePath = (Join-Path $RepoRoot 'website\dist\toju-website')
DestinationPath = (Join-Path $IisRoot 'toju-website') DestinationPath = (Join-Path $IisRoot 'toju-website')
Port = $WebsitePort Port = $WebsitePort
}, },

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) | | **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" | | **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 ## Relationships
- The **Marketing site** links to release artifacts produced by the Gitea Workflows under `.gitea/workflows/release-draft.yml` and `publish-draft-release.yml`. - 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. - It does **not** consume the signaling server, the product client, or shared kernel types — independent codebase.
## Boundaries / IO ## 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/`. - **Consumes:** the release manifest URL and download links; static assets under `website/src/images/`.
## Invariants ## Invariants
- The marketing site has its own `package.json` and its own Angular version — do **not** hoist its dependencies into the root workspace. - 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 ## Flagged ambiguities

View File

@@ -10,9 +10,10 @@ Angular 19 marketing site for MetoYou / Toju. This package is separate from the
## Commands ## Commands
- `npm run start` starts the local dev server and uses `proxy.conf.json`. - `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 watch` rebuilds in development mode.
- `npm run test` runs the Karma test suite. - `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. - `npm run serve:ssr:toju-website` serves the built SSR output.
## Structure ## 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/app/` | Website pages, sections, and shared UI |
| `src/images/` | Marketing images copied to `/images` during build | | `src/images/` | Marketing images copied to `/images` during build |
| `public/` | Static public assets | | `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 | | `proxy.conf.json` | Local development proxy configuration |
| `angular.json` | Angular build, serve, SSR, prerender, and test targets | | `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`. - The website is its own Angular workspace and is not installed by the root `npm install`.
- Build output in `dist/toju-website/` is generated. - 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/`. - Keep website code isolated from `toju-app/`, `electron/`, and `server/`.

View File

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

View File

@@ -5,8 +5,10 @@
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "ng build",
"postbuild": "node tools/copy-iis-web-config.mjs",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test", "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" "serve:ssr:toju-website": "node dist/toju-website/server/server.mjs"
}, },
"private": true, "private": true,

View File

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

View File

@@ -17,9 +17,7 @@
/> />
<span class="text-xl font-bold text-foreground">{{ 'common.brand' | translate }}</span> <span class="text-xl font-bold text-foreground">{{ 'common.brand' | translate }}</span>
</span> </span>
<span <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">
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"
>
{{ 'components.header.beta' | translate }} {{ 'components.header.beta' | translate }}
</span> </span>
</a> </a>
@@ -78,7 +76,7 @@
href="https://web.toju.app/" href="https://web.toju.app/"
target="_blank" target="_blank"
rel="noopener" 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 }} {{ 'components.header.useWebVersion' | translate }}
<svg <svg
@@ -175,7 +173,7 @@
href="https://web.toju.app/" href="https://web.toju.app/"
target="_blank" target="_blank"
rel="noopener" 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 }} {{ 'components.header.useWebVersion' | translate }}
</a> </a>

View File

@@ -1,538 +1,184 @@
<!-- Hero --> <section class="home-shell">
<section class="relative min-h-screen flex items-center justify-center overflow-hidden"> <div class="hero-grid">
<!-- Gradient orbs --> <div class="hero-copy section-fade">
<div <p class="eyebrow">{{ 'pages.home.hero.badge' | translate }}</p>
[appParallax]="0.15" <h1>
class="absolute top-1/4 -left-32 w-96 h-96 bg-purple-600/20 rounded-full blur-[128px] animate-float" <span>{{ 'pages.home.hero.titleLine1' | translate }}</span>
></div> <span>{{ 'pages.home.hero.titleLine2' | translate }}</span>
<div </h1>
[appParallax]="0.25" <p class="hero-description">{{ 'pages.home.hero.description' | translate }}</p>
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>
<div class="relative z-10 container mx-auto px-6 text-center pt-32 pb-20"> <div class="hero-actions">
<div @if (downloadUrl()) {
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" <a
> [href]="downloadUrl()"
<span class="w-2 h-2 rounded-full bg-purple-400 animate-pulse"></span> class="button-primary"
{{ 'pages.home.hero.badge' | translate }} >
</div> {{ '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 <a
[href]="downloadUrl()" href="https://web.toju.app/"
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"
target="_blank" target="_blank"
rel="noopener" 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 {{ 'common.actions.openInBrowser' | translate }}
src="/images/gitea.png"
alt=""
width="16"
height="16"
class="w-4 h-4 object-contain"
/>
{{ 'common.actions.viewSourceCode' | translate }}
</a> </a>
</div> </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>
</div> </div>
</section> </section>
<!-- CTA Banner --> <app-ad-slot />
<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> <section class="feature-editorial">
<div class="relative container mx-auto px-6 text-center section-fade"> <div class="section-heading section-fade">
<h2 class="text-3xl md:text-4xl font-bold text-foreground mb-4">{{ 'pages.home.cta.title' | translate }}</h2> <p class="eyebrow">{{ 'pages.home.features.titleLine1' | translate }}</p>
<p class="text-muted-foreground text-lg mb-8 max-w-lg mx-auto">{{ 'pages.home.cta.description' | translate }}</p> <h2>{{ 'pages.home.features.titleLine2' | translate }}</h2>
<div class="flex flex-col sm:flex-row items-center justify-center gap-4"> <p>{{ 'pages.home.features.description' | translate }}</p>
@if (downloadUrl()) { </div>
<a
[href]="downloadUrl()" <div class="feature-layout">
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" <article class="feature-panel feature-panel-large section-fade">
> <span>01</span>
{{ 'common.actions.downloadFor' | translate:{ os: getDetectedOsLabel() } }} <h3>{{ 'pages.home.features.items.voiceCalls.title' | translate }}</h3>
</a> <p>{{ 'pages.home.features.items.voiceCalls.description' | translate }}</p>
} @else { </article>
<a
routerLink="/downloads" <article class="feature-panel section-fade">
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" <span>02</span>
> <h3>{{ 'pages.home.features.items.screenSharing.title' | translate }}</h3>
{{ 'common.actions.downloadBrand' | translate }} <p>{{ 'pages.home.features.items.screenSharing.description' | translate }}</p>
</a> </article>
}
<a <article class="feature-panel section-fade">
href="https://web.toju.app/" <span>03</span>
target="_blank" <h3>{{ 'pages.home.features.items.fileSharing.title' | translate }}</h3>
rel="noopener" <p>{{ 'pages.home.features.items.fileSharing.description' | translate }}</p>
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" </article>
>
{{ 'common.actions.tryInBrowser' | translate }} <article class="feature-panel feature-panel-wide section-fade">
</a> <span>04</span>
</div> <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> </div>
</section> </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 { SeoService } from '../../services/seo.service';
import { ScrollAnimationService } from '../../services/scroll-animation.service'; import { ScrollAnimationService } from '../../services/scroll-animation.service';
import { AdSlotComponent } from '../../components/ad-slot/ad-slot.component'; import { AdSlotComponent } from '../../components/ad-slot/ad-slot.component';
import { ParallaxDirective } from '../../directives/parallax.directive';
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
@@ -23,10 +22,10 @@ import { ParallaxDirective } from '../../directives/parallax.directive';
NgOptimizedImage, NgOptimizedImage,
TranslateModule, TranslateModule,
RouterLink, RouterLink,
AdSlotComponent, AdSlotComponent
ParallaxDirective
], ],
templateUrl: './home.component.html' templateUrl: './home.component.html',
styleUrl: './home.component.scss'
}) })
export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
readonly detectedOS = signal<DetectedOS>({ 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 base;
@tailwind components; @tailwind components;
@@ -6,22 +6,24 @@
@layer base { @layer base {
:root { :root {
--background: 240 10% 3.9%; --background: 210 18% 7%;
--foreground: 0 0% 98%; --foreground: 42 33% 94%;
--card: 240 10% 6%; --card: 210 17% 10%;
--card-foreground: 0 0% 98%; --card-foreground: 42 33% 94%;
--primary: 262.1 83.3% 57.8%; --primary: 154 49% 55%;
--primary-foreground: 210 20% 98%; --primary-foreground: 210 18% 7%;
--secondary: 240 3.7% 15.9%; --secondary: 210 14% 15%;
--secondary-foreground: 0 0% 98%; --secondary-foreground: 42 33% 94%;
--muted: 240 3.7% 15.9%; --muted: 210 14% 15%;
--muted-foreground: 240 5% 64.9%; --muted-foreground: 42 13% 67%;
--accent: 262.1 83.3% 57.8%; --accent: 38 64% 61%;
--accent-foreground: 0 0% 98%; --accent-foreground: 210 18% 7%;
--border: 240 3.7% 15.9%; --border: 210 13% 22%;
--input: 240 3.7% 15.9%; --input: 210 13% 22%;
--ring: 262.1 83.3% 57.8%; --ring: 154 49% 55%;
--radius: 0.75rem; --radius: 0.6rem;
--font-sans: 'Outfit', system-ui, sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
} }
* { * {
@@ -33,8 +35,29 @@
} }
body { 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; 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 */ /* Utility classes */
.glass { .glass {
backdrop-filter: blur(16px) saturate(180%); backdrop-filter: blur(16px) saturate(180%);
background: hsl(var(--background) / 0.7); background: hsl(var(--background) / 0.82);
border: 1px solid hsl(var(--border) / 0.3); border-bottom: 1px solid hsl(var(--border));
} }
.gradient-text { .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-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
@@ -76,7 +99,7 @@
inset: 0; inset: 0;
border-radius: inherit; border-radius: inherit;
padding: 1px; 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: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor; -webkit-mask-composite: xor;
mask-composite: exclude; mask-composite: exclude;

View File

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