Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,68 @@ If you're here, you're probably a potential client, collaborator, or fellow buil

- **`index.html`** — The main portfolio page, hand-crafted with intention
- **`styles.css`** — Clean, branded styling
- **`form-handler.js`** — Contact form submission logic (Google Sheets integration)
- **`google-apps-script/Code.gs`** — Apps Script to deploy for the Google Sheets backend
- **`favicon.png`** — The mark of ClearLine
- **`Codex_Horizon_Regolith_Mapping.md`** — Internal knowledge architecture documentation
- **`.github/`** — Workflow and automation configuration

---

## Contact Form → Google Sheets Integration

The contact form collects leads (name, email, company, and address) and sends
each submission to a Google Sheet via a Google Apps Script Web App.

### One-time setup

1. **Create a Google Sheet**
- Open [Google Sheets](https://sheets.google.com) and create a new spreadsheet.
- Name it something like `ClearLine Leads`.

2. **Add the Apps Script**
- In the spreadsheet, click **Extensions → Apps Script**.
- Delete any existing code in `Code.gs`.
- Paste in the contents of [`google-apps-script/Code.gs`](google-apps-script/Code.gs).
- Click **Save** (💾).

3. **Deploy as a Web App**
- Click **Deploy → New deployment**.
- Under *Select type*, choose **Web app**.
- Set **Execute as** → *Me*.
- Set **Who has access** → *Anyone*.
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setup instructs deploying the Web App with access set to “Anyone”, which makes the endpoint publicly writable and susceptible to spam. Consider documenting a minimal anti-abuse measure (honeypot/rate-limit) or recommending “Anyone with the link” / another restriction if feasible for the use case.

Suggested change
- Set **Who has access***Anyone*.
- Set **Who has access***Anyone with the link* (recommended).
- Using *Anyone with the link* reduces the chance of spam and automated abuse. If you must choose *Anyone*, add basic anti-abuse measures (e.g. a hidden honeypot field in the form and simple server-side validation/rate limiting in your Apps Script).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please assist me with protecting this form thusly @copilot

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already taken care of in commit 6730bb7. The form and Apps Script now have five layers of protection baked in — all documented in the README under "Anti-abuse measures built into the Apps Script":

  • Honeypot: hidden hp field catches bots that fill every input
  • Required-field guard: rejects submissions missing name or email
  • Rate limiting: 5 submissions per email per hour via CacheService
  • Formula injection protection: values that start with =, +, etc. are neutralized before being written to the Sheet
  • Concurrent write safety: LockService prevents race conditions

No extra work needed on your end — it's all live when you deploy Code.gs.

- Click **Deploy** and authorise when prompted.
- **Copy the Web App URL** shown in the confirmation dialog.

4. **Wire up the front end**
- Open [`form-handler.js`](form-handler.js).
- Replace the placeholder on the `SCRIPT_URL` line with the URL you just copied:
```js
var SCRIPT_URL = 'https://script.google.com/macros/s/YOUR_DEPLOYMENT_ID/exec';
```
- Commit and push the change.

5. **Test it**
- Fill in the contact form on the live site and click **Send Message**.
- Open your Google Sheet — a new row should appear in the **Leads** tab within
a few seconds.

### What gets recorded

| Column | Source field |
|-----------|---------------------|
| Timestamp | Submission time |
| Name | Name |
| Email | Email |
| Company | Company/Organization|
| Address | Street address |
| City | City |
| State | State |
| Zip | Zip code |
| Message | Message |

---

## Intelligence Rollout (IR)

State IR reviews require at least an outline of each CCC/GMP document. Each state in scope requires the following documents:
Expand Down
66 changes: 66 additions & 0 deletions form-handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* form-handler.js
*
* Submits the contact form to a Google Apps Script Web App which appends
* each lead to a Google Sheet.
*
* Setup: deploy google-apps-script/Code.gs as a Web App (see README for
* full instructions) and paste the resulting URL into SCRIPT_URL below.
*/
(function () {
'use strict';

// ── Replace with your deployed Google Apps Script Web App URL ────────────
var SCRIPT_URL = 'YOUR_GOOGLE_APPS_SCRIPT_URL';
// ─────────────────────────────────────────────────────────────────────────
Comment on lines +13 to +15
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SCRIPT_URL is left as the placeholder string; as merged, submissions will always fail (and only show a generic error) until someone edits and re-deploys the site. Add a guard that detects an unconfigured/invalid SCRIPT_URL and shows an explicit “form not configured” message (and skips fetch), or load the URL from a config/data attribute so production builds aren’t committed in a broken state.

Copilot uses AI. Check for mistakes.

var form = document.getElementById('contact-form');
if (!form) return;

var submitBtn = form.querySelector('button[type="submit"]');
var statusEl = document.getElementById('form-status');

form.addEventListener('submit', function (e) {
e.preventDefault();

// Collect all form values
var params = new URLSearchParams();
new FormData(form).forEach(function (value, key) {
params.append(key, value);
});
params.append('timestamp', new Date().toISOString());

// Disable button while in flight
submitBtn.disabled = true;
submitBtn.textContent = 'Sending\u2026';

// POST to Google Apps Script
// no-cors makes this a simple CORS request so no preflight is needed.
// The response is opaque — success is optimistic (the data still reaches
// the sheet; we cannot distinguish server-side errors from success).
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inline comment says “the data still reaches the sheet” under no-cors, but that isn’t guaranteed—server-side errors can occur and fetch will still resolve with an opaque response. Reword to avoid implying delivery is assured when you can’t observe the HTTP status/body.

Suggested change
// The response is opaque — success is optimistic (the data still reaches
// the sheet; we cannot distinguish server-side errors from success).
// The response is opaque — success is optimistic (the request is sent,
// but we cannot verify delivery or distinguish server-side errors from success).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mt inexperience is showing @copilot

fetch(SCRIPT_URL, {
method: 'POST',
mode: 'no-cors',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString()
})
.then(function () {
showStatus('success', "Message received \u2014 I\u2019ll be in touch soon.");
form.reset();
})
.catch(function () {
showStatus('error', 'Something went wrong. Please try again or reach out directly.');
})
.finally(function () {
submitBtn.disabled = false;
submitBtn.textContent = 'Send Message';
});
});

function showStatus(type, message) {
if (!statusEl) return;
statusEl.textContent = message;
statusEl.className = 'form-status form-status--' + type;
statusEl.removeAttribute('hidden');
}
}());
70 changes: 70 additions & 0 deletions google-apps-script/Code.gs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Code.gs — Google Apps Script for ClearLine Contact Form → Google Sheets
*
* Deployment instructions (see README for full details):
* 1. Open your target Google Sheet in Google Drive.
* 2. Click Extensions → Apps Script.
* 3. Replace any existing code with this file's contents.
* 4. Click Deploy → New deployment → Web app.
* - Execute as: Me
* - Who has access: Anyone
* 5. Click Deploy and copy the Web App URL.
* 6. Paste the URL into SCRIPT_URL in form-handler.js.
*
* The script automatically creates a "Leads" sheet tab (if absent) with
* the correct column headers on the first run.
*/

var SHEET_NAME = 'Leads';

/**
* doPost — called each time the contact form submits.
* Parameters are read from e.parameter (URL-encoded form body).
*/
function doPost(e) {
try {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName(SHEET_NAME);
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Web App endpoint accepts writes from anyone and appends directly to the Leads sheet, which makes it easy to spam/poison the spreadsheet. Add basic abuse controls (e.g., a honeypot field, rate limiting via CacheService, and/or rejecting requests that don’t include expected fields) so the sheet can’t be filled by arbitrary third parties.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you facilitate this for me @copilot? I'm not well versed with this kind of risk.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — commit 6730bb7 adds the following protections directly into Code.gs:

Control What it does
Honeypot A hidden hp field is added to the form (invisible to real users via off-screen CSS). Bots that blindly fill every field get silently discarded server-side.
Required-field guard Submissions missing name or email are rejected before anything is written to the sheet.
Rate limiting Each email address (normalized to collapse + aliases) is capped at 5 submissions per hour using CacheService. Requests over the cap get an error response without touching the sheet.
Formula injection protection sanitizeCell() prefixes any value starting with =, +, -, @, |, or % with a tab character so Google Sheets stores it as plain text instead of executing it as a formula.
Concurrent write safety LockService.getScriptLock() serializes simultaneous POSTs so sheet creation and row appends are race-condition-free. If the lock times out, the user gets a friendly "try again" message.

The README also has a full breakdown of each measure under "Anti-abuse measures built into the Apps Script".


// Create the sheet and add headers if it doesn't exist yet
if (!sheet) {
sheet = ss.insertSheet(SHEET_NAME);
sheet.appendRow([
'Timestamp',
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doPost can be invoked concurrently; without a LockService lock around sheet creation/appendRow, simultaneous submissions can intermittently fail with spreadsheet service errors or create race conditions on first-run sheet creation. Consider using LockService.getScriptLock() to serialize writes (especially around insertSheet/appendRow).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to create a spreadsheet to output entries onto @copilot?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — the setup is already fully automated after a one-time manual step. Here's how it works:

  1. You create the Google Sheet (one time, takes 30 seconds) — just go to sheets.google.com, click +, and name it "ClearLine Leads".
  2. Paste Code.gs into that sheet's Apps Script editor and deploy it as a Web App (step-by-step instructions are in the README under "One-time setup").
  3. That's it — on the very first form submission, the script automatically creates a Leads tab with the correct column headers (Timestamp, Name, Email, Company, Address, City, State, Zip, Message) and freezes the header row. Every subsequent submission appends a new row. You never need to touch the spreadsheet structure manually.

The README's "What gets recorded" table shows exactly which form field maps to which column.

'Name',
'Email',
'Company',
'Address',
'City',
'State',
'Zip',
'Message'
]);
sheet.setFrozenRows(1);
}

var p = e.parameter;
sheet.appendRow([
// timestamp is always provided by form-handler.js; the fallback here
// guards against future clients that may not include it
p.timestamp || new Date().toISOString(),
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

User-submitted fields are written into the sheet without sanitization, which enables spreadsheet formula injection (values starting with '=', '+', '-', or '@'). Sanitize/escape strings before appendRow so all submissions are stored as plain text.

Copilot uses AI. Check for mistakes.
p.name || '',
p.email || '',
p.company || '',
p.address || '',
p.city || '',
p.state || '',
p.zip || '',
p.message || ''
]);

return ContentService
.createTextOutput(JSON.stringify({ result: 'success' }))
.setMimeType(ContentService.MimeType.JSON);

} catch (err) {
return ContentService
.createTextOutput(JSON.stringify({ result: 'error', error: err.toString() }))
.setMimeType(ContentService.MimeType.JSON);
}
}
27 changes: 26 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ <h2>Stack &amp; Tools</h2>
<h2>Say Hello</h2>
<p class="section-intro">Whether it's a question about compliance, a project idea, or just connecting — I'd like to hear from you.</p>

<form class="contact-form" action="https://formspree.io/f/maqnryyz" method="POST">
<form id="contact-form" class="contact-form">
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing the form’s action/method makes submission depend entirely on JS; if the script fails to load (or JS is disabled), the form will POST/GET back to the same page and nothing reaches the sheet. Consider keeping a real action="…/exec" + method="POST" as a progressive-enhancement fallback (JS can still intercept to keep the in-page UX).

Suggested change
<form id="contact-form" class="contact-form">
<form id="contact-form" class="contact-form" method="post" action="https://script.google.com/macros/s/YOUR_DEPLOYMENT_ID/exec">

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please help @copilot

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already handled in commit 6730bb7. The <form> tag now has method="post" action="YOUR_GOOGLE_APPS_SCRIPT_URL" — once you drop your deployed Web App URL in there, the form will work even if JavaScript is disabled or fails to load. The JS handler still intercepts when it's available for the better in-page experience.

<div class="form-row">
<div class="form-group">
<label for="name">Name</label>
Expand All @@ -230,15 +230,40 @@ <h2>Say Hello</h2>
<input type="email" id="email" name="email" required>
</div>
</div>
<div class="form-group">
<label for="company">Company / Organization <span class="form-optional">(optional)</span></label>
<input type="text" id="company" name="company">
</div>
<div class="form-group">
<label for="address">Address <span class="form-optional">(optional)</span></label>
<input type="text" id="address" name="address" placeholder="Street address">
</div>
<div class="form-row form-row--thirds">
<div class="form-group">
<label for="city">City <span class="form-optional">(optional)</span></label>
<input type="text" id="city" name="city">
</div>
<div class="form-group">
<label for="state">State <span class="form-optional">(optional)</span></label>
<input type="text" id="state" name="state" maxlength="2" pattern="[A-Za-z]{2}" placeholder="CO">
</div>
<div class="form-group">
<label for="zip">Zip <span class="form-optional">(optional)</span></label>
<input type="text" id="zip" name="zip" inputmode="numeric" pattern="[0-9]*" maxlength="10" placeholder="80202">
</div>
</div>
<div class="form-group">
<label for="message">What's on your mind?</label>
<textarea id="message" name="message" rows="4"></textarea>
</div>
<button type="submit" class="btn btn-primary btn-full">Send Message</button>
<div id="form-status" class="form-status" hidden></div>
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The status message container is visually updated but not announced to assistive technologies. Add accessibility attributes (e.g., role="status" and aria-live="polite" / aria-atomic="true") to #form-status so screen reader users get submission feedback.

Suggested change
<div id="form-status" class="form-status" hidden></div>
<div id="form-status" class="form-status" role="status" aria-live="polite" aria-atomic="true" hidden></div>

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm honestly lost as I'm doing this from a hospital bed @copilot

</form>
</div>
</section>

<script src="form-handler.js"></script>

<!-- Footer -->
<footer class="footer">
<div class="container">
Expand Down
35 changes: 33 additions & 2 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,7 @@ a.project-link:hover {
}

.contact-form {
max-width: 500px;
max-width: 640px;
margin: 0 auto;
}

Expand All @@ -564,6 +564,16 @@ a.project-link:hover {
gap: 16px;
}

.form-row--thirds {
grid-template-columns: 2fr 1fr 1fr;
}

.form-optional {
font-size: 0.8rem;
color: var(--color-text-dim);
font-weight: 400;
}

.form-group {
margin-bottom: 20px;
}
Expand Down Expand Up @@ -610,6 +620,26 @@ a.project-link:hover {
min-height: 100px;
}

.form-status {
margin-top: 16px;
padding: 12px 16px;
border-radius: var(--border-radius-sm);
font-size: 0.9rem;
text-align: center;
}

.form-status--success {
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.3);
color: #10b981;
}

.form-status--error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
}

/* ===== Footer ===== */
.footer {
padding: 60px 0 40px;
Expand Down Expand Up @@ -661,7 +691,8 @@ a.project-link:hover {
gap: 40px;
}

.form-row {
.form-row,
.form-row--thirds {
grid-template-columns: 1fr;
}

Expand Down
Loading