diff --git a/dashboard/app/(auth)/auth.css b/dashboard/app/(auth)/auth.css index 9ad4672..91c8bfc 100644 --- a/dashboard/app/(auth)/auth.css +++ b/dashboard/app/(auth)/auth.css @@ -44,11 +44,15 @@ .auth-brand-content { width: 100%; - max-width: 600px; + max-width: 640px; margin: 0 auto; display: flex; flex-direction: column; - gap: 2rem; + gap: 1.75rem; +} + +.auth-brand-content .tunl-terminal .lgs { + min-width: 0; } .auth-brand-text { diff --git a/dashboard/app/(auth)/layout.tsx b/dashboard/app/(auth)/layout.tsx index 1b19c14..b7216bc 100644 --- a/dashboard/app/(auth)/layout.tsx +++ b/dashboard/app/(auth)/layout.tsx @@ -23,7 +23,6 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
- {/* Same Animated Hero Flow as Landing Page */}
diff --git a/dashboard/app/landing.css b/dashboard/app/landing.css new file mode 100644 index 0000000..db2dec1 --- /dev/null +++ b/dashboard/app/landing.css @@ -0,0 +1,1308 @@ +/* Landing page design — scoped so dashboard styles stay untouched */ +html:has(.landing) { + scroll-padding-top: calc(env(safe-area-inset-top, 0px) + 76px); + scroll-behavior: smooth; +} + +@media (prefers-reduced-motion: reduce) { + html:has(.landing) { + scroll-behavior: auto; + } +} + +.landing { + --bg: #f6f7f6; + --panel: #fff; + --ink: #0c0f0d; + --mute: #202923; + --line: #dfe3e0; + --green: #2fd272; + --green-ink: #0b7a3a; + --on-green: #04140b; + --tint: rgba(47, 210, 114, 0.12); + --no: #c0364b; + --warn: #8a5f00; + --sans: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --mono: var(--font-mono), ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --t-xs: 13px; + --t-sm: 15px; + --t-md: 17px; + --t-lg: 20px; + --t-xl: clamp(28px, 3.6vw, 42px); + --t-2xl: clamp(36px, 5vw, 56px); + --s1: 8px; + --s2: 16px; + --s3: 24px; + --s4: 32px; + --s5: 48px; + --s6: 64px; + --sec: clamp(64px, 9vw, 104px); + --r-s: 8px; + --r-m: 12px; + --r-l: 18px; + color-scheme: light; + background: var(--bg); + color: var(--ink); + font-family: var(--sans); + font-size: var(--t-md); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + min-height: 100vh; + padding-top: env(safe-area-inset-top, 0px); + padding-bottom: env(safe-area-inset-bottom, 0px); +} + +html.dark .landing { + --bg: #0a0c0b; + --panel: #111412; + --ink: #e9eeea; + --mute: #8f9b94; + --line: #222825; + --green-ink: #3ddc84; + --tint: rgba(47, 210, 114, 0.1); + --no: #ef6b7e; + --warn: #e0b341; + color-scheme: dark; +} + +.landing *, +.landing *::before, +.landing *::after { + box-sizing: border-box; +} + +.landing a { + color: inherit; +} + +.landing :focus-visible { + outline: 2px solid var(--green-ink); + outline-offset: 3px; + border-radius: 4px; +} + +.landing .wrap { + max-width: 1160px; + margin: 0 auto; + padding: 0 28px; +} + +.landing code, +.landing pre { + font-family: var(--mono); +} + +.landing code.i { + font-size: 0.85em; + background: var(--panel); + border: 1px solid var(--line); + padding: 2px 6px; + border-radius: 6px; +} + +.landing .band code.i { + background: var(--bg); +} + +.landing h1, +.landing h2, +.landing h3 { + margin: 0; + font-weight: 600; + text-wrap: balance; +} + +.landing p { + text-wrap: pretty; +} + +.landing h1 { + font-size: var(--t-2xl); + line-height: 1.05; + letter-spacing: -0.04em; +} + +.landing h2 { + font-size: var(--t-xl); + line-height: 1.12; + letter-spacing: -0.03em; +} + +.landing h3 { + font-size: var(--t-md); + line-height: 1.35; + letter-spacing: -0.01em; +} + +.landing .sr { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +.landing .sec { + padding: var(--sec) 0; +} + +.landing .band { + background: var(--panel); + border-block: 1px solid var(--line); +} + +.landing .head { + max-width: 36em; + margin-bottom: var(--s5); +} + +.landing .head p { + margin: var(--s2) 0 0; + color: var(--mute); +} + +/* buttons */ +.landing .btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 11px 18px; + border-radius: var(--r-s); + font: 500 var(--t-sm) / 1.2 var(--sans); + text-decoration: none; + border: 1px solid var(--line); + background: var(--panel); + color: var(--ink); + cursor: pointer; + transition: + border-color 0.15s ease, + background-color 0.15s ease; +} + +.landing .btn:hover { + border-color: var(--mute); +} + +.landing .btn.primary { + background: var(--green); + border-color: var(--green); + color: var(--on-green); +} + +.landing .btn.primary:hover { + background: #43dc83; + border-color: #43dc83; +} + +.landing .btn.sm { + padding: 8px 14px; +} + +.landing .band .btn { + background: var(--bg); +} + +.landing .actions { + display: flex; + gap: var(--s1); + flex-wrap: wrap; +} + +.landing .icon-btn { + display: inline-grid; + place-items: center; + width: 38px; + height: 38px; + border-radius: var(--r-s); + border: 1px solid transparent; + background: transparent; + color: var(--ink); + cursor: pointer; +} + +.landing .icon-btn:hover { + border-color: var(--line); + background: var(--panel); +} + +.landing .icon-btn svg { + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.landing .i-sun { + display: none; +} + +html.dark .landing .i-sun { + display: block; +} + +html.dark .landing .i-moon { + display: none; +} + +/* nav */ +.landing .top { + position: sticky; + top: env(safe-area-inset-top, 0px); + z-index: 10; + background: color-mix(in srgb, var(--bg) 88%, transparent); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--line); +} + +.landing .bar { + display: flex; + align-items: center; + gap: var(--s3); + height: 64px; +} + +.landing .logo { + display: inline-flex; + align-items: center; + gap: 7px; + font-family: var(--mono); + font-weight: 600; + font-size: var(--t-lg); + text-decoration: none; + letter-spacing: -0.02em; +} + +.landing .logo .p { + color: var(--green-ink); +} + +.landing .logo .blk { + width: 0.55em; + height: 1.05em; + background: var(--green); + display: inline-block; + margin-left: 2px; +} + +.landing .links { + display: flex; + gap: var(--s3); + margin-left: auto; + font-size: var(--t-sm); +} + +.landing .links a { + text-decoration: none; + color: var(--mute); +} + +.landing .links a:hover { + color: var(--ink); +} + +.landing .links .only-m { + display: none; +} + +.landing .nav-right { + display: flex; + align-items: center; + gap: var(--s1); +} + +.landing .login { + font-size: var(--t-sm); + font-weight: 500; + text-decoration: none; + padding: 0 var(--s1); +} + +.landing .menu-btn { + display: none; +} + +/* hero */ +.landing .hero { + padding: clamp(40px, 6vw, 72px) 0 var(--sec); +} + +.landing .hero-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); + gap: var(--s6); + align-items: center; +} + +.landing .lead { + font-size: var(--t-lg); + line-height: 1.5; + color: var(--mute); + margin: var(--s3) 0 var(--s4); + max-width: 30em; +} + +.landing .cmd { + display: flex; + align-items: center; + gap: var(--s2); + background: #0c0c0c; + color: #ccc; + border-radius: var(--r-m); + padding: 10px 10px 10px var(--s3); + font-family: var(--mono); +} + +.landing .cmd .tg { + flex: none; +} + +.landing .cmd code { + flex: 1; + min-width: 0; + overflow-x: auto; + white-space: nowrap; + font-size: clamp(14px, 1.4vw, 16px); + user-select: all; + scrollbar-width: none; +} + +.landing .cmd code::-webkit-scrollbar { + display: none; +} + +.landing .copy { + flex: none; + min-width: 84px; + padding: 10px 16px; + border: 0; + border-radius: var(--r-s); + background: var(--green); + color: var(--on-green); + font: 600 var(--t-sm) / 1 var(--sans); + cursor: pointer; + transition: background-color 0.15s ease; +} + +.landing .copy:hover { + background: #43dc83; +} + +.landing .hero-meta { + margin: var(--s2) 0 var(--s3); + color: var(--mute); + font-size: var(--t-sm); +} + +/* terminal / snip colors (shared with hero terminal demo) */ +.landing .tg { + color: #16c60c; +} +.landing .ty { + color: #f9f1a5; +} +.landing .tr { + color: #e74856; +} +.landing .tc { + color: #61d6d6; +} +.landing .td { + color: #8c8c8c; +} +.landing .tx { + color: #f2f2f2; +} +.landing .tl { + color: #5b8dff; + text-decoration: underline; +} + +/* how it works */ +.landing .flow { + display: grid; + grid-template-columns: 1fr 112px 1fr 112px 1fr; + align-items: stretch; +} + +.landing .node { + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--r-m); + padding: var(--s3); +} + +.landing .node h3 { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: var(--s1); +} + +.landing .num { + display: inline-grid; + place-items: center; + flex: none; + width: 24px; + height: 24px; + border: 1px solid var(--ink); + border-radius: 50%; + font: 500 var(--t-xs) / 1 var(--mono); +} + +.landing .node code { + display: block; + font-size: var(--t-xs); + color: var(--green-ink); + margin: 0 0 var(--s1); + word-break: break-all; +} + +.landing .node p { + margin: 0; + color: var(--mute); + font-size: var(--t-sm); +} + +.landing .link { + display: grid; + align-content: center; + justify-items: center; + gap: var(--s1); +} + +.landing .link .lab { + font: var(--t-xs) / 1 var(--mono); + color: var(--mute); +} + +.landing .ln { + position: relative; + width: 100%; + height: 2px; + background: color-mix(in srgb, var(--mute) 40%, transparent); +} + +.landing .dot { + position: absolute; + display: none; + top: 50%; + left: calc(100% - 10px); + width: 10px; + height: 10px; + margin-top: -5px; + border-radius: 50%; + background: var(--green); +} + +.landing .flow.in .dot { + display: block; + animation: landing-flowx 1.6s ease-in-out infinite alternate; +} + +.landing .flow.in .link.b .dot { + animation-delay: 1.6s; +} + +@keyframes landing-flowx { + from { + left: calc(100% - 10px); + } + to { + left: 0; + } +} + +@keyframes landing-flowy { + from { + top: calc(100% - 10px); + } + to { + top: 0; + } +} + +/* zero install */ +.landing .zero-grid { + display: grid; + grid-template-columns: 1fr 1.2fr; + gap: var(--s6); + align-items: start; +} + +.landing .zero-grid .head { + margin: 0; +} + +.landing .lists { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--s4); +} + +.landing .lists h3 { + margin-bottom: var(--s1); + font-size: var(--t-sm); + color: var(--mute); + font-weight: 500; +} + +.landing .lists ul { + list-style: none; + margin: 0; + padding: 0; +} + +.landing .lists li { + display: flex; + gap: 12px; + padding: 11px 0; + border-top: 1px solid var(--line); + font-size: var(--t-sm); +} + +.landing .lists li::before { + flex: none; + width: 16px; + font-family: var(--mono); + font-weight: 600; +} + +.landing .no li { + color: var(--mute); +} + +.landing .no li::before { + content: "\00d7"; + color: var(--no); +} + +.landing .yes li::before { + content: "\2713"; + color: var(--green-ink); +} + +/* use cases */ +.landing .bento { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--s2); +} + +.landing .tile { + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--r-m); + padding: var(--s3); +} + +.landing .tile h3 { + margin-bottom: 6px; +} + +.landing .tile p { + margin: 0; + color: var(--mute); + font-size: var(--t-sm); +} + +.landing .tile.big { + grid-column: span 2; + grid-row: span 2; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: var(--s4); + padding: var(--s4); +} + +.landing .tile.big h3 { + font-size: var(--t-lg); +} + +.landing .snip { + background: #0c0c0c; + color: #ccc; + border-radius: var(--r-s); + padding: var(--s2) var(--s3); + font-size: var(--t-xs); + line-height: 1.8; + overflow-x: auto; +} + +.landing .snip p { + margin: 0; + color: inherit; + font-size: inherit; + white-space: nowrap; +} + +/* capabilities */ +.landing .insp { + margin: 0 0 var(--s5); + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--r-m); + overflow: hidden; +} + +.landing .insp-bar { + display: flex; + align-items: center; + gap: var(--s2); + padding: 10px var(--s2); + border-bottom: 1px solid var(--line); + font: var(--t-xs) / 1 var(--mono); + color: var(--mute); +} + +.landing .dots { + display: flex; + gap: 6px; +} + +.landing .dots i { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--line); +} + +.landing .insp-body { + display: grid; + grid-template-columns: minmax(260px, 0.85fr) 1.4fr; +} + +.landing .reqs { + list-style: none; + margin: 0; + padding: var(--s1); + border-right: 1px solid var(--line); + font: var(--t-xs) / 1.4 var(--mono); +} + +.landing .reqs li { + display: grid; + grid-template-columns: 5ch minmax(0, 1fr) 3.5ch 6ch; + gap: 1ch; + align-items: baseline; + padding: 8px 10px; + border-radius: 6px; +} + +.landing .reqs li.sel { + background: var(--tint); +} + +.landing .reqs b { + font-weight: 600; +} + +.landing .reqs span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.landing .reqs em { + font-style: normal; + color: var(--green-ink); +} + +.landing .reqs em.w { + color: var(--warn); +} + +.landing .reqs small { + color: var(--mute); + text-align: right; + font-size: inherit; +} + +.landing .detail { + padding: var(--s3); + min-width: 0; +} + +.landing .dh { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 10px; + font: var(--t-sm) / 1.4 var(--mono); +} + +.landing .dh em { + font-style: normal; + color: var(--green-ink); +} + +.landing .dh small { + color: var(--mute); + font-size: var(--t-xs); +} + +.landing .tabs { + display: flex; + gap: var(--s3); + margin: var(--s2) 0; + border-bottom: 1px solid var(--line); + font-size: var(--t-sm); + color: var(--mute); +} + +.landing .tabs span { + padding-bottom: 8px; + margin-bottom: -1px; + border-bottom: 2px solid transparent; +} + +.landing .tabs .on { + color: var(--ink); + border-color: var(--green); +} + +.landing .hdrs { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px var(--s2); + margin: 0 0 var(--s2); + font: var(--t-xs) / 1.5 var(--mono); +} + +.landing .hdrs dt { + color: var(--mute); +} + +.landing .hdrs dd { + margin: 0; + word-break: break-all; +} + +.landing .detail pre { + margin: 0; + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--r-s); + padding: 12px var(--s2); + font-size: var(--t-xs); + line-height: 1.6; + overflow-x: auto; +} + +.landing .insp-cap { + padding: 12px var(--s2); + border-top: 1px solid var(--line); + color: var(--mute); + font-size: var(--t-sm); +} + +.landing .group + .group { + margin-top: var(--s5); +} + +.landing .glabel { + font-size: var(--t-sm); + font-weight: 600; + color: var(--green-ink); + margin: 0 0 var(--s2); +} + +.landing .cols4 { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0 var(--s4); +} + +.landing .item { + border-top: 1px solid var(--line); + padding: 22px 0 var(--s4); +} + +.landing .item h3 { + margin-bottom: 6px; +} + +.landing .item p { + margin: 0; + color: var(--mute); + font-size: var(--t-sm); +} + +.landing .soon { + margin: var(--s5) 0 0; + color: var(--mute); + font-size: var(--t-sm); +} + +.landing .soon b { + color: var(--ink); + font-weight: 600; +} + +/* compare */ +.landing .tw { + overflow-x: auto; + border: 1px solid var(--line); + border-radius: var(--r-m); + background: var(--bg); +} + +.landing table { + border-collapse: collapse; + width: 100%; + min-width: 760px; + font-size: var(--t-sm); +} + +.landing th, +.landing td { + padding: 14px 20px; + text-align: left; + border-bottom: 1px solid var(--line); + vertical-align: top; +} + +.landing tr:last-child td { + border-bottom: 0; +} + +.landing thead th { + font-size: var(--t-xs); + color: var(--mute); + font-weight: 500; +} + +.landing thead th.me { + color: var(--ink); + font-weight: 600; +} + +.landing .me { + background: var(--tint); +} + +.landing td.me { + font-weight: 500; +} + +.landing td small { + display: block; + color: var(--mute); + font-size: var(--t-xs); + margin-top: 2px; + font-weight: 400; +} + +.landing .v { + display: inline-flex; + gap: 8px; + align-items: baseline; +} + +.landing .v.y::before { + content: "\2713"; + color: var(--green-ink); + font-weight: 600; +} + +.landing .v.n { + color: var(--mute); +} + +.landing .v.n::before { + content: "\00d7"; + color: var(--no); + font-weight: 600; +} + +.landing .v.t { + color: var(--mute); +} + +.landing td.me .v.t { + color: var(--ink); +} + +.landing .fn { + margin: var(--s2) 0 0; + color: var(--mute); + font-size: var(--t-xs); + max-width: 60em; +} + +/* pricing */ +.landing .plans { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--s3); +} + +.landing .plan { + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--r-m); + padding: var(--s4); + display: flex; + flex-direction: column; +} + +.landing .plan.hl { + border: 2px solid var(--green); +} + +.landing .plan .tag { + color: var(--mute); + font-size: var(--t-sm); + margin: 2px 0 0; +} + +.landing .plan .amt { + font-size: var(--t-2xl); + font-weight: 600; + letter-spacing: -0.05em; + line-height: 1; + margin: var(--s3) 0 var(--s1); +} + +.landing .plan .amt span { + font-size: var(--t-sm); + letter-spacing: 0; + font-weight: 400; + color: var(--mute); +} + +.landing .plan > p { + margin: 0; + color: var(--mute); + font-size: var(--t-sm); +} + +.landing .plan ul { + list-style: none; + margin: 22px 0 var(--s3); + padding: 0; + flex: 1; +} + +.landing .plan li { + padding: 11px 0; + border-top: 1px solid var(--line); + font-size: var(--t-sm); +} + +.landing .plan .btn { + align-self: flex-start; +} + +/* faq */ +.landing .faq-grid { + display: grid; + grid-template-columns: 1fr 1.8fr; + gap: var(--s6); + align-items: start; +} + +.landing .faq-grid h2 { + position: sticky; + top: calc(env(safe-area-inset-top, 0px) + 100px); +} + +.landing details { + border-top: 1px solid var(--line); + padding: 20px 0; +} + +.landing details:last-child { + border-bottom: 1px solid var(--line); +} + +.landing summary { + cursor: pointer; + font-weight: 500; + font-size: var(--t-md); + list-style: none; + display: flex; + justify-content: space-between; + gap: var(--s2); +} + +.landing summary::-webkit-details-marker { + display: none; +} + +.landing summary::after { + content: "+"; + font: var(--t-lg) / 1.3 var(--mono); + color: var(--mute); +} + +.landing details[open] summary::after { + content: "\2212"; +} + +.landing details p { + color: var(--mute); + margin: 12px 0 0; + max-width: 40em; + font-size: var(--t-sm); +} + +.landing details::details-content { + block-size: 0; + overflow: hidden; + transition: + block-size 0.25s ease, + content-visibility 0.25s allow-discrete; +} + +.landing details[open]::details-content { + block-size: auto; +} + +/* closing cta */ +.landing .box { + background: #0c0c0c; + color: #f2f2f2; + border-radius: var(--r-l); + padding: var(--s6); + display: grid; + grid-template-columns: 1.1fr 1fr; + gap: var(--s5); + align-items: center; +} + +.landing .box h2 { + margin-bottom: var(--s1); +} + +.landing .box p { + margin: 0; + color: #9a9a9a; +} + +.landing .box .cmd { + background: #161616; + border: 1px solid #2a2a2a; + margin-bottom: var(--s2); +} + +.landing .box .btn { + background: transparent; + border-color: #3a3a3a; + color: #f2f2f2; +} + +.landing .box .btn:hover { + border-color: #8a8a8a; +} + +/* footer */ +.landing footer { + border-top: 1px solid var(--line); + padding: var(--s5) 0 var(--s6); + color: var(--mute); + font-size: var(--t-sm); +} + +.landing .foot { + display: grid; + grid-template-columns: 1.6fr 1fr 1fr; + gap: var(--s5); +} + +.landing footer h4 { + margin: 0 0 12px; + font-size: var(--t-sm); + color: var(--ink); + font-weight: 600; +} + +.landing footer ul { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: var(--s1); +} + +.landing footer a { + text-decoration: none; +} + +.landing footer a:hover { + color: var(--ink); +} + +.landing footer p { + margin: 10px 0 0; + max-width: 22em; +} + +.landing .legal { + margin-top: var(--s5); + padding-top: var(--s3); + border-top: 1px solid var(--line); + font-size: var(--t-xs); +} + +/* responsive */ +@media (max-width: 1100px) { + .landing .cols4 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 960px) { + .landing .hero-grid, + .landing .zero-grid, + .landing .faq-grid, + .landing .plans, + .landing .box, + .landing .foot, + .landing .lists { + grid-template-columns: 1fr; + } + + .landing .hero-grid { + gap: var(--s5); + } + + .landing .zero-grid, + .landing .faq-grid { + gap: var(--s4); + } + + .landing .faq-grid h2 { + position: static; + } + + .landing .box { + padding: var(--s4) var(--s3); + } + + .landing .bento { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .landing .tile.big { + grid-column: span 2; + grid-row: auto; + } + + .landing .menu-btn { + display: inline-grid; + } + + .landing .links { + display: none; + position: absolute; + left: 0; + right: 0; + top: 100%; + margin: 0; + flex-direction: column; + gap: 0; + background: var(--bg); + border-bottom: 1px solid var(--line); + padding: var(--s1) 28px var(--s2); + } + + .landing .top.open .links { + display: flex; + } + + .landing .links a { + padding: 12px 0; + font-size: var(--t-md); + border-top: 1px solid var(--line); + } + + .landing .links a:first-child { + border-top: 0; + } + + .landing .nav-right { + margin-left: auto; + } + + .landing .flow { + grid-template-columns: 1fr; + } + + .landing .link { + grid-auto-flow: column; + justify-content: center; + align-items: center; + gap: var(--s2); + height: 76px; + } + + .landing .ln { + width: 2px; + height: 48px; + } + + .landing .dot { + left: 50%; + top: calc(100% - 10px); + margin: 0 0 0 -5px; + } + + .landing .flow.in .dot { + animation-name: landing-flowy; + } + + .landing .insp-body { + grid-template-columns: 1fr; + } + + .landing .reqs { + border-right: 0; + border-bottom: 1px solid var(--line); + } +} + +@media (max-width: 640px) { + .landing .wrap { + padding: 0 var(--s2); + } + + .landing .links { + padding-inline: var(--s2); + } + + .landing .bento, + .landing .cols4 { + grid-template-columns: 1fr; + } + + .landing .tile.big { + grid-column: auto; + } + + .landing .login, + .landing .nav-right .btn { + display: none; + } + + .landing .links .only-m { + display: block; + } + + .landing .cmd { + padding-left: var(--s2); + } +} + +@media (prefers-reduced-motion: reduce) { + .landing *, + .landing *::before, + .landing *::after { + animation: none !important; + transition: none !important; + } +} diff --git a/dashboard/app/layout.tsx b/dashboard/app/layout.tsx index c0ad384..18b43b4 100644 --- a/dashboard/app/layout.tsx +++ b/dashboard/app/layout.tsx @@ -1,9 +1,17 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import Script from "next/script"; import { Geist, JetBrains_Mono } from "next/font/google"; import { Toaster } from "sonner"; import { Providers } from "@/components/providers"; import { cn } from "@/lib/utils"; +import { + SEO_DESCRIPTION, + SEO_KEYWORDS, + SEO_TITLE, + SITE_NAME, + SITE_URL, + buildJsonLdGraph, +} from "@/lib/seo"; import "./globals.css"; const geistSans = Geist({ @@ -16,43 +24,42 @@ const jetbrainsMono = JetBrains_Mono({ subsets: ["latin"], }); +export const viewport: Viewport = { + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#f6f7f6" }, + { media: "(prefers-color-scheme: dark)", color: "#0a0c0b" }, + ], + colorScheme: "light dark", + width: "device-width", + initialScale: 1, + viewportFit: "cover", +}; + export const metadata: Metadata = { - metadataBase: new URL("https://tunl.online"), + metadataBase: new URL(SITE_URL), title: { - default: "tunl — Free SSH Reverse Tunnel | Expose Localhost Online (tunl.online)", + default: SEO_TITLE, template: "%s | tunl.online", }, - description: - "tunl (tunl.online) is a zero-install OpenSSH reverse tunneling platform. Expose localhost web servers, APIs, WebSockets, and webhooks to the public internet using a single SSH command.", - keywords: [ - "tunl", - "tunl online", - "ssh tunl", - "ssh tunnel", - "ssh reverse tunnel", - "expose localhost online", - "ngrok alternative openssh", - "free ssh reverse proxy", - "localhost https tunnel", - "webhook testing", - "dev tunnel", - "custom subdomains", - "localtunnel alternative", - "bore alternative", - "cloudflare tunnel alternative", - ], + description: SEO_DESCRIPTION, + keywords: SEO_KEYWORDS, alternates: { canonical: "/", }, authors: [ - { name: "tunl team", url: "https://tunl.online" }, + { name: "tunl", url: SITE_URL }, { name: "Pankaj Thakur", url: "https://thakur.dev" }, ], - creator: "tunl", - publisher: "tunl", + creator: "Pankaj Thakur", + publisher: SITE_NAME, category: "Developer Tools", - applicationName: "tunl", + applicationName: SITE_NAME, referrer: "origin-when-cross-origin", + formatDetection: { + email: false, + address: false, + telephone: false, + }, robots: { index: true, follow: true, @@ -68,25 +75,24 @@ export const metadata: Metadata = { openGraph: { type: "website", locale: "en_US", - url: "https://tunl.online", + url: SITE_URL, siteName: "tunl.online", - title: "tunl — Free SSH Reverse Tunnel | Expose Localhost Online", - description: - "Zero-install OpenSSH reverse tunneling. Expose localhost to the internet instantly with one command: ssh -R 80:localhost:3000 -p 2222 tunl.online", + title: "tunl — Make localhost public with one SSH command", + description: SEO_DESCRIPTION, images: [ { url: "/opengraph-image", width: 1200, height: 630, - alt: "tunl — Free SSH Reverse Tunnel Platform", + alt: "tunl — Free OpenSSH reverse tunnel to expose localhost online", }, ], }, twitter: { card: "summary_large_image", - title: "tunl — Free SSH Reverse Tunnel | Expose Localhost Online (tunl.online)", + title: "tunl — Make localhost public with one SSH command", description: - "Expose localhost web servers, APIs, and webhooks to the public internet via native OpenSSH. No CLI download required.", + "Zero-install OpenSSH reverse tunnel. Expose localhost, APIs, WebSockets, and webhooks with one ssh command. Free ngrok alternative.", creator: "@thakurdotdev", site: "@thakurdotdev", images: ["/opengraph-image"], @@ -95,139 +101,12 @@ export const metadata: Metadata = { icon: [{ url: "/favicon.ico" }, { url: "/tunl.svg", type: "image/svg+xml" }], apple: [{ url: "/tunl.svg" }], }, + other: { + "color-scheme": "light dark", + }, }; -const jsonLdGraph = { - "@context": "https://schema.org", - "@graph": [ - { - "@type": "WebSite", - "@id": "https://tunl.online/#website", - url: "https://tunl.online", - name: "tunl", - alternateName: ["tunl online", "tunl.online", "ssh tunl", "tunl reverse tunnel"], - description: - "Zero-install OpenSSH reverse tunneling platform to expose localhost to the public internet.", - inLanguage: "en-US", - publisher: { - "@id": "https://tunl.online/#organization", - }, - }, - { - "@type": "Organization", - "@id": "https://tunl.online/#organization", - name: "tunl", - url: "https://tunl.online", - logo: "https://tunl.online/tunl.svg", - sameAs: [ - "https://github.com/thakurdotdev", - "https://x.com/thakurdotdev", - "https://thakur.dev", - ], - }, - { - "@type": "SoftwareApplication", - "@id": "https://tunl.online/#application", - name: "tunl", - alternateName: "tunl online", - applicationCategory: "DeveloperApplication", - operatingSystem: "macOS, Linux, Windows, BSD", - url: "https://tunl.online", - description: - "Zero-install OpenSSH reverse tunneling platform. Expose localhost web servers, APIs, WebSockets, and webhooks to the public internet via standard OpenSSH.", - offers: { - "@type": "Offer", - price: "0", - priceCurrency: "USD", - }, - featureList: [ - "Zero-install OpenSSH reverse tunneling", - "Instant public HTTPS URLs with SSL certificates", - "WebSocket and Server-Sent Events (SSE) support", - "Persistent custom subdomains with Ed25519 SSH keys", - "Real-time HTTP request inspection and telemetry", - ], - }, - { - "@type": "HowTo", - "@id": "https://tunl.online/#howto", - name: "How to Expose Localhost to the Internet using SSH with tunl", - description: - "A quick 3-step guide to tunneling localhost to a public HTTPS URL using standard OpenSSH on tunl.online.", - step: [ - { - "@type": "HowToStep", - name: "Start your local server", - text: "Run your web server or API locally on any TCP port (e.g., localhost:3000).", - }, - { - "@type": "HowToStep", - name: "Run the tunl SSH command", - text: "Open your terminal and execute: ssh -R 80:localhost:3000 -p 2222 tunl.online", - }, - { - "@type": "HowToStep", - name: "Access your public HTTPS URL", - text: "Receive an instant live HTTPS endpoint (e.g., https://xyz.tunl.online) with automatic SSL certificates.", - }, - ], - }, - { - "@type": "FAQPage", - "@id": "https://tunl.online/#faq", - mainEntity: [ - { - "@type": "Question", - name: "What is tunl online?", - acceptedAnswer: { - "@type": "Answer", - text: "tunl (tunl.online) is a zero-install reverse tunneling platform powered by standard OpenSSH. It allows developers to expose local development servers, APIs, WebSockets, and webhooks to the public internet over secure HTTPS without installing any proprietary CLI agent or daemon.", - }, - }, - { - "@type": "Question", - name: "How does tunl create an SSH tunnel without installing any CLI?", - acceptedAnswer: { - "@type": "Answer", - text: "tunl uses native OpenSSH remote port forwarding (ssh -R). Since OpenSSH is preinstalled on macOS, Linux, and Windows 10/11, you can simply run 'ssh -R 80:localhost:3000 -p 2222 tunl.online' directly in your command line.", - }, - }, - { - "@type": "Question", - name: "How does tunl compare to ngrok and Cloudflare Tunnel?", - acceptedAnswer: { - "@type": "Answer", - text: "Unlike ngrok or Cloudflare Tunnel (cloudflared), tunl requires zero CLI downloads, zero background daemons, and zero account registration for ephemeral tunnels. It uses standard OpenSSH cryptographic keys instead of proprietary authentication tokens.", - }, - }, - { - "@type": "Question", - name: "Is tunl free to use?", - acceptedAnswer: { - "@type": "Answer", - text: "Yes, tunl is free to use. You can start anonymous ephemeral tunnels immediately with no credit card or account. Registering an SSH key allows you to claim persistent custom subdomains at no cost during developer preview.", - }, - }, - { - "@type": "Question", - name: "Can I reserve a custom persistent subdomain on tunl.online?", - acceptedAnswer: { - "@type": "Answer", - text: "Yes! Log in and register your OpenSSH public key (Ed25519 or RSA). Once registered, your custom subdomain (e.g., my-app.tunl.online) is tied to your key fingerprint and reserved exclusively for you.", - }, - }, - { - "@type": "Question", - name: "Does tunl support WebSockets, Next.js HMR, and webhook callbacks?", - acceptedAnswer: { - "@type": "Answer", - text: "Yes, tunl fully supports HTTP/1.1 protocol upgrades including WebSockets, Server-Sent Events (SSE), and Vite/Next.js Hot Module Replacement, as well as webhook payloads from Stripe, GitHub, Shopify, and Slack.", - }, - }, - ], - }, - ], -}; +const jsonLdGraph = buildJsonLdGraph(); export default function RootLayout({ children, diff --git a/dashboard/app/manifest.ts b/dashboard/app/manifest.ts index 7dbc143..3f9d267 100644 --- a/dashboard/app/manifest.ts +++ b/dashboard/app/manifest.ts @@ -2,14 +2,15 @@ import type { MetadataRoute } from "next"; export default function manifest(): MetadataRoute.Manifest { return { - name: "tunl — Free OpenSSH Reverse Tunneling Platform", + name: "tunl — Make localhost public with one SSH command", short_name: "tunl", description: - "Expose localhost web servers, APIs, and webhooks to the public internet via standard OpenSSH. No CLI binary downloads required.", + "Zero-install OpenSSH reverse tunnel. Expose localhost web servers, APIs, WebSockets, and webhooks with one ssh command. Free ngrok alternative.", start_url: "/", display: "standalone", - background_color: "#0a0a0a", - theme_color: "#0a0a0a", + background_color: "#0a0c0b", + theme_color: "#2fd272", + categories: ["developer", "utilities", "productivity"], icons: [ { src: "/tunl.svg", diff --git a/dashboard/app/opengraph-image.tsx b/dashboard/app/opengraph-image.tsx index f05defb..d18bb99 100644 --- a/dashboard/app/opengraph-image.tsx +++ b/dashboard/app/opengraph-image.tsx @@ -1,7 +1,8 @@ import { ImageResponse } from "next/og"; export const runtime = "edge"; -export const alt = "tunl — Free SSH Reverse Tunnel Platform (tunl.online)"; +export const alt = + "tunl — Make localhost public with one SSH command. Free zero-install OpenSSH reverse tunnel (tunl.online)"; export const size = { width: 1200, height: 630, diff --git a/dashboard/app/page.tsx b/dashboard/app/page.tsx index 8db0af7..eaf0948 100644 --- a/dashboard/app/page.tsx +++ b/dashboard/app/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import { Comparison } from "@/components/landing/comparison"; import { Faq } from "@/components/landing/faq"; import { Features } from "@/components/landing/features"; @@ -6,15 +7,40 @@ import { Hero } from "@/components/landing/hero"; import { HowItWorks } from "@/components/landing/how-it-works"; import { LandingFooter } from "@/components/landing/landing-footer"; import { LandingNav } from "@/components/landing/landing-nav"; +import { LandingShell } from "@/components/landing/landing-shell"; import { Pricing } from "@/components/landing/pricing"; import { UseCases } from "@/components/landing/use-cases"; import { WhySsh } from "@/components/landing/why-ssh"; +import { SEO_DESCRIPTION, SEO_KEYWORDS, SEO_TITLE, SITE_URL } from "@/lib/seo"; +import "./landing.css"; + +export const metadata: Metadata = { + title: { + absolute: SEO_TITLE, + }, + description: SEO_DESCRIPTION, + keywords: SEO_KEYWORDS, + alternates: { + canonical: SITE_URL, + }, + openGraph: { + title: "tunl — Make localhost public with one SSH command", + description: SEO_DESCRIPTION, + url: SITE_URL, + type: "website", + }, + twitter: { + title: "tunl — Make localhost public with one SSH command", + description: + "Zero-install OpenSSH reverse tunnel. Expose localhost, APIs, WebSockets, and webhooks with one ssh command.", + }, +}; export default function LandingPage() { return ( -
+ -
+
@@ -26,6 +52,6 @@ export default function LandingPage() {
-
+ ); } diff --git a/dashboard/components/hero-flow.tsx b/dashboard/components/hero-flow.tsx index 491187f..f532424 100644 --- a/dashboard/components/hero-flow.tsx +++ b/dashboard/components/hero-flow.tsx @@ -1,232 +1,8 @@ "use client"; -import { Check, Copy } from "lucide-react"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; - -const SSH_COMMAND = "ssh -R 80:localhost:3000 -p 2222 tunl.online"; - -const FLOW_REQUESTS = [ - { time: "14:07:39", method: "GET", path: "/", status: "200 OK", latency: "291ms" }, - { - time: "14:07:40", - method: "POST", - path: "/api/webhooks/stripe", - status: "200 OK", - latency: "34ms", - }, - { time: "14:07:41", method: "GET", path: "/api/v1/users", status: "200 OK", latency: "12ms" }, -]; +import { HeroTerminal } from "@/components/landing/hero-terminal"; +/** Auth-panel demo — same live tunl terminal output as the landing hero. */ export function HeroFlow() { - const [copied, setCopied] = useState(false); - const [flowStage, setFlowStage] = useState(0); - const [visibleRequests, setVisibleRequests] = useState(0); - - const handleCopy = () => { - navigator.clipboard.writeText(SSH_COMMAND); - setCopied(true); - toast.success("Command copied to clipboard"); - setTimeout(() => setCopied(false), 2000); - }; - - // Staggered sequential flow animation on mount - useEffect(() => { - const timers: NodeJS.Timeout[] = []; - - timers.push(setTimeout(() => setFlowStage(1), 50)); - timers.push(setTimeout(() => setFlowStage(2), 250)); - timers.push(setTimeout(() => setFlowStage(3), 500)); - timers.push(setTimeout(() => setFlowStage(4), 750)); - timers.push(setTimeout(() => setFlowStage(5), 1000)); - timers.push(setTimeout(() => setFlowStage(6), 1250)); - - timers.push(setTimeout(() => setVisibleRequests(1), 1450)); - timers.push(setTimeout(() => setVisibleRequests(2), 1750)); - timers.push(setTimeout(() => setVisibleRequests(3), 2050)); - - // Failsafe for background tab throttling - timers.push( - setTimeout(() => { - setFlowStage(6); - setVisibleRequests(3); - }, 2500), - ); - - return () => timers.forEach(clearTimeout); - }, []); - - return ( -
- {/* SSH Command Box */} -
- -
- - {/* Flow Diagram (Animated left-to-right / top-to-bottom) */} -
-
- {/* Localhost */} -
= 1 ? "translate-y-0 opacity-100" : "translate-y-2 opacity-0" - }`} - > - - Your machine - - localhost:3000 -
- - {/* SSH Arrow (Desktop) */} -