Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ jobs:
run: python3 tools/seo_check.py dist
- name: Validate image budget
run: python3 tools/check_images.py

- name: Newsletter assembly unit tests
run: python3 tools/test_newsletter_draft.py
1 change: 1 addition & 0 deletions .github/workflows/lighthouse.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ jobs:
--collect.url=http://localhost:8299/speak.html
--collect.url=http://localhost:8299/about.html
--collect.url=http://localhost:8299/contact.html
--collect.url=http://localhost:8299/privacy.html
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
Expand Down
89 changes: 89 additions & 0 deletions .github/workflows/newsletter.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
name: Monthly newsletter draft

# Assembles the monthly newsletter and creates it as a DRAFT in Loops, then
# opens a GitHub issue so the owner knows it is ready to read and send. It never
# sends anything: a human opens Loops and hits send. If a month has no news, the
# tool writes nothing and no issue is opened.

on:
schedule:
# 14:00 UTC on the 1st of each month. Covers the previous calendar month.
- cron: '0 14 1 * *'
workflow_dispatch:
inputs:
month:
description: 'Target month YYYY-MM (blank = previous calendar month)'
required: false
default: ''
dry_run:
description: 'Render only, do not create a Loops draft'
type: boolean
required: false
default: false

permissions:
contents: read
issues: write

concurrency:
group: newsletter
cancel-in-progress: false

jobs:
draft:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Read enough history for the git-log "what's new" scan.
fetch-depth: 0

- uses: actions/setup-python@v5
with:
python-version: '3.x'

- name: Install PyYAML
run: pip install pyyaml

- name: Assemble the draft
id: draft
env:
LOOPS_API_KEY: ${{ secrets.LOOPS_API_KEY }}
run: |
python3 tools/newsletter_draft.py \
${{ github.event.inputs.month && format('--month {0}', github.event.inputs.month) || '' }} \
${{ github.event.inputs.dry_run == 'true' && '--dry-run' || '' }}

- name: Notify the owner
if: steps.draft.outputs.drafted == 'true'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const subject = process.env.SUBJECT || 'Newsletter draft ready';
const previewPath = process.env.PREVIEW || '';
let preview = '';
try { preview = fs.readFileSync(previewPath, 'utf8'); } catch (e) {}
const body = [
'Your monthly newsletter draft is ready in Loops. Nothing has been sent.',
'',
'To send it: open Loops, go to Campaigns, open this draft, read it, and',
'press Send when you are happy. Silence sends nothing.',
'',
'<details><summary>Preview of the draft</summary>',
'',
'```',
preview,
'```',
'',
'</details>',
].join('\n');
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `Newsletter draft ready: ${subject}`,
body,
});
env:
SUBJECT: ${{ steps.draft.outputs.subject }}
PREVIEW: ${{ steps.draft.outputs.preview }}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,6 @@ public/assets/img/.orig/

# Lighthouse CI output
.lighthouseci/

# Newsletter draft preview (rendered by tools/newsletter_draft.py at run time)
newsletter-preview.txt
53 changes: 53 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,59 @@ contained.
- Pages still render and stay legible if the GA request is blocked (no dependency),
so the offline-friendly experience holds.

## Newsletter

Email capture and a monthly draft, on **Loops** (free plan). The signup box and the
privacy note ship together: never one without the other.

- **Signup box = a plain HTML form, no third-party JS.** This is why Loops was
chosen over every provider that wants a JS embed: it accepts a plain form POST to a
"custom form" endpoint, so it keeps the no-external-scripts rule (GA stays the one
exception). [Newsletter.astro](src/components/Newsletter.astro) posts to
`settings.newsletter.formEndpoint`; with JS off it is a normal full-page POST, and
[main.js](public/assets/js/main.js) enhances it to an inline AJAX submit (mirrors
the contact form, same `_honey` honeypot). Every string, and the endpoint, live in
the `newsletter` object of [content/settings.yaml](content/settings.yaml) (schema in
[keystatic.config.ts](keystatic.config.ts)), editable from `/keystatic`. Do **not**
hardcode the endpoint or paste a Loops JS snippet.
- **Placement is one setting.** `settings.newsletter.placement` is `footer` (the band
above the footer, every page) or `ideas` (one block on the Ideas page). The Footer
and Ideas page each render `Newsletter` only for their mode.
- **Double opt-in is on in Loops** (Settings → Sending). It applies only to the form
endpoint, so subscribers land unconfirmed until they click the email. Never add
contacts through the API and assume they are confirmed.
- **Privacy + consent are load-bearing, not decoration.** [privacy.astro](src/pages/privacy.astro)
(`content/privacy.yaml`) is the plain-English note; the consent line next to every
email field links to it, and so does the contact form. `tools/validate_site.py`
fails CI if a built newsletter form's action drifts from `settings.yaml`, if the
honeypot is dropped, or if any page with an email input stops linking the privacy
note. That triad is what stops a redesign from quietly breaking subscriptions or
dropping consent. Keep it.
- **CAN-SPAM address:** the physical mailing address required in the email footer is
set in **Loops account settings**, never in this repo. Do not commit a home address.

### The monthly draft (never a send)

[tools/newsletter_draft.py](tools/newsletter_draft.py), run monthly by
[.github/workflows/newsletter.yml](.github/workflows/newsletter.yml), assembles a
draft and creates it in Loops via `POST /v1/campaigns`. **It never sends.** A human
opens Loops and hits send; silence is not approval.

- The email leads with one styling idea (drawn from `content/ideas.yaml` notes, his
own words, rotated by month), then a short "what's new" built from
`instagram-ledger.json` and the git history of `content/*.yaml` (adds and
replacements; trims are not announced). Window is the previous calendar month.
- **No news, no draft.** `build_draft` returns `None` when the month has nothing to
report, and the workflow opens no issue. A padded newsletter is worse than none.
- The Loops API key is the `LOOPS_API_KEY` GitHub Actions secret. It never enters the
repo, the build, client code, a log, or a PR. `--dry-run` renders to
`newsletter-preview.txt` (gitignored) without touching the API.
- Tests: `python3 tools/test_newsletter_draft.py` (in CI). The pure assembly logic is
dependency-free; only the CLI edges read YAML, shell out to git, and call Loops.
- One caveat verified on first run: no Loops page states the Campaign API is on by
default for a new free team, so the first real call is the test. `create_loops_draft`
is the single place to adjust the request body if the live API differs from the docs.

### When you add a new page

1. Add a singleton to `keystatic.config.ts` (reuse the `seo()` helper) and a
Expand Down
24 changes: 24 additions & 0 deletions content/privacy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
seo:
title: "Privacy note | Quentin Fears"
description: "How Quentin Fears handles the email you share via the newsletter or contact form: what is collected, why, and how to unsubscribe or delete it."
ogTitle: "Privacy note | Quentin Fears"
ogDescription: "What I collect, why, and how to unsubscribe or ask for deletion."
ogType: "website"
hero:
eyebrow: "Privacy"
heading: "A short, plain privacy note."
intro: "This covers the email address you give me through the newsletter signup or the contact form. It is written to be read, not to bury anything."
updated: "July 2026"
sections:
- heading: "What I collect"
body: "If you subscribe to the newsletter, I collect your email address. If you send a message through the contact form, I collect your email address and whatever you choose to type: your name, company, and message. That is all. No tracking profile, no selling of data."
- heading: "Why I collect it"
body: "The newsletter email address is used only to send you the newsletter, roughly once a month. A contact-form message is used only to reply to you about your inquiry. I do not use either to send you anything you did not ask for."
- heading: "Who processes it"
body: "The newsletter runs on Loops, an email service that stores your address and sends the emails on my behalf. Subscribing uses double opt-in: after you sign up, Loops sends a confirmation email, and you are added only once you click to confirm. Contact-form messages are delivered to my inbox through FormSubmit. Both are third parties acting as processors, and your address is shared with them only for these purposes."
- heading: "How to unsubscribe"
body: "Every newsletter has an unsubscribe link in the footer. Click it and you are removed straight away. You can also email me and I will remove you."
- heading: "How to ask for deletion"
body: "Email me at <a href=\"mailto:hello@quentinfears.com\">hello@quentinfears.com</a> and ask me to delete your details, and I will remove your address from Loops and delete the related messages. No reason needed."
- heading: "Questions"
body: "Anything unclear, or you want to know exactly what I hold about you, email <a href=\"mailto:hello@quentinfears.com\">hello@quentinfears.com</a> and I will answer."
13 changes: 13 additions & 0 deletions content/settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,16 @@ footerBlurb: "I help brands and people use style to say who they are, and who th
tagline: "Don't just wear clothes, wear confidence."
copyrightName: "Quentin Fears"
copyrightYear: "2026"
newsletter:
formEndpoint: "https://app.loops.so/api/newsletter-form/REPLACE_WITH_FORM_ID"
placement: "footer"
eyebrow: "The newsletter"
heading: "Style notes, once a month."
blurb: "One styling idea to use this week, then a short note on what is new in the work. No noise, about once a month."
emailLabel: "Email address"
emailPlaceholder: "you@example.com"
buttonLabel: "Subscribe"
consentLead: "You are giving me your email so I can send you the newsletter. Unsubscribe anytime."
privacyLinkLabel: "How I use your email"
successMessage: "Almost there. Check your inbox and confirm the subscription."
errorMessage: "Something went wrong. Please try again, or email hello@quentinfears.com."
65 changes: 64 additions & 1 deletion keystatic.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export default config({
ui: {
brand: { name: 'Quentin Fears site content' },
navigation: {
Pages: ['home', 'work', 'ideas', 'speak', 'about', 'contact'],
Pages: ['home', 'work', 'ideas', 'speak', 'about', 'contact', 'privacy'],
Shared: ['settings', 'galleries'],
},
},
Expand Down Expand Up @@ -146,6 +146,48 @@ export default config({
tagline: fields.text({ label: 'Footer tagline' }),
copyrightName: fields.text({ label: 'Copyright name' }),
copyrightYear: fields.text({ label: 'Copyright year (fallback)' }),
newsletter: fields.object(
{
// The signup box posts straight to this Loops "custom form" endpoint
// (a plain HTML form, no third-party JavaScript). Get the URL from
// Loops: Forms, then your form, then the custom-form embed. It looks
// like https://app.loops.so/api/newsletter-form/<FORM_ID>.
formEndpoint: fields.url({
label: 'Loops form endpoint',
description:
'The Loops custom-form URL, e.g. https://app.loops.so/api/newsletter-form/<FORM_ID>. The signup box posts here.',
}),
placement: fields.select({
label: 'Where the signup box appears',
description:
'Footer = on every page above the footer. Ideas page = one block on the Ideas page only.',
options: [
{ label: 'Footer (every page)', value: 'footer' },
{ label: 'Ideas page only', value: 'ideas' },
],
defaultValue: 'footer',
}),
eyebrow: fields.text({ label: 'Eyebrow (small all-caps label)' }),
heading: fields.text({ label: 'Heading' }),
blurb: fields.text({ label: 'Blurb', multiline: true }),
emailLabel: fields.text({ label: 'Email field label' }),
emailPlaceholder: fields.text({ label: 'Email field placeholder' }),
buttonLabel: fields.text({ label: 'Button label' }),
consentLead: fields.text({
label: 'Consent line',
description:
'Shown next to the field. The privacy-note link is added after it automatically.',
multiline: true,
}),
privacyLinkLabel: fields.text({
label: 'Privacy-note link text',
description: 'The clickable words that link to the privacy note.',
}),
successMessage: fields.text({ label: 'Success message', multiline: true }),
errorMessage: fields.text({ label: 'Error message', multiline: true }),
},
{ label: 'Newsletter signup' }
),
},
}),

Expand Down Expand Up @@ -559,5 +601,26 @@ export default config({
}),
},
}),

privacy: singleton({
label: 'Privacy note',
path: 'content/privacy',
format: { data: 'yaml' },
schema: {
seo: seo(),
hero: eyebrowHeadingIntro(),
updated: fields.text({
label: 'Last updated',
description: 'Shown under the heading, e.g. July 2026.',
}),
sections: fields.array(
fields.object({
heading: fields.text({ label: 'Section heading' }),
body: fields.text({ label: 'Section body (HTML allowed)', multiline: true }),
}),
{ label: 'Sections', itemLabel: (p) => p.fields.heading.value }
),
},
}),
},
});
3 changes: 2 additions & 1 deletion lighthouserc.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"http://localhost:8299/ideas.html",
"http://localhost:8299/speak.html",
"http://localhost:8299/about.html",
"http://localhost:8299/contact.html"
"http://localhost:8299/contact.html",
"http://localhost:8299/privacy.html"
],
"numberOfRuns": 3,
"settings": {
Expand Down
32 changes: 32 additions & 0 deletions public/assets/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,38 @@ button { font: inherit; cursor: pointer; }
.form-status--ok { color: var(--accent); }
.form-status--err { color: #ff6a4d; }

/* ============================================================
Newsletter signup
============================================================ */
.newsletter { border-top: 1px solid var(--line); padding-block: clamp(2.75rem, 6vw, 4.25rem); }
.newsletter--section { background: var(--bg-2); }
.newsletter__inner { display: grid; gap: clamp(1.5rem, 4vw, 3rem); align-items: start; }
@media (min-width: 860px) {
.newsletter--footer .newsletter__inner { grid-template-columns: 1fr 1.1fr; align-items: center; }
.newsletter--section .newsletter__inner { grid-template-columns: 0.9fr 1.1fr; }
}
.newsletter__lead h2 { font-family: var(--serif); font-size: clamp(1.7rem, 3.4vw, 2.5rem); line-height: 1.05; letter-spacing: -0.01em; margin-bottom: 0.9rem; }
.newsletter__blurb { color: var(--ink-soft); max-width: 42ch; }
.newsletter__form { max-width: 34rem; }
.newsletter__row { display: flex; flex-wrap: wrap; align-items: end; gap: 0.9rem; }
.newsletter__field { flex: 1 1 16rem; margin-bottom: 0; }
.newsletter__btn { flex: 0 0 auto; white-space: nowrap; }
.newsletter__field input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.newsletter__consent { margin: 1rem 0 0; font-size: 0.82rem; line-height: 1.5; color: var(--ink-faint); }
.newsletter__consent a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
.newsletter__status { min-height: 0; }
.newsletter__status:empty { margin-top: 0; }

/* ============================================================
Privacy note
============================================================ */
.privacy-updated { color: var(--ink-faint); font-size: 0.9rem; margin-top: 1rem; }
.prose-narrow { max-width: 62ch; }
.privacy-block { margin-bottom: 2rem; }
.privacy-block h2 { font-family: var(--serif); font-size: clamp(1.3rem, 2.4vw, 1.7rem); margin-bottom: 0.6rem; }
.privacy-block p { color: var(--ink-soft); line-height: 1.7; }
.privacy-block a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }

/* ============================================================
Lightbox gallery
============================================================ */
Expand Down
38 changes: 38 additions & 0 deletions public/assets/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,44 @@
});
}

/* ---- Newsletter form: post to the Loops custom-form endpoint via AJAX,
inline status. No-JS visitors get a normal POST to the same endpoint.
Double opt-in is on in Loops, so a confirmation email is what "success"
promises here. ---- */
var nl = document.getElementById("newsletter-form");
if (nl) {
var nlStatus = nl.querySelector(".form-status");
var nlAction = nl.getAttribute("action") || "";
nl.addEventListener("submit", function (e) {
// Only enhance real Loops endpoints; anything else submits normally.
if (nlAction.indexOf("loops.so") === -1) return;
var honey = nl.querySelector('[name="_honey"]');
if (honey && honey.value) { e.preventDefault(); return; } // bot: drop silently
// Let the browser show its own message for an empty/invalid email.
if (typeof nl.checkValidity === "function" && !nl.checkValidity()) return;
e.preventDefault();
var btn = nl.querySelector('button[type="submit"]');
var label = btn ? btn.innerHTML : "";
var data = new FormData(nl);
var okMsg = nl.getAttribute("data-ok") || "Thanks, please check your inbox to confirm.";
var errMsg = nl.getAttribute("data-err") || "Something went wrong. Please try again.";
if (btn) { btn.disabled = true; btn.textContent = "Subscribing…"; }
if (nlStatus) { nlStatus.textContent = ""; nlStatus.className = "form-status newsletter__status"; }
fetch(nlAction, { method: "POST", body: data, headers: { Accept: "application/json" } })
.then(function (r) { return r.json().catch(function () { return { success: r.ok }; }); })
.then(function (res) {
if (res && (res.success === true || res.success === "true")) {
nl.reset();
if (nlStatus) { nlStatus.textContent = okMsg; nlStatus.className = "form-status newsletter__status form-status--ok"; }
} else { throw new Error((res && res.message) || "failed"); }
})
.catch(function () {
if (nlStatus) { nlStatus.textContent = errMsg; nlStatus.className = "form-status newsletter__status form-status--err"; }
})
.finally(function () { if (btn) { btn.disabled = false; btn.innerHTML = label; } });
});
}

/* ---- Reel: swap poster for the YouTube embed on click ---- */
var reels = document.querySelectorAll(".reel[data-yt]");
reels.forEach(function (r) {
Expand Down
Loading
Loading