Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349<script lang="ts"> import { page } from "$app/state"; import { signupNewsletter } from "#lib/api/newsletter.ts"; import CopyLink from "#lib/components/landing/shell/CopyLink.svelte"; import SiteLink from "#lib/components/landing/shell/SiteLink.svelte"; import { FOOTER, LEGAL } from "#lib/components/landing/shell/links.ts"; import StatusDot from "#lib/components/StatusDot.svelte"; import Container from "#lib/components/landing/ui/Container.svelte"; import EmailForm from "#lib/components/landing/ui/EmailForm.svelte"; import Check from "$icon/check";
/** * The landing footer: the newsletter on the left, four link columns * on the right, the legal line under both. It spans the navbar's page column at every * width. Tablets keep the two halves side by side with * the columns two by two; phones stack them. */ let email = $state(""); let status = $state<"idle" | "sending" | "sent" | "failed">("idle"); let message = $state("");
async function subscribe(event: SubmitEvent) { event.preventDefault(); if (status === "sending") return; const address = email.trim(); if (!address) { status = "failed"; message = "Enter your email address."; return; } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address)) { status = "failed"; message = "Enter a valid email address."; return; } status = "sending"; message = ""; try { const deliberiUrl = page.data.publicConfig?.deliberiUrl; if (!deliberiUrl) throw new Error("Couldn't sign you up. Try again later."); await signupNewsletter(deliberiUrl, address); status = "sent"; email = ""; return; } catch (cause) { message = (cause as Error).message; } status = "failed"; }</script>
<footer class="foot"> <Container class="foot-in"> <div class="top"> <div class="loop"> <h2 class="l-trim">Stay in the loop</h2> <p> We've got a newsletter! Punch in your email to stay updated on what we're shipping at Tangled. </p> <EmailForm bind:value={email} label={status === "sent" ? "Subscribed" : "Subscribe"} icon={status === "sent" ? Check : undefined} disabled={status === "sending" || status === "sent"} invalid={status === "failed"} describedby={status === "failed" ? "newsletter-error" : undefined} onsubmit={subscribe} oninput={() => { if (status !== "sending") status = "idle"; }} /> {#if status === "failed"}<p class="note error" id="newsletter-error" role="alert"> {message} </p>{:else if status === "sent"}<p class="note" role="status"> You're signed up! </p>{/if} </div> <nav class="cols" aria-label="Footer"> {#each FOOTER as col (col.title)} <div> <h3 class="l-trim">{col.title}</h3> <ul> {#each col.links as link (link.label)} <li> {#if link.copy}<CopyLink {link} text={link.copy} />{:else if link.status}<SiteLink {link} >{link.label}<StatusDot /></SiteLink >{:else}<SiteLink {link} />{/if} </li> {/each} </ul> </div> {/each} </nav> </div>
<div class="legal"> <span class="ll"> <span class="c">© 2026 Tangled Labs Oy. All rights reserved.</span> {#each LEGAL as link (link.label)}<SiteLink {link} />{/each} </span> <a class="credit" href="https://www.altalogy.com/?ref=tangled" target="_blank" rel="noopener noreferrer">Design by Altalogy</a > </div> </Container></footer>
<style> .foot { position: relative; padding-block: 48px calc(32px + env(safe-area-inset-bottom, 0px)); } /* The hairline spans the column, centred on it, and fades out evenly at both ends. */ .foot :global(.foot-in)::before { content: ""; position: absolute; inset: -48px 0 auto; height: 1px; background: linear-gradient( 90deg, transparent, var(--hairline) 10%, var(--hairline) 90%, transparent ); }
/* minmax(0, …): the column shrinks to the screen rather than growing to the form's intrinsic width. */ .top { display: grid; grid-template-columns: minmax(0, 1fr); gap: 40px; }
.loop h2 { margin: 0 4px 16px; font-size: var(--text-small); font-weight: 600; line-height: 1.4; letter-spacing: -0.01em; color: var(--text); } /* Trimmed to its caps, as the heading is, so the gaps either side are the design's. */ .loop p { text-box: trim-both cap alphabetic; max-width: 337px; margin: 0 4px 24px; font-size: var(--text-small); font-weight: 450; line-height: 1.4; letter-spacing: -0.01em; color: var(--muted); }
.loop .note { margin: 8px 4px 0; color: var(--text); animation: note-in 0.18s var(--ease-out); } .loop .error { color: var(--danger); } @keyframes note-in { from { opacity: 0; translate: 0 -2px; } } @media (prefers-reduced-motion: reduce) { .loop .note { animation-duration: 0.01s; } }
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 32px 24px; } /* The column is spaced by trimmed text: 20px between the label and the first link and between links, each 13px link ~9px of cap height — a 29px pitch. The items take the links' own line box (18.2px, not the page's 24px), so the gaps are what's left of that 20px once the half-leading above and below the caps is counted. */ .cols h3 { margin-bottom: 16px; font-size: var(--text-small); font-weight: 600; line-height: 1.4; letter-spacing: -0.01em; color: var(--text); } .cols ul { display: grid; gap: 11px; padding: 0; list-style: none; } .cols li { font-size: var(--text-small); line-height: 1.4; } .cols li :global(a) { color: var(--text); font-size: var(--text-small); font-weight: 450; line-height: 1.4; letter-spacing: -0.01em; white-space: nowrap; transition: color 0.15s ease; } .cols li :global(a:hover) { color: var(--text); }
.legal { display: flex; flex-direction: column; gap: 12px; /* 48px from the last link's caps to the copyright's: less the half-leading of each. */ margin-top: 39px; padding: 0 4px; font-size: var(--text-legal); line-height: 1.6; letter-spacing: -0.01em; } .ll { display: flex; flex-wrap: wrap; gap: 4px 20px; } .c { flex-basis: 100%; color: var(--text-3); } .legal :global(a) { color: var(--muted); transition: color 0.15s ease; } .legal :global(a:hover) { color: var(--text); } /* The design credit: present, but out of the way until pointed at. */ .legal .credit { opacity: 0.4; transition: color 0.15s ease, opacity 0.15s ease; } .legal .credit:hover, .legal .credit:focus-visible { opacity: 1; }
/* Phones: no hairline, 40px under the CTA's button. */ @media (width < 48rem) { .foot { padding-block: 40px calc(43px + env(safe-area-inset-bottom, 0px)); } .foot :global(.foot-in)::before { display: none; } .loop h2, .loop p { max-width: none; margin-inline: 0; } .loop :global(.email) { width: 100%; } .cols { grid-template-columns: repeat(auto-fit, minmax(max(120px, (100% - 24px) / 2), 1fr)); } .legal { padding: 0; } }
@media (width >= 48rem) { .top { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; } }
/* Tablets: the links take only the width they need and sit at the column's end, so the right-hand ones line up with the screen's edge rather than floating mid-half. */ @media (48rem <= width < 64rem) { .top { grid-template-columns: minmax(0, 1fr) auto; } .cols { grid-template-columns: auto auto; gap: 32px 64px; } }
@media (width >= 64rem) { /* The form is the last thing in the column, so the note hangs just under it, out of flow: showing it never moves the footer. */ .loop { position: relative; } .loop .note { position: absolute; top: 100%; left: 0; right: 0; } /* The hairline runs on past both of the column's ends where there's room. */ .foot :global(.foot-in)::before { inset-inline: calc(-1 * min(34px, var(--bleed))); } .loop :global(.email) { width: min(100%, 291px); } .foot { padding-bottom: calc(27px + env(safe-area-inset-bottom, 0px)); } .top { display: flex; align-items: flex-start; justify-content: space-between; gap: 48px; } /* Fixed column widths, as drawn, so the columns sit where the design has them rather than wherever their longest link ends. */ .cols { grid-template-columns: 90px 78px 111px auto; gap: 32px; } .legal { flex-direction: row; align-items: center; justify-content: space-between; } .c { flex-basis: auto; } }</style>