diff --git a/CLAUDE.md b/CLAUDE.md index 1eab7fa..91ee244 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,10 +55,8 @@ (`--force` overrides). Never hand-run `docker compose build/up` on the server: that is a side door (a hand-run compose caused a 502 on manager, 2026-09-24). Server checkout `/var/www/ai-factory`, stack `infra/docker-compose.prod.yml`. Verify by grepping the new code inside the container, not by uptime. -- After every commit and push/deploy, the report goes to Teams through the session "Bộ chỉ huy" - (2026-09-26): send it the `~/.openclaw/workspace/ops/teams-commit.sh deployed` lines with - SendMessage and let it post. Never run `teams-commit.sh` or `teams-send.sh` from this session: all - sends share one headless Chrome (CDP 9333), and two sessions at once crash it. +- After every commit and push/deploy, post it to Teams yourself with `~/.openclaw/workspace/ops/teams-commit.sh`. + If it fails with a CDP or login-looking error, run `~/.openclaw/workspace/ops/teams-chrome.sh ensure` and retry. - Mail goes out through the company's world4you SMTP (`smtp.world4you.com:587`, STARTTLS) as `developerweb@codemenschen.at`, DKIM-signed for codemenschen.at. Not Resend (its Tokyo IPs landed the sign-in mail in spam, 2026-09-07) and not the host's sendmail (generic rDNS, no DKIM). diff --git a/apps/web/src/app/(site)/[locale]/layout.tsx b/apps/web/src/app/(site)/[locale]/layout.tsx index e653566..da17dc2 100644 --- a/apps/web/src/app/(site)/[locale]/layout.tsx +++ b/apps/web/src/app/(site)/[locale]/layout.tsx @@ -6,6 +6,7 @@ import { AccountLink } from "@/components/AccountLink"; import { LangSwitch } from "@/components/LangSwitch"; import { Logo } from "@/components/Logo"; import { MobileNav } from "@/components/MobileNav"; +import { NavLinks } from "@/components/NavLinks"; import { PageViews } from "@/components/PageViews"; import { AdConsentBanner, AdConsentLink } from "@/components/AdConsent"; import "../../globals.css"; @@ -29,14 +30,15 @@ export default async function LocaleLayout({ const { locale } = await params; if (!isLocale(locale)) notFound(); const dict = getDict(locale as Locale); - // The same links in the bar and, on a phone, behind the menu button. + // The same links in the bar and, on a phone, behind the menu button. The prototype page is the + // start button's, not a fifth link: two ways to the same page only made the bar longer. const navLinks = [ { href: `/${locale}#services`, label: dict.nav.services }, { href: `/${locale}#how`, label: dict.nav.how }, { href: `/${locale}#prices`, label: dict.nav.pricing }, { href: `/${locale}/app`, label: dict.nav.appDev }, - { href: `/${locale}/prototype`, label: dict.proto.navLink }, ]; + const cta = { href: `/${locale}/prototype`, label: dict.nav.cta }; return ( @@ -48,18 +50,15 @@ export default async function LocaleLayout({ - +
- - {dict.nav.cta} + + {dict.nav.cta} + {dict.nav.ctaShort} - +
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 6c85c99..4b491b1 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -76,22 +76,32 @@ section { padding: 72px 0; } .logo-name { font-family: var(--font-body); font-size: 1.45rem; font-weight: 800; letter-spacing: -0.03em; } .logo-by { font-family: var(--font-body); font-size: 0.68rem; font-weight: 500; color: var(--ink-soft); letter-spacing: 0; } .nav-links { display: flex; gap: 22px; margin: 0 auto; } -.nav-links a { color: var(--ink-soft); text-decoration: none; font-size: 0.92rem; font-weight: 500; white-space: nowrap; } -.nav-links a:hover { color: var(--ink); } +.nav-links a { position: relative; color: var(--ink-soft); text-decoration: none; font-size: 0.92rem; font-weight: 500; white-space: nowrap; padding: 6px 0; } +.nav-links a:hover, .nav-links a[aria-current="page"] { color: var(--ink); } +.nav-links a::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 2px; border-radius: 2px; background: var(--accent); transform: scaleX(0); transition: transform 0.18s ease; } +.nav-links a:hover::after, .nav-links a[aria-current="page"]::after { transform: scaleX(1); } .nav-right { display: flex; align-items: center; gap: 10px; margin-left: auto; } .nav-right > * { white-space: nowrap; } .lang-toggle { font-size: 13px; font-weight: 600; border: 1px solid var(--border); border-radius: 99px; padding: 4px 12px; color: var(--ink-soft); text-decoration: none; background: var(--surface); } .lang-toggle:hover { border-color: var(--ink-soft); color: var(--ink); } -/* German labels are longer; drop the header CTA before the bar can wrap */ -@media (max-width: 1320px) { .nav-right .nav-cta { display: none; } } +.nav-account { display: inline-flex; align-items: center; } +.nav-account-ico { display: none; width: 16px; height: 16px; } +/* The start button always stays: its long label where there is room, a short one where there is not, + and on a phone it moves into the menu panel. */ +.nav-cta-short { display: none; } +@media (max-width: 1180px) { .nav-cta-long { display: none; } .nav-cta-short { display: inline; } .nav-links { gap: 18px; } } @media (max-width: 1000px) { .nav-links { display: none; } } +@media (max-width: 640px) { .nav-right .nav-cta { display: none; } } /* The menu button and its panel: without them a phone had no menu at all. */ .nav-burger { display: none; position: relative; } -.nav-burger-btn { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; font-size: 18px; line-height: 1; border: 1px solid var(--border); border-radius: 99px; background: var(--surface); color: var(--ink); cursor: pointer; } -.nav-panel { position: absolute; right: 0; top: calc(100% + 10px); min-width: 210px; display: grid; gap: 2px; padding: 8px; background: var(--surface); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 12px 30px rgba(0,0,0,.12); } -.nav-panel a { padding: 10px 12px; border-radius: 10px; color: var(--ink); text-decoration: none; font-size: 15px; font-weight: 500; } -.nav-panel a:hover { background: var(--bg); } +.nav-burger-btn { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; padding: 0; border: 1px solid var(--border); border-radius: 99px; background: var(--surface); color: var(--ink); cursor: pointer; } +.nav-burger-btn[aria-expanded="true"] { border-color: var(--ink-soft); } +.nav-panel { position: absolute; right: 0; top: calc(100% + 10px); width: min(280px, calc(100vw - 32px)); display: grid; gap: 2px; padding: 8px; background: var(--surface); border: 1px solid var(--border); border-radius: 16px; box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14); } +.nav-panel a:not(.btn) { padding: 11px 12px; border-radius: 10px; color: var(--ink); text-decoration: none; font-size: 15px; font-weight: 500; } +.nav-panel a:not(.btn):hover { background: var(--tint); } +.nav-panel a[aria-current="page"] { background: var(--accent-soft); color: var(--accent-hover); } +.nav-panel-cta { margin-top: 6px; text-align: center; } @media (max-width: 1000px) { .nav-burger { display: block; } } @media (max-width: 560px) { .logo-by { display: none; } } /* German "Anmelden" made the bar 11px wider than a 360px phone; tighten it there */ @@ -100,6 +110,9 @@ section { padding: 72px 0; } .nav-right { gap: 6px; } .nav-right .lang-toggle { padding: 4px 9px; } .logo-name { font-size: 1.25rem; } + .nav-account-label { display: none; } + .nav-account-ico { display: block; } + .nav-right .nav-account { padding: 6px 9px; } } /* ---------- Buttons ---------- */ diff --git a/apps/web/src/components/AccountLink.tsx b/apps/web/src/components/AccountLink.tsx index 8c7f6cd..2835185 100644 --- a/apps/web/src/components/AccountLink.tsx +++ b/apps/web/src/components/AccountLink.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import type { Locale } from "@/lib/i18n"; import { useToken } from "@/lib/token"; +import { Icon } from "./LineIcon"; /** * Nav entry to the customer portal: "My projects" once a portal token exists on this device, "Sign in" before. @@ -11,9 +12,12 @@ import { useToken } from "@/lib/token"; */ export function AccountLink({ locale, labels }: { locale: Locale; labels: { account: string; login: string } }) { const signedIn = !!useToken(); + const label = signedIn ? labels.account : labels.login; + // On a small phone the words do not fit next to the logo: the person icon stands in for them. return ( - - {signedIn ? labels.account : labels.login} + + + {label} ); } diff --git a/apps/web/src/components/MobileNav.tsx b/apps/web/src/components/MobileNav.tsx index 1d428f3..8f59343 100644 --- a/apps/web/src/components/MobileNav.tsx +++ b/apps/web/src/components/MobileNav.tsx @@ -1,28 +1,36 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; /** - * The menu on a narrow screen. The links in the bar are hidden under 1000px, and until now - * nothing took their place: on a phone the site had no menu at all. + * The menu on a narrow screen: the links of the bar, and the start button, which the bar has no + * room for on a phone. Closes on Escape, on a click outside and when a link is followed. */ -export function MobileNav({ links }: { links: { href: string; label: string }[] }) { +export function MobileNav({ links, cta }: { links: { href: string; label: string }[]; cta: { href: string; label: string } }) { const path = usePathname(); // Open is remembered per page, so going somewhere else closes the menu without an effect. const [openOn, setOpenOn] = useState(null); const open = openOn === path; const setOpen = (next: boolean) => setOpenOn(next ? path : null); + const box = useRef(null); useEffect(() => { const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpenOn(null); + const onClick = (e: MouseEvent) => { + if (box.current && !box.current.contains(e.target as Node)) setOpenOn(null); + }; window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); + document.addEventListener("click", onClick); + return () => { + window.removeEventListener("keydown", onKey); + document.removeEventListener("click", onClick); + }; }, []); return ( -
+
{open && ( )}
diff --git a/apps/web/src/components/NavLinks.tsx b/apps/web/src/components/NavLinks.tsx new file mode 100644 index 0000000..4567d52 --- /dev/null +++ b/apps/web/src/components/NavLinks.tsx @@ -0,0 +1,19 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +/** The links of the bar; the page one is on is marked, so the menu says where you are. */ +export function NavLinks({ links }: { links: { href: string; label: string }[] }) { + const path = usePathname(); + + return ( + + ); +} diff --git a/apps/web/src/dictionaries/de.ts b/apps/web/src/dictionaries/de.ts index 93be0e4..ec8ae82 100644 --- a/apps/web/src/dictionaries/de.ts +++ b/apps/web/src/dictionaries/de.ts @@ -9,6 +9,7 @@ export const de = { pricing: "Preise", faq: "FAQ", cta: "Kostenlose Vorschau starten", + ctaShort: "Gratis Vorschau", account: "Meine Projekte", login: "Anmelden", }, diff --git a/apps/web/src/dictionaries/en.ts b/apps/web/src/dictionaries/en.ts index 57facd2..d1e595d 100644 --- a/apps/web/src/dictionaries/en.ts +++ b/apps/web/src/dictionaries/en.ts @@ -9,6 +9,7 @@ export const en = { pricing: "Pricing", faq: "FAQ", cta: "Get a free preview", + ctaShort: "Free preview", account: "My projects", login: "Sign in", },