Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ The source of the public site at `withmarfa.com`: Astro, built to static files a

- `src/pages/`: one file per page. `404.astro` is what Cloudflare serves for a missing path. `sitemap.xml.ts` lists every page except those in its `UNLISTED` set.
- `src/layouts/Base.astro`: the document head, the header, the column and the footer, used by every page.
- `src/components/`: the header (the Marfa mark, which leads home, a GitHub icon and the Docs pill), the footer (About, the Contact card and Privacy), the question that opens (one at a time within a group), and the sign-up form.
- `src/styles/global.css`: the color tokens, shared with the docs site's canvas and text colors. Light and dark follow the system; there is no toggle.
- `src/components/`: the header (the Marfa mark, which leads home, a GitHub icon and the Docs pill), which stays at the top as a page scrolls; the footer (About, Subscribe and Privacy); the Get Updates card with the sign-up form, opened from the footer and the home page; the Get in Touch card, opened from About; and the question that opens (one at a time within a group).
- `src/styles/global.css`: the color tokens, shared with the docs site's canvas and text colors, and the shared buttons and cards. Light and dark follow the system; there is no toggle.
- `src/lib/analytics.ts`: PostHog, loaded after the page.
- `public/`: copied as is. `_headers` sets the security headers and the Content Security Policy. `og.png` is the link preview image, rendered from `scripts/og-image.html`.
- `worker/index.ts`: the Worker, which answers `POST /api/subscribe`. `worker/worker-configuration.d.ts` is generated by `pnpm types` from `wrangler.jsonc`; regenerate it whenever bindings change, or `pnpm check` fails.
Expand All @@ -44,7 +44,7 @@ pnpm preview # build, then serve dist/ and the Worker with Wrangler as Cloudf

## Sign-ups

- The form on About posts to `/api/subscribe`. The Worker checks that the request comes from this site, limits each IP address to five tries a minute, ignores bots that fill the hidden `website` field, and keeps the address, lowercased, with the time in the `signups` table of the D1 database `withmarfa-website` (EU jurisdiction). A repeat address changes nothing and gets the same reply, so the form never reveals who is on the list.
- The form in the Get Updates card posts to `/api/subscribe`. The Worker checks that the request comes from this site, limits each IP address to five tries a minute, ignores bots that fill the hidden `website` field, and keeps the address, lowercased, with the time in the `signups` table of the D1 database `withmarfa-website` (EU jurisdiction). A repeat address changes nothing and gets the same reply, so the form never reveals who is on the list.
- With JavaScript the form shows the result in place; without it the Worker redirects to `/subscribed` or `/subscribe-failed`.
- Nothing logs an address. Invocation logs and traces are off, because they would keep each visitor's location and browser beside the time of their sign-up; only the Worker's own log lines are kept. To read the list, from a shell authenticated to the WithMarfa account: `pnpm exec wrangler d1 execute withmarfa-website --remote --command "SELECT email, created_at FROM signups ORDER BY created_at"`. To remove someone, `DELETE FROM signups WHERE email = '…'`.
- A schema change is a new file in `migrations/`, applied with `pnpm exec wrangler d1 migrations apply withmarfa-website --remote` before the code that needs it is merged. The deploy does not apply migrations.
Expand Down
40 changes: 40 additions & 0 deletions src/components/Contact.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
/**
* The Get in Touch card. It shows the address before anything opens a mail
* app, and offers to copy it instead.
*/
const EMAIL = 'hello@withmarfa.com';
---

<div id="contact" class="sheet" popover role="dialog" aria-labelledby="contact-title">
<p id="contact-title" class="sheet-title">Get in Touch</p>
<p>Questions, ideas, or want to hear more? Email <span class="address">{EMAIL}</span>.</p>
<div class="sheet-actions">
<button type="button" class="button plain" popovertarget="contact" popovertargetaction="hide">Close</button>
<button type="button" class="button secondary" data-copy={EMAIL}>Copy Address</button>
<a class="button primary" href={`mailto:${EMAIL}`}>Open Mail</a>
</div>
</div>

<script>
for (const button of document.querySelectorAll<HTMLButtonElement>('[data-copy]')) {
const label = button.textContent;
button.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(button.dataset.copy!);
button.textContent = 'Copied';
} catch {
// Clipboard access can be refused; the address stays on screen to select.
button.textContent = 'Couldn’t Copy';
}
setTimeout(() => (button.textContent = label), 1800);
});
}
</script>

<style>
.address {
color: var(--text);
user-select: all;
}
</style>
119 changes: 4 additions & 115 deletions src/components/Footer.astro
Original file line number Diff line number Diff line change
@@ -1,46 +1,20 @@
---
import { pagePath } from '../lib/path';
/**
* The footer, and the Contact card it opens. The card shows the address before
* anything opens a mail app, and offers to copy it instead.
*/
import Updates from './Updates.astro';

const path = pagePath(Astro.url);
const EMAIL = 'hello@withmarfa.com';
---

<footer class="col">
<span>© 2026 WithMarfa</span>
<nav aria-label="Site">
<a href="/about" aria-current={path === '/about' ? 'page' : undefined}>About</a>
<button type="button" popovertarget="contact">Contact</button>
<button type="button" popovertarget="updates">Subscribe</button>
<a href="/privacy" aria-current={path === '/privacy' ? 'page' : undefined}>Privacy</a>
</nav>
</footer>

<div id="contact" popover role="dialog" aria-labelledby="contact-title">
<p id="contact-title" class="title">Get in touch</p>
<p class="address">{EMAIL}</p>
<div class="actions">
<button type="button" class="secondary" data-copy={EMAIL}>Copy address</button>
<a class="primary" href={`mailto:${EMAIL}`}>Open Mail</a>
</div>
</div>

<script>
for (const button of document.querySelectorAll<HTMLButtonElement>('[data-copy]')) {
const label = button.textContent;
button.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(button.dataset.copy!);
button.textContent = 'Copied';
} catch {
// Clipboard access can be refused; the address stays on screen to select.
button.textContent = 'Couldn’t copy';
}
setTimeout(() => (button.textContent = label), 1800);
});
}
</script>
<Updates />

<style>
footer {
Expand Down Expand Up @@ -75,89 +49,4 @@ const EMAIL = 'hello@withmarfa.com';
nav a[aria-current='page'] {
color: var(--text);
}

/* The card sits in the middle of the window, over a soft veil. */
[popover] {
width: min(320px, calc(100vw - 32px));
box-sizing: border-box;
margin: auto;
padding: 20px;
border: 1px solid var(--rule);
border-radius: 16px;
background: var(--bg);
color: var(--text);
box-shadow: 0 12px 40px color-mix(in srgb, #000 14%, transparent);
opacity: 0;
transform: translateY(6px) scale(0.98);
transition:
opacity 0.2s ease,
transform 0.2s ease,
overlay 0.2s allow-discrete,
display 0.2s allow-discrete;
}

[popover]:popover-open {
opacity: 1;
transform: none;
}

@starting-style {
[popover]:popover-open {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
}

[popover]::backdrop {
background: color-mix(in srgb, #000 18%, transparent);
}

.title {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
}

.address {
margin: 0 0 16px;
color: var(--secondary);
font-size: 15px;
user-select: all;
}

.actions {
display: flex;
gap: 8px;
}

.actions > * {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
padding: 0 12px;
border: 0;
border-radius: 10px;
font: inherit;
font-size: 14px;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: opacity 0.15s ease;
}

.actions > *:hover {
opacity: 0.85;
}

.secondary {
background: color-mix(in srgb, var(--text) 7%, transparent);
color: var(--text);
}

.primary {
background: var(--text);
color: var(--bg);
}
</style>
111 changes: 74 additions & 37 deletions src/components/Header.astro
Original file line number Diff line number Diff line change
Expand Up @@ -7,41 +7,68 @@ import { pagePath } from '../lib/path';
const path = pagePath(Astro.url);
---

<header class="col">
<a class="mark" href="/" aria-label="WithMarfa home" aria-current={path === '/' ? 'page' : undefined}>
<svg viewBox="11 4 360 360" width="20" height="20" aria-hidden="true">
<clipPath id="mark-tile"><rect x="11" y="4" width="360" height="360" rx="120"></rect></clipPath>
<g clip-path="url(#mark-tile)">
<rect x="11" y="4" width="360" height="360" class="tile"></rect>
<path class="arrow" d="M489.455 -105C516.618 -105 537.669 -97.5247 552.609 -82.574C566.87 -67.6233 574 -46.5565 574 -19.3735L574 546.373C574 574.236 566.191 595.303 550.572 609.574C534.953 624.525 512.883 632 484.362 632C460.594 632 441.919 627.243 428.338 617.729C414.077 608.894 401.175 593.944 389.63 572.877L170.628 179.402H206.279L-11.7048 572.877C-23.9282 593.944 -36.8307 608.894 -50.4123 617.729C-64.6729 627.243 -83.6871 632 -107.455 632C-134.618 632 -155.33 624.525 -169.59 609.574C-184.53 595.303 -192 574.236 -192 546.373L-192 -19.3735C-192 -46.5565 -184.87 -67.6233 -170.609 -82.574C-156.348 -97.5247 -135.297 -105 -107.455 -105C-80.2917 -105 -59.2403 -97.5247 -44.3006 -82.574C-30.0399 -67.6233 -22.9096 -46.5565 -22.9096 -19.3735L-22.9096 331.288H-44.3006L119.697 45.8658C129.204 30.9151 139.39 19.7022 150.255 12.2268C160.441 4.75149 174.363 1.01385 192.019 1.01385C208.996 1.01385 222.917 4.75149 233.782 12.2268C244.647 19.7022 254.494 30.9151 263.322 45.8658L428.338 332.307H404.91L404.91 -19.3735C404.91 -46.5565 412.04 -67.6233 426.301 -82.574C440.561 -97.5247 461.613 -105 489.455 -105Z"></path>
</g>
</svg>
</a>
<nav aria-label="Elsewhere">
<a class="github" href="https://github.com/withmarfa" aria-label="GitHub">
<svg viewBox="0 0 16 16" width="17" height="17" aria-hidden="true">
<path
fill="currentColor"
d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"
></path>
<header>
<div class="col bar">
<a class="mark" href="/" aria-label="WithMarfa home" aria-current={path === '/' ? 'page' : undefined}>
<svg viewBox="11 4 360 360" width="20" height="20" aria-hidden="true">
<clipPath id="mark-tile"><rect x="11" y="4" width="360" height="360" rx="120"></rect></clipPath>
<g clip-path="url(#mark-tile)">
<rect x="11" y="4" width="360" height="360" class="tile"></rect>
<path class="arrow" d="M489.455 -105C516.618 -105 537.669 -97.5247 552.609 -82.574C566.87 -67.6233 574 -46.5565 574 -19.3735L574 546.373C574 574.236 566.191 595.303 550.572 609.574C534.953 624.525 512.883 632 484.362 632C460.594 632 441.919 627.243 428.338 617.729C414.077 608.894 401.175 593.944 389.63 572.877L170.628 179.402H206.279L-11.7048 572.877C-23.9282 593.944 -36.8307 608.894 -50.4123 617.729C-64.6729 627.243 -83.6871 632 -107.455 632C-134.618 632 -155.33 624.525 -169.59 609.574C-184.53 595.303 -192 574.236 -192 546.373L-192 -19.3735C-192 -46.5565 -184.87 -67.6233 -170.609 -82.574C-156.348 -97.5247 -135.297 -105 -107.455 -105C-80.2917 -105 -59.2403 -97.5247 -44.3006 -82.574C-30.0399 -67.6233 -22.9096 -46.5565 -22.9096 -19.3735L-22.9096 331.288H-44.3006L119.697 45.8658C129.204 30.9151 139.39 19.7022 150.255 12.2268C160.441 4.75149 174.363 1.01385 192.019 1.01385C208.996 1.01385 222.917 4.75149 233.782 12.2268C244.647 19.7022 254.494 30.9151 263.322 45.8658L428.338 332.307H404.91L404.91 -19.3735C404.91 -46.5565 412.04 -67.6233 426.301 -82.574C440.561 -97.5247 461.613 -105 489.455 -105Z"></path>
</g>
</svg>
</a>
<a class="docs" href="https://docs.marfa.so">
Docs
<svg viewBox="0 0 10 10" width="9" height="9" aria-hidden="true">
<path d="M2.5 7.5 7.5 2.5M3.5 2.5h4v4" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</a>
</nav>
<nav aria-label="Elsewhere">
<a class="github" href="https://github.com/withmarfa" aria-label="GitHub">
<svg viewBox="0 0 16 16" width="17" height="17" aria-hidden="true">
<path
fill="currentColor"
d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"
></path>
</svg>
</a>
<a class="pill docs" href="https://docs.marfa.so">
Docs
<svg viewBox="0 0 10 10" width="9" height="9" aria-hidden="true">
<path d="M2.5 7.5 7.5 2.5M3.5 2.5h4v4" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</a>
</nav>
</div>
</header>

<script>
const header = document.querySelector('header')!;
const mark = () => header.toggleAttribute('data-scrolled', window.scrollY > 4);
addEventListener('scroll', mark, { passive: true });
mark();
</script>

<style>
/* Stays at the top while a longer page scrolls beneath it, frosted so the
page shows faintly through, with a hairline once the page has moved. */
header {
position: sticky;
top: 0;
z-index: 10;
margin: 0 calc(var(--gutter) * -1);
padding: var(--header-space) var(--gutter);
background: color-mix(in srgb, var(--bg) 80%, transparent);
-webkit-backdrop-filter: blur(16px) saturate(1.4);
backdrop-filter: blur(16px) saturate(1.4);
box-shadow: 0 1px 0 transparent;
transition: box-shadow 0.2s ease;
}

header[data-scrolled] {
box-shadow: 0 1px 0 var(--rule);
}

.bar {
display: flex;
justify-content: space-between;
align-items: center;
min-height: 44px;
padding-top: var(--header-top);
}

.mark {
Expand All @@ -67,13 +94,14 @@ const path = pagePath(Astro.url);
nav {
display: flex;
align-items: center;
gap: 14px;
gap: 8px;
}

/* Secondary to the Docs pill: an icon only, in the muted color. */
/* Quieter than the pill: an icon only, in the muted color. */
.github {
position: relative;
display: inline-flex;
margin-right: 6px;
color: var(--muted);
transition: color 0.15s ease;
}
Expand All @@ -85,36 +113,45 @@ const path = pagePath(Astro.url);
.github::after {
content: '';
position: absolute;
inset: -13px -8px;
inset: -13px -7px;
}

/* A small translucent pill: quiet against the page in both themes. */
.docs {
/* A small, quiet pill: the page's text stays the loudest thing on it. */
.pill {
position: relative;
display: inline-flex;
align-items: center;
gap: 5px;
height: 26px;
padding: 0 11px 0 12px;
height: 28px;
padding: 0 12px;
border: 0;
border-radius: 14px;
background: color-mix(in srgb, var(--text) 7%, transparent);
color: var(--text);
font: inherit;
font-size: 13px;
font-weight: 500;
line-height: 1;
white-space: nowrap;
text-decoration: none;
cursor: pointer;
transition:
background-color 0.15s ease,
transform 0.15s ease;
}

.docs:hover {
.pill:hover {
background: color-mix(in srgb, var(--text) 12%, transparent);
}

.docs:active {
.pill:active {
transform: scale(0.97);
}

.docs {
padding-right: 11px;
}

.docs svg {
opacity: 0.7;
transition: transform 0.15s ease;
Expand All @@ -124,10 +161,10 @@ const path = pagePath(Astro.url);
transform: translate(1px, -1px);
}

/* The pill's 26px stays the look; this widens what a finger can hit. */
.docs::after {
/* The pill keeps its 28px look; this widens what a finger can hit. */
.pill::after {
content: '';
position: absolute;
inset: -9px -4px;
inset: -8px -3px;
}
</style>
Loading
Loading