style: Update default theme
This commit is contained in:
@@ -25,6 +25,13 @@ Durable rules for AI agents working on this project. Read this file at session s
|
||||
|
||||
## 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]
|
||||
|
||||
- **Trigger:** about to report a task as complete after running tests but skipping ESLint.
|
||||
|
||||
165
project-files/themes/toju-website-dark.json
Normal file
165
project-files/themes/toju-website-dark.json
Normal 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": {}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ theme/
|
||||
│ ├── constants/
|
||||
│ │ └── theme-llm-guide.constants.ts LLM prompt context for AI-assisted theme editing
|
||||
│ ├── 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-schema.logic.ts Schema field definitions, animation starters, suggested defaults
|
||||
│ │ └── theme-validation.logic.ts Theme document validation against registry + schema
|
||||
@@ -42,6 +42,12 @@ theme/
|
||||
└── 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
|
||||
|
||||
```mermaid
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
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 { ThemeService } from './theme.service';
|
||||
|
||||
@@ -30,55 +36,58 @@ describe('ThemeService theme application', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('uses the compact Toju dark theme as the built-in default JSON', () => {
|
||||
expect(JSON.parse(DEFAULT_THEME_JSON) as unknown).toEqual({
|
||||
it('uses the website dark theme as the built-in default JSON', () => {
|
||||
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: {
|
||||
name: 'Toju Default Dark',
|
||||
version: '2.0.0',
|
||||
description: 'Built-in dark glass theme for the full Toju app shell.'
|
||||
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: '224 28% 7%',
|
||||
foreground: '210 40% 96%',
|
||||
card: '224 25% 10%',
|
||||
cardForeground: '210 40% 96%',
|
||||
popover: '224 26% 9%',
|
||||
popoverForeground: '210 40% 96%',
|
||||
primary: '193 95% 68%',
|
||||
primaryForeground: '222 47% 11%',
|
||||
secondary: '223 19% 16%',
|
||||
secondaryForeground: '210 40% 96%',
|
||||
muted: '223 18% 14%',
|
||||
mutedForeground: '215 20% 70%',
|
||||
accent: '218 22% 18%',
|
||||
accentForeground: '210 40% 98%',
|
||||
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: '222 18% 22%',
|
||||
input: '222 18% 22%',
|
||||
ring: '193 95% 68%',
|
||||
railBackground: '226 33% 8%',
|
||||
workspaceBackground: '224 30% 9%',
|
||||
panelBackground: '224 24% 11%',
|
||||
panelBackgroundAlt: '222 22% 13%',
|
||||
titleBarBackground: '226 34% 7%',
|
||||
surfaceHighlight: '193 95% 68%',
|
||||
surfaceHighlightAlt: '261 82% 72%'
|
||||
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.875rem',
|
||||
surface: '1.35rem',
|
||||
radius: '0.6rem',
|
||||
surface: '0.85rem',
|
||||
pill: '999px'
|
||||
},
|
||||
effects: {
|
||||
panelShadow: '0 24px 60px rgba(0, 0, 0, 0.42)',
|
||||
softShadow: '0 14px 36px rgba(0, 0, 0, 0.28)',
|
||||
glassBlur: 'blur(18px) saturate(135%)'
|
||||
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: {
|
||||
layout: expect.objectContaining({
|
||||
serversRail: {
|
||||
container: 'appShell',
|
||||
grid: { x: 0, y: 0, w: 1, h: 1 }
|
||||
@@ -99,10 +108,76 @@ describe('ThemeService theme application', () => {
|
||||
container: 'roomLayout',
|
||||
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', () => {
|
||||
const theme = createCompleteThemeDocument();
|
||||
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"');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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', () => {
|
||||
@@ -378,6 +436,32 @@ describe('ThemeService theme application', () => {
|
||||
expect(service.getHostStyles('titleBar')).toEqual({});
|
||||
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 {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ThemeElementStyles
|
||||
} from '../../domain/models/theme.model';
|
||||
import {
|
||||
BUILT_IN_THEME_PRESETS,
|
||||
DEFAULT_THEME_JSON,
|
||||
createDefaultThemeDocument,
|
||||
createDefaultThemeLayout,
|
||||
@@ -137,6 +138,7 @@ export class ThemeService {
|
||||
readonly activeThemeName: Signal<string>;
|
||||
readonly knownAnimationClasses: Signal<string[]>;
|
||||
readonly isDraftDirty: Signal<boolean>;
|
||||
readonly builtInPresets = BUILT_IN_THEME_PRESETS;
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
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 {
|
||||
const usesModifier = event.ctrlKey || event.metaKey;
|
||||
|
||||
|
||||
@@ -111,7 +111,12 @@ function allElementsEmpty(elements: Record<string, ThemeElementStyles>): boolean
|
||||
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 {
|
||||
css: '',
|
||||
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 {
|
||||
return (
|
||||
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_JSON = JSON.stringify(
|
||||
export const BUILT_IN_THEME_PRESETS: readonly BuiltInThemePreset[] = [
|
||||
{
|
||||
meta: DEFAULT_THEME_DOCUMENT.meta,
|
||||
tokens: DEFAULT_THEME_DOCUMENT.tokens,
|
||||
layout: DEFAULT_THEME_DOCUMENT.layout
|
||||
key: 'toju-website-dark',
|
||||
theme: DEFAULT_THEME_DOCUMENT
|
||||
},
|
||||
{
|
||||
key: 'toju-default-dark',
|
||||
theme: createLegacyDefaultDarkThemeDocument()
|
||||
}
|
||||
];
|
||||
export const DEFAULT_THEME_JSON = JSON.stringify(
|
||||
DEFAULT_THEME_DOCUMENT,
|
||||
null,
|
||||
2
|
||||
);
|
||||
|
||||
@@ -164,6 +164,41 @@
|
||||
|
||||
<div class="theme-settings__workspace min-h-0 flex-1">
|
||||
<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()) {
|
||||
<section class="theme-studio-card p-3.5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
|
||||
@@ -62,6 +62,7 @@ export class ThemeSettingsComponent {
|
||||
readonly isDraftDirty = this.theme.isDraftDirty;
|
||||
readonly isFullscreen = this.modal.themeStudioFullscreen;
|
||||
readonly activeTheme = this.theme.activeTheme;
|
||||
readonly builtInPresets = this.theme.builtInPresets;
|
||||
readonly draftTheme = this.theme.draftTheme;
|
||||
readonly THEME_ANIMATION_FIELDS = THEME_ANIMATION_FIELD_HINTS;
|
||||
readonly animationKeys = this.theme.knownAnimationClasses;
|
||||
@@ -353,6 +354,14 @@ export class ThemeSettingsComponent {
|
||||
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 {
|
||||
this.theme.resetToDefault('button');
|
||||
this.activeWorkspace.set('editor');
|
||||
|
||||
@@ -86,7 +86,7 @@ function Publish-IisSite {
|
||||
$deployments = @(
|
||||
@{
|
||||
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')
|
||||
Port = $WebsitePort
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/`.
|
||||
|
||||
@@ -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": "**/*",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
341
website/src/app/pages/home/home.component.scss
Normal file
341
website/src/app/pages/home/home.component.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
@@ -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>({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
10
website/tools/copy-iis-web-config.mjs
Normal file
10
website/tools/copy-iis-web-config.mjs
Normal 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);
|
||||
22
website/tools/iis-release-config.test.mjs
Normal file
22
website/tools/iis-release-config.test.mjs
Normal 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*\]/);
|
||||
});
|
||||
38
website/tools/website-design.test.mjs
Normal file
38
website/tools/website-design.test.mjs
Normal 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/);
|
||||
});
|
||||
Reference in New Issue
Block a user