Skip to content

Latest commit

 

History

138 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Grand Website

Static landing-page wireframe for Grand.

Status

  • Replaced the iPhone product-card house crops with the dedicated grand-hub.jpg and grand-sensor.jpg photos, with each device centered in its image frame. Desktop/tablet keep the full house scene.

  • Disabled menu, hero, and message-thread entrance animations at phone widths (700px and below); all content is visible immediately. Tablet and desktop retain their animations.

  • Limited the “Scroll to learn more” hint to desktop widths above 1100px; hidden on tablet and mobile.

  • Fade the “Scroll to learn more” cue out over 400ms after 48px of scrolling, and fade it back in on returning to the top. The initial entrance delay only runs on load; reduced-motion users get immediate visibility changes.

  • Darkened the alternatives labels to the primary ink color and descriptions to dark charcoal for clearer contrast.

  • Top-aligned the worry section’s text and message thread on desktop and tablet.

  • Added a staggered hero-text entrance: headline, supporting copy, and actions fade upward in sequence, with 400ms between starts. Scrolling or focusing the hero controls reveals the text immediately; reduced-motion users see it without animation.

  • Added a small “Scroll to learn more” link and downward arrow beneath the hero, using existing whitespace to preserve its centering. Its fade-in matches the menu’s two-second pause and 1.6-second entrance. The arrow then gently moves three times, respects reduced motion, and links to the worry section.

  • Added a two-second pause followed by a 1.6-second menu fade and 16px upward slide from the hero on initial homepage load. Scrolling or keyboard focus reveals the menu immediately and cancels the entrance; reduced-motion users see it immediately. Only the menu content animates; the translucent header background stays stationary and page layout stays fixed.

  • Slightly increased section spacing: standard desktop/tablet padding from 160px to 176px and mobile from 80px to 88px, with matching increases to the tighter alternatives and mobile section overrides. Hero centering is preserved.

  • Increased the gap above Mom’s reply so its Tapback clears the preceding message, while retaining the compact Tuesday message stack.

  • Reused the exact pink-heart Tapback artwork from the original assets/imessage-screen.png in the worry thread, using an SVG clipping window to retain its native heart, shading, reaction bubble, and trailing dots. Removed the substitute emoji and hand-drawn red heart.

  • Added tablet connector lines from the full house image into the hub/sensor caption band, ending below the devices. The tablet “What Grand learns” introduction now spans the full content width above the routine list.

  • Split the mobile “How Grand works” scene into two stacked versions of the original rounded photo-and-dark-caption layout: one zoomed house-image crop for the hub and one for the kitchen sensor. Desktop/tablet retain the full scene and existing captions.

  • Animated the worry thread from Tuesday onward: the day label, three unanswered messages, and Delivered status appear in sequence once the conversation enters view. Monday stays visible, space is reserved to avoid layout shifts, and reduced-motion/no-JavaScript visitors see the full thread immediately.

  • Removed sticky positioning from the “What Grand learns” introduction so it scrolls normally alongside the routine list.

  • Grouped Tuesday’s unanswered iMessage bubbles into a compact stack with 6px gaps and a single Delivered label beneath the final message; removed the intervening timestamp/status rows.

  • Removed the caregiver experience section’s tinted background so it uses the page’s white background.

  • Desktop navigation is a distinct translucent white menu bar with a subtle backdrop blur directly above the hero. The hero image itself is vertically centered on load, with the menu above it excluded from the centering calculation, and the menu sticks to the viewport top when scrolling reaches it, with a compact, darker shadow while pinned (1px offset, 2px blur, 12% opacity). Tablet/mobile and secondary-page navigation are unchanged.

  • Vertically centered the desktop hero within the initial viewport below the header, with equal top/bottom space and no automatic scrolling. Tablet and mobile layouts retain their existing behavior.

  • Updated full-page homepage screenshots in docs/screenshots/homepage/: desktop at 1440px, tablet at 768px, and mobile at 390px, documenting the final design and tablet refinements for PR #74.

  • Refined the tablet layout (701–1000px): caregiver app screenshots remain three across; the emergency section places its introduction above a paired layout of response steps and a compact photo, instead of letting the photo expand beneath the section.

  • Removed the mint background from “The alternatives”; retained the open, square-edged layout, spacing, and shared content alignment.

  • Changed “The alternatives” to a full-width mint band with square edges, retaining the shared content alignment and responsive comparison layout. This supersedes the restored rounded panel below.

  • Restored the v3 rounded, tinted alternatives panel after reverting the experimental ruled layout.

  • Moved the product-photo connector dots and line endpoints just below the hub and both sensors so the annotations no longer cover the devices.

  • Expanded the “How Grand works” title and introduction to the full content width, removing the 720px heading-group cap.

  • Implemented the September 21 v3 homepage handoff on codex/website-updates-20260918: wider hero and alternatives panels, HTML message thread, updated headings and spacing, and a combined “How Grand works” section with responsive hub/sensor captions. Both #system and #attention anchors remain available. Preserved production metadata, analytics, waitlist scripts, and responsive image variants.

  • Integrated the v3 product scene as assets/grand-hub-and-sensors-home-v3.webp (163 KB, down from a 2.1 MB PNG), with lazy loading and device-aligned leader lines. Captions move under the photo at 1000px and stack at 700px. Verified layout at 390–2560px and all 20 existing tests pass. Safari has not been separately tested.

  • Added assets/grand-hub-and-sensors-home-v3.png: revised product lifestyle image places the hub in the living room and satellites in a separate bedroom and kitchen, visible through distinct doorways. This image is now used by the homepage via an optimized WebP; earlier versions remain for comparison.

  • Added assets/grand-hub-and-sensors-home-v2.png, a cleanup pass on the home product scene targeting texture artifacts, product lettering, and edges while retaining the composition. Original retained for comparison; neither image is wired into the website.

  • Generated assets/grand-hub-and-sensors-home.png: a natural living-room scene with the Grand hub and two plug-in wall sensors, using the existing product photos as design references. Available as a new asset; not yet placed on the website.

  • Swapped the worry section’s desktop columns to place its copy on the left and the phone image on the right; mobile continues to show copy before the phone.

  • Removed the tagline beside the Grand wordmark from the shared menu header across the homepage, welcome, privacy, and terms pages.

  • Unified green accents and CTA backgrounds on the footer’s original dark green (#1e2a24), with a shared darker button hover color.

  • Matched the “When something’s wrong” steps to the numbered routine list: shared serif headings, green two-digit numerals, dividers, and spacing. Mobile retains the response photo before the steps.

  • Restyled the “How it works” routine section as four numbered rows with green serif numerals, serif headings, and fine dividers. Desktop pairs the introduction with the list; tablet and mobile stack them. Current descriptions and caregiver-circle emergency wording are preserved.

  • Aligned the shared menu and hero with the sections’ 1200px container and responsive gutters. The desktop hero uses a rounded frame, a cropped photo fading into a muted background, and inset copy. Mobile retains the stacked photo and copy with a rounded photo. Section divider lines are removed; list dividers remain.

  • Matched the mobile hero's green to the header CTA: the "she's okay." accent and the "See how Grand works" button now use --band (#1e2a24) instead of --green (#1f5a3f). Why: on the stacked mobile hero they sit directly below the dark "Become a tester" pill, and the two greens read as a mismatch rather than one brand color. Desktop is unchanged (the hero copy there is white over the photo). Section eyebrows (.eyebrow) were darkened for the same reason and then put back to --green: both --band and --green-dark stop reading as green at 13px uppercase, and the eyebrow needs to carry color more than it needs to match the dark bands. --green still carries eyebrows, links, input focus, and the .tile--urgent accents.

  • Restored the approved homepage copy into the editorial redesign. The redesign (PR #69) shipped a new visual design and rewrote nearly every line of copy on the homepage; the whole PR was reverted (#70) to protect the words, then reapplied here with the original copy put back. Section eyebrows, the hub/sensor and routine-tile kickers, the three-item caregiver list, and the #response intro paragraph all returned; the redesign's 0104 numerals gave way to the original text labels. Why: the homepage copy is approved, research-backed writing, and the design brief never covered it. Design work must not rewrite index.html copy — treat the words as fixed input and fit the layout around them. Three invented claims were removed in the process: "No audio is ever sent to the cloud", "a real person from Grand reaches out to confirm she's okay" (see the #response note below — there is no human in the loop), and "We never share your number/email". The <noscript> fallback and the 45-second signup timeout message were kept, since they accompany a real bug fix rather than a positioning change.

  • Restyled welcome.html with the shared homepage header, footer, Newsreader/Figtree typography, green form controls, and welcome.css. Mobile puts the questionnaire ahead of the supporting image. Both contact variants, validation feedback, and scheduling/waitlist completion states retain the existing behavior.

  • Fixed signup errors caused by the 10-second client timeout: a live QA retry confirmed the backend can take about 20 seconds and that the original signup had already saved. Signup and profile requests now wait up to 45 seconds for a confirmed JSON response; a timeout explains that confirmation is uncertain and signup retry preserves the submission ID to avoid duplicates.

  • Updated Privacy and Terms to share the new homepage header, footer, fonts, and colors, with a dedicated readable legal layout in legal.css. Legal wording and effective dates are unchanged; mobile spacing, keyboard skip links, and long-link wrapping are included.

  • Applied the supplied September mobile layout: photo-first hero, full-width primary CTA, copy-first worry section, compact typography and section spacing, horizontally scrollable app screenshots, reordered emergency section, and a two-column footer. The mobile header is now static; anchor scrolling only reserves header space when the header is sticky or fixed. This supersedes the earlier narrow-screen hero adjustment below.

  • Refined the narrow-screen hero with a fluid, balanced headline, a dedicated line for “she's okay.”, smaller supporting copy, and consistently stacked actions. Desktop typography stays unchanged.

  • Rebuilt the homepage from the September 2026 editorial white design handoff: Newsreader/Figtree typography, green CTAs, product photos, framed app screenshots, routine explanations, and dark signup/footer band.

  • Homepage styling now lives in homepage.css; welcome and legal pages share homepage.css with their respective welcome.css and legal.css layouts. The live Google Apps Script signup, phone/email experiment, attribution, analytics, and welcome.html handoff remain connected. Original section anchors are retained for existing links and reporting.

  • Added the handoff's cropped iMessage image, removed its fictional photo caption and mock success handler, and added compact mobile navigation, keyboard focus, reduced-motion styling, and accessible signup feedback.

  • Added a first-pass one-page wireframe for the public landing page.

  • Positioning is companion-first for the older person, with family reassurance as the buyer story.

  • The wireframe uses the existing Grand product language: warm surfaces, sage/clay accents, editorial type moments, privacy by default, and no surveillance framing.

  • Added an optimized hero image from the selected Pexels option: Moe Magners photo pexels-moe-magners-5335290.jpg, resized to 2400px wide at assets/hero-pexels-moe-magners-5335290.jpg.

  • Deployed the static page with GitHub Pages from the gh-pages branch under rainforestapp/grand-website; the Deploy GitHub Pages workflow publishes main to gh-pages automatically when main changes.

  • Configured GitHub Pages for the canonical custom domain www.grandeldercare.com, with DNSimple records pointing www to rainforestapp.github.io and the apex domain to GitHub Pages A/AAAA records. GitHub is issuing a certificate for both www.grandeldercare.com and grandeldercare.com.

  • Refined the landing-page copy after review feedback, including more specific music examples, clearer reminder language, and replacing the abstract product preview with concrete Grace app ideas.

  • Reframed the landing page around the broader Grand system: Grace is the smart speaker companion, and optional Grand Satellites add home tracking.

  • Added a "How Grand works" section that explains Grace as the smart speaker and Grand Satellites as the optional home-tracking add-on.

  • Added a Grand satellites/home-tracking section focused on daily rhythm, routine changes, and filtered emergency-like alerts without cameras or live feeds.

  • Split use cases into "Ask Grace" and "Grand notices" so companion moments and home-tracking moments can sit together without blurring their roles.

  • Replaced the abstract satellite floor-plan with resized real Grand iOS app screenshots from ../grand-ios/docs/screenshots: ios-home.png and pr1-settings-index.png.

  • Simplified the hero headline and strapline so first-time readers understand the promise without needing prior context for Grand, Grace, or satellites.

  • Rewrote the hero to lead with reassurance instead of the two-part independence/worry construction. Why: "You'll know she's okay." states the outcome the buyer actually wants in one breath, and the shorter strapline ("A few discreet sensors to flag when something's off — no cameras, no wearables, nothing to charge.") drops the "small hub" mechanism detail that the "How Grand works" section already covers. Also swapped the hero CTAs so "See how Grand works" is now primary (filled) and reordered ahead of the secondary "Be first to know" outline button, because an unconvinced first-time visitor needs to understand the product before committing to the waitlist. Removed the "Care at home" hero eyebrow so nothing competes with the headline, added text-wrap: balance on #hero-title so the two-line headline doesn't orphan "okay." on its own line, and raised the hero copy's bottom padding (clamp(42px, 9vw, 140px)) so the shorter block sits optically centered on tall desktop viewports instead of pinned to the bottom edge.

  • Wired the waitlist form for a Google Sheets backend via Google Apps Script, including client-side email validation, status messaging, and browser/user-agent metadata capture.

  • Repositioned the entire page from companion-first to peace-of-mind / dignity-first, aimed squarely at the adult child (the buyer). This supersedes the companion-first framing above. Why: the product's MVP is a quiet, passive sensor system (a hub + sensors) that lets an adult child know their parent is okay without cameras, wearables, or anything that announces "you're old" — not a voice companion. The "Grace" companion concept and all companion features (music, reading aloud, trivia, conversation, family-message dictation) were removed because they describe a different product and were not in the MVP spec.

    • "Grand satellites" renamed to "Grand sensors"; the device story is now simply "the Grand hub + Grand sensors."
    • Hero now leads with the fear→relief promise. (The wording was later tightened to "You'll know she's okay." — see the hero rewrite below.)
    • Added an upfront "The worry" problem section after the hero that sets up the fear and contrasts pendants/watches, call-for-help buttons, and cameras before presenting the solution. (This replaced a later "Why not a wearable, button, or camera?" comparison block, which said the same thing twice once the problem was framed up front.)
    • Trust close ties privacy directly to dignity and drops conversation-era rules; no pricing and no public mention of human-in-the-loop alert review, per product decision. (The human-in-the-loop decision was later reversed — see the Grand Call Center section below.)
    • Reused the existing iOS screenshots (assets/grand-ios-home.png, assets/grand-ios-settings.png) — they already show the passive-sensing view with no "Grace" UI.
  • Added a "Grand Call Center" section (#response) between the caregiver experience and the waitlist. Why: this reverses the earlier "no public mention of human-in-the-loop alert review" decision — the call center is now a headline differentiator, because "what actually happens in an emergency?" is the buyer's biggest pre-purchase question and the human agent + EMS escalation is the answer. The section presents the emergency flow as a four-step process (a real person calls through the hub/sensors → confirms she's safe → calls EMS if not → the family is notified throughout and can join the call), with a call-center agent photo (assets/call-center-pexels-kampus-8204317.jpg, Pexels / Kampus Production, cropped 3:4 around the agent and optimized to ~250KB). The caregiver-experience intro and the urgent signal tile were reworded to hand off to this section instead of implying an automated-only reach-out, and an "Emergency response" nav link was added.

  • Reformatted "The worry" section from a three-card competitor teardown into a narrative "anxiety window" timeline plus a compact "you've probably already thought about…" strikethrough list. Why: user research (May–June interviews) showed the problem is emotional, not comparative — the single most vivid finding was the adult child who worries from the moment she wakes until it's socially acceptable to call at 8am, and the failed alternatives land as stories (the pendant on the nightstand during the fall, the button that's "a reminder you're old") rather than spec-sheet dismissals. The old equal-cards format asked visitors to evaluate product categories before feeling understood, and gave competitor categories the same visual weight as the worry itself. The timeline dramatizes a familiar morning (clay dots for the anxious beats, a sage dot for the relief beat), a payoff line bridges into "How Grand works," and the three alternatives survive as demoted one-line dismissals so the content wasn't lost.

  • Added baseline SEO and indexing plumbing: canonical URL, Open Graph and Twitter card tags, JSON-LD structured data (Organization + WebSite), robots.txt, sitemap.xml, and a favicon set. Why: the site previously had none of this, so Google had little to work with and link previews in iMessage/Slack/social showed no image or branding. This is intentionally the crawlability baseline, not an optimization pass. See the "SEO & Indexing" section below.

  • Enriched waitlist signups to qualify fit against the ICP without adding friction to the email capture. Why: the sheet previously held only an email, giving no way to tell whether a signup matches the target buyer (an anxious adult child of a single senior who lives alone) or where to launch first. Three changes:

    • Passive location at signup. script.js fires a best-effort client-side IP geolocation lookup (https://ipapi.co/json/) on page load and attaches geo (city/region/country/postal) to the signup payload. It is time-boxed to ~1.2s and can never block or fail a signup — if it's slow or blocked, the row just has no location. This is on top of the coarse timezone already captured.
    • Post-signup profile page (welcome.html). The email stays the only required field; on success the homepage stores the email in sessionStorage (not the URL, to avoid leaking it into referrer/pixel traffic) and redirects to welcome.html, which asks five optional questions — full name, ZIP code, why they're interested, whether the person Grand is for lives alone, and alpha-tester interest. This is progressive profiling: motivated signers answer, hesitant ones still convert. The page is noindex.
    • Real conversion events. Both pixels previously fired only PageView/PageVisit, so ad platforms couldn't see signups. script.js now fires fbq('track','Lead') + rdt('track','SignUp') on signup, and fbq('track','CompleteRegistration') + rdt('track','Lead') on profile completion.
  • Added Privacy Policy (privacy.html) and Terms of Service (terms.html) pages and linked both from the footer of index.html, welcome.html, and each other. Why: the site runs Meta and Reddit ad pixels and collects waitlist emails plus optional profile data, so it needs a published privacy policy and terms — and the ad platforms require a linked privacy policy for pixel/conversion use. Both pages are boilerplate written from what we know about Grand (pre-launch home-sensor product, waitlist only, US-based call center is not yet live), using a new shared .legal-* layout in styles.css (readable 760px prose column, serif section headings, clay links) that matches the site's warm surfaces and editorial type. The privacy policy explicitly discloses that a waitlist sign-up is recorded as a conversion event and shared back to advertising partners (Meta, Reddit) so campaigns can be measured — the specific fact the team wanted stated. The legal pages deliberately omit the Meta/Reddit pixel snippets (no need to fire ad tracking on the privacy/terms pages themselves) and are indexable (added to sitemap.xml). One placeholder to confirm with counsel: governing law is set to Delaware. All contact addresses site-wide use hello@grandeldercare.com — a mailto: typo (grandelderare.com, missing a "c") in the index.html and welcome.html footers was corrected as part of this change.

  • Rebuilt the site footer: a top row with a "Contact us" label + hello@grandeldercare.com mailto: link on the left and the four nav links as a single right-aligned column, above a bottom bar with the grand. wordmark logo (assets/grand-logo.png) on the bottom-left and the copyright on the bottom-right (no divider — the footer reads as one section). Why: earlier footer iterations laid the nav links out as a run-on horizontal line, then as labelled columns that still read as cluttered. This is the simplified layout the team asked for. The "Grand is not a replacement for 911 or professional medical care" disclaimer was dropped per request, and the earlier "Quiet home monitoring…" tagline was removed from the footer (it wasn't requested). The logo is the brand wordmark trimmed and made transparent (cream background removed) so it blends on the footer surface.

  • Added "Grace" product-experiment subpages at /gracecompanion and /gracephone as lean, de-branded scaffolds, with /grace now available as a clone of /gracecompanion. Why: we want to test separate product lines under the Grand umbrella without touching the main Grand page or linking the sites together. See the "Grace Product Subpages" section below. The main Grand page (index.html) and all its supporting files were left completely untouched.

  • Consolidated the Grace experiment to a single /grace page. Deleted the gracecompanion/ and gracephone/ folders, leaving /grace as the only Grace site. Why: we no longer need three parallel product-line experiments — one Grace page is enough to maintain and iterate on. This supersedes the "three independent sites" model above. On the next deploy the rsync --delete step drops the live /gracecompanion and /gracephone URLs automatically; nothing else referenced them (no cross-nav, not in sitemap.xml), and the root Grand site is untouched. /grace keeps its existing product = "gracecompanion" waitlist tag so signups continue flowing to the GraceCompanion sheet tabs (data continuity; the label is invisible to users).

  • Standardized the /grace "What Grace does" section to list format. Converted the two flowing-paragraph blocks ("Grace keeps her company", "She brings people closer") to the same .does-list bold-lead-in style as the middle block, and removed the leftover [TK - confirm in v1] marker. Why: the section mixed prose and list formatting; the founder found the scannable bold-lead-in list easier to read, so all three blocks now match. HTML-only change — .does-list was already defined in grace/styles.css.

  • Switched the Grand alpha signup from email to phone number, on its own sheet tabs. The "Become a tester" field on index.html now collects a phone number (type="tel", 10–15 digit validation) instead of an email, and the whole Grand site is tagged product = "grandphone" so signups, profile answers, and analytics events route to two new tabs — Grand phone number alpha list and Grand phone number events — via the existing PRODUCT_SHEETS mechanism. Why: the team wants to reach alpha testers by phone for onboarding calls, and a clean break onto new tabs keeps phone numbers out of the historical email column and freezes the email-era Waitlist/Events tabs as an archive. waitlist.gs gained a phone column (appended last, so existing sheets auto-migrate with a blank column) and now identifies people by phone (digits-only match) for phone products, keeping the email contract for everything else. Deploy dependency: the Apps Script must be redeployed (Deploy → Manage deployments → New version) for phone signups to be accepted — the currently deployed version validates email server-side and would reject them. Ship the frontend and the redeploy together.

  • Reworked the post-submit screen on welcome.html into an alpha-onboarding CTA. Moved the alpha-tester Calendly link out of the profile form onto the confirmation screen, then dropped the "Thank you."/"we'll be in touch" copy in favor of a single focused ask: heading "Fast track getting set up as a test user of Grand", a short explainer, and a primary "Schedule a 20-minute call with us" button (Calendly). Removed the "Back to home" button. Why: once someone finishes the profile, booking the onboarding call is the only next step worth surfacing — a confirmation-and-dead-end screen wasted the highest-intent moment.

  • Fixed phone numbers landing in the sheet as #ERROR!. Phone values start with + (e.g. +1 (781) 492-4290), and Google Sheets parses any cell beginning with +, =, -, or @ as a formula, so the phone column evaluated to #ERROR!. waitlist.gs already prefixed the value with a text-forcing apostrophe (plainTextPhone_); this adds a second guard, ensurePhoneColumnIsText_, which sets the phone column's number format to plain text (@) before every write so a raw value can never be parsed as a formula. Why belt-and-suspenders: the format guard is independent of the apostrophe and protects any future write path. Deploy dependency: this only takes effect once the Apps Script is redeployed (Deploy → Manage deployments → New version) — the error rows in the live sheet were written by the pre-fix deployment. Existing #ERROR! cells must be corrected by hand; the original number is still recoverable from that row's raw_payload JSON ("phone":"…").

  • Gated the "Schedule 20-minute call" link behind a fit qualifier on welcome.html. The profile form gained two questions — the caregiver's phone type (iPhone / Android / Something else) and "Does your loved one have any pets?" (Yes / No) — and, together with the existing "does the person live alone?" question, these three now qualify the visitor. On submit, only a caregiver who uses an iPhone, whose loved one lives alone, and has no pets sees the Calendly scheduling panel; everyone else lands on a new Grace-style "You're on the list." panel (no call link). Every submission's answers are still saved either way, and script.js tags the waitlist_profile_submit_success / profile_completed analytics events with qualified so the split is visible. Why: too many low-fit people were booking onboarding calls — the alpha needs iPhone users (the companion app is iOS-only), single seniors living alone (the core ICP), and pet-free homes (pets confound the passive motion sensing), so the call slot should be reserved for candidates who clear all three. Before/After: Before — every profile submitter saw "Schedule 20-minute call". After — only all-three qualifiers see it; the rest get a graceful waitlist dead-end. waitlist.gs gained phone_type and has_pets columns (appended last, so existing sheets auto-migrate with blank columns). Deploy dependency: redeploy the Apps Script (Deploy → Manage deployments → New version) so the two new profile answers are written to the sheet — the site otherwise works, the columns just stay blank until redeploy.

    • Made every profile field except "Why are you interested?" required, and moved that free-text question to the end. Full name, email, ZIP, phone type, lives-alone, and pets must all be answered before the form submits (blank → "Please complete all required fields." and focus jumps to the first empty field); email also gets a format check ("Please enter a valid email address."). The open-ended "Why are you interested in Grand?" is now the last field and the only optional one. Each required label/legend carries a clay * (with a "* Required" note at the top of the form); the optional field keeps its "Optional" hint. Why: the qualifier and follow-up fields are the ones we act on, so we want them complete; the essay question is nice-to-have and belongs after the quick taps so it never blocks a motivated signup.
    • Pets lists "No" first, with no option pre-selected. Why: most target households are pet-free, so No leads; leaving it unselected keeps pets a genuine required choice (a pre-checked default would auto-pass the qualifier) — consistent with the other required questions.
    • Tightened the welcome.html layout so the now-longer form sits higher on the page. Cut the .profile-section top/bottom padding (clamp(56px,8vw,104px)clamp(32px,4vw,56px)), split the .profile-layout grid gap into a smaller row-gap (clamp(16px,2vw,24px)) so the heading no longer floats far above the content, and trimmed the .profile-main gap to 16px. Also moved the "* Required" note out of the form and dropped its margin-bottom — inside the form it added its own margin on top of the 24px field gap, leaving a ~44px hole above the first field. Now the description, "* Required", and the first field sit at an even 16px. Why: adding the required questions made the form tall enough to risk pushing the first fields below the fold; pulling the whole block up and closing the intro gap keeps the top of the form visible on a typical laptop viewport without cramping it.
  • Replaced the "Grand call center" content in the #response section with a caregiver-decides emergency flow. The section keeps its exact layout (photo-left/copy-right two-column grid, eyebrow → <h2> → intro → auto-numbered .response-steps), but the copy no longer describes a U.S.-based human agent who calls the parent and escalates to EMS. New eyebrow "When something's wrong", headline "You decide what happens next.", and three steps: (1) Grand pings your caregiver circle on a fall or routine change, (2) you talk to her directly through the hub and sensors, (3) you decide if emergency services are needed — if she needs help, Grand surfaces the right local emergency number for her area. This dropped the step count from four to three; the CSS counter renumbers automatically. Why: the emergency model changed — the family, not a Grand call center, is in the loop and makes the call, so the copy now puts the caregiver in control and reflects that Grand alerts the circle rather than dialing EMS itself. This supersedes the earlier "Grand Call Center" / human-in-the-loop framing described above. Before/After: Before — "The Grand call center · If the worst does happen, we're there with her.", four steps ending in a Grand agent calling EMS. After — "When something's wrong · You decide what happens next.", three steps ending in the caregiver placing the call with Grand's help. The #response id and "Emergency response" nav label are unchanged.

    • Swapped the section photo from the call-center agents (call-center-pexels-kampus-8204317*.jpg, now deleted) to a caregiver at home checking her phone — assets/caregiver-phone-pexels-kaboompics-6135183.jpg (Pexels / Kaboompics, photo #6135183). Why: the old image showed a call center, which the new copy no longer describes; a caregiver looking at her phone matches the "Grand pings your circle / you decide" flow. Same responsive setup as before: desktop 3:4 crop (1200×1600, ~146KB) framed on the subject, with <picture> mobile sources at ≤560px — a tighter 4:3 crop at -mobile.jpg (340w) and -mobile-2x.jpg (680w). The alt text was updated to match.
  • Refreshed the homepage share preview (thumbnail + description). The card previously used a tight portrait crop of a stock headshot and a description built around the old “no cameras, nothing to charge” framing. Why: the crop showed only a face — nothing about the product — so a shared link communicated “some elder-care thing” and nothing more. The new image is a wider shot of the same woman in her living room with a Grand sensor plugged into the wall outlet behind her, so the preview shows the product in situ, and the copy now leads with detection (“both acute events like falls and subtle changes in her routine”) rather than only what Grand isn't.

    • Published at a new filename (og-image-v2.jpg) instead of overwriting og-image.jpg. Why: Facebook, LinkedIn, Slack, and iMessage cache share images by URL, often for weeks. Reusing the old path would have kept serving the old thumbnail to every platform that had already scraped the site; a new URL forces a fresh fetch. It also leaves the Grace subpage's existing card untouched, since it still points at the old file.
    • Then aligned <title> and <meta name="description"> to the same copy. Why: leaving them on the old wording meant the search snippet and the share card described the product two different ways. <title> keeps the established Grand · … brand prefix (38 chars, inside Google's ~60 limit) rather than matching og:title exactly — og:site_name already renders “Grand” as a separate line on the share card, but a search result has no such affordance, so dropping the brand there would cost brand-query recognition for nothing.
    • The search description is a deliberately shorter variant of the share description (147 vs 178 chars). Why: Google truncates at ~155–160, and the full sentence got cut mid-word at “No c…”, losing “No cameras, no wearables.” — the sharpest differentiator in the line. Dropping the “so you can be sure she’s safe” clause fits the whole thought in the snippet. The share card keeps the longer version because those platforms allow ~300 chars and the reassurance clause earns its space there.
    • The Organization JSON-LD description was updated to the short variant too, replacing the old “quiet home sensor system” sentence. It feeds brand/knowledge-panel surfaces where the same length pressure applies. Note this string lives inside a <script> tag, so it uses a literal — HTML entities like &rsquo; are not decoded there and would render as raw text.
    • Three description strings now exist by design: the long one on og:description (share cards), and the short one shared by <meta name="description"> and the JSON-LD (search surfaces). The visible hero and “The worry” copy still use the older “nothing to charge” framing — out of scope for this pass.
  • Replaced the share thumbnail again with a branded, headlined version (assets/og-image-v3.jpg). Same photograph and framing as og-image-v2.jpg, but the delivered art now has the grand · wordmark and the headline “You'll know she's okay” composited into the blank wall on the left. Why: the v2 card carried no brand mark and no words of its own — in a feed or a group chat it had to borrow all of its meaning from the og:title line underneath, which several surfaces truncate or de-emphasise. Baking the promise into the pixels means the card says what Grand does even when the text around it is clipped. Before/After: Before — photo only, wall empty on the left. After — same photo with wordmark + two-line headline in that empty space. og:image:alt was rewritten to describe the text as well as the scene, since the alt string is what screen readers get instead of the baked-in words.

    • New filename again (-v3) rather than overwriting -v2. Why: the same URL-keyed caching that motivated the v1→v2 rename applies here; og-image-v2.jpg has been live and scraped since the previous pass.
    • Kept og:title as “Know your aging parent is okay” rather than matching the image headline verbatim. Why: the title also has to work as a standalone line on cards that don't render the image at all, where “You'll know she's okay” has no subject. The two read as a pair when both show.
  • Softened the waitlist launch-date copy from a fixed season to a relative timeframe. The .waitlist-note above the phone sign-up on index.html now reads “Grand will officially launch to the public later this year, but we are accepting prototype testers today (completely free). Sign up to learn more.” Why: the public launch date is not firm, and “Fall 2026” had to be re-edited every time it moved — a relative timeframe stops the page from advertising a specific season we may miss, while keeping the prototype-tester ask (the only thing the section actually converts on) unchanged. Before/After: Before — “Grand will officially launch to the public Fall 2026, but we are…”. After — “Grand will officially launch to the public later this year, but we are…”. Copy-only change; the eyebrow, headline, form, and validation are untouched. Known trade-off: “later this year” silently expires on 2026-12-31 — unlike “Fall 2026” it will read as current while being false, and this repo has no build step, CMS, or test that could flag it, so it needs a manual calendar reminder to revisit.

  • Added a phone-vs-email A/B test on the homepage waitlist field. Half of sessions see the phone field that is live today, half see an email field instead; whichever contact method the signup did not capture is then offered on welcome.html as an optional extra. A new ab-test.js assigns the arm, index.html carries one field block per arm, and waitlist.gs gained a waitlist_variant column on both the alpha list and the events tab. Why: signups dropped after 6f3bdf1 (Aug 12) switched the field from email to phone, and nobody knows whether the phone ask is actually the cause — the site changed several things that week. This measures the field type on its own instead of guessing. Before/After: Before — every visitor saw a required phone field, and a required email on the profile page. After — a 50/50 split on the homepage field, and a second contact method that is offered but never required. Deploy dependency: redeploy the Apps Script (Deploy -> Manage deployments -> New version) before pushing the frontend. The deployed profile handler matches rows by phone-then-email, so an email-arm visitor who volunteers a phone would not match their signup row and would get a duplicate row appended. A missing waitlist_variant column is recoverable from raw_payload; duplicate rows are not, cheaply.

    • Assignment is scoped to the browser session, not the person. ab-test.js stores the arm in sessionStorage and rolls it with crypto.getRandomValues. Why: it matches PostHog's cookieless_mode: "always" identity, so the unit of randomization and the unit of analysis are the same thing, and it adds no new persistent storage — the privacy posture in privacy.html is unchanged. Known trade-off: a returning visitor in a new session can be re-rolled, so results must be read at session level and never per person.
    • ?variant=phone|email is a QA and demo override only. It is allowlisted to those two values and persisted to sessionStorage so it survives the navigation to welcome.html. Why it must not be used to split ad traffic: pointing one ad or creative at ?variant=email would make the arm a function of campaign, audience and creative rather than of a coin flip. That is not a randomized test, it is a comparison of two ad sets.
    • The unused field is hidden by CSS, not by JavaScript. ab-test.js is a synchronous <script> in <head>, placed above the stylesheet link, and sets data-waitlist-variant on <html>; styles.css hides the other block before the first paint, and script.js then removes it from the DOM. Why: toggling visibility from JS after load would flash both fields for a frame, and a hidden required input left in the DOM is still an autofill and screen-reader target. A sync script placed after a pending stylesheet link would block on it, hence the ordering. Fallback: with no attribute set at all — ab-test.js blocked, or JS off — the phone field renders and the email one stays hidden, so a failure degrades to the form that is live today rather than to no form.
    • welcome.html decides what to ask from sessionStorage, not from the variant. It offers whichever of grand_signup_phone / grand_signup_email is missing, and offers both when neither is set. Why: a direct visit, a new tab, or blocked storage would otherwise re-roll the arm on the profile page, fire a second contradictory exposure event, and could ask someone for the identifier they had just given. This also fixes an existing rough edge, where a direct visit to welcome.html submitted a blank phone and appended an orphan row.
    • Email is no longer required on welcome.html. Why: the phone number was always a nice-to-have rather than a requirement, and we would rather not lose a signup over a second contact method. It also keeps the two arms symmetric — if one arm forced a second field and the other did not, the test would be measuring "required vs optional" alongside "phone vs email" and neither result would mean anything. The five qualifier fields (full_name, zipcode, phone_type, lives_alone, has_pets) are still required, because they decide who is routed to the scheduling call.
    • Every sheet event carries waitlist_variant, and the profile handler matches rows by an ordered identity list. trackAnalyticsEvent tags the base payload, so the existing section_view for #waitlist becomes the test's denominator for free. waitlist.gs now tries candidate_id, then the column the variant's signup actually wrote, then phone, then email. Why: the sheet events pipeline is the primary analysis surface rather than PostHog — it has a stable per-tab session_id, it is not blocked by ad blockers the way posthog.js is, and posthog.js calls identify() mid-funnel at submit, which changes distinct_id under cookieless mode.
    • waitlist.gs gained a CODE_VERSION constant, and doGet now reports every product's tabs. Why: saving code in the Apps Script editor does not update the live web app, and there was no way to tell the deployed version from the committed one. doGet also only ever reported the frozen email-era Waitlist/Events tabs, so hitting the /exec URL to check whether live signups were landing showed a stagnant row count and told you nothing. It uses getSheetByName rather than getSheet_ so a health check can never create an empty tab as a side effect.
    • ensureHeaders_ now compares the header row's actual contents instead of sheet.getLastColumn(), and widens the grid first. Why: getLastColumn() reports the last column holding content anywhere in the sheet, not the width of the header row. A single stray value out to the right of the data — a hand-added notes column, a paste that overshot — makes it greater than or equal to HEADERS.length, so the auto-migration is silently skipped. New values still get written into their column, but with a blank header above them, which looks exactly like "the new column never appeared" while the value is visibly present inside raw_payload. Comparing the header cells themselves is immune to anything sitting outside the schema, and leaves such a column untouched.
    • The exposure event fires at most once per session. It previously fired on every homepage load. Why that mattered: the variant is sticky for the whole session, so a visitor who reloaded — or came back to the homepage from welcome.html — logged an exposure per page load, every one of them in the same arm. That does not merely inflate the count, it biases the ratio, and a handful of reloads by a few visitors is enough to make an even assignment read as badly skewed. It did exactly that on 2026-09-10: PostHog showed 15 exposures splitting 11/4 in a window where the events tab held 9 distinct sessions, while the same day's assignment was 12/12. Guarded on a grand_waitlist_exposure_logged key in sessionStorage; if storage is unavailable the exposure is logged, because a browser with no sessionStorage also has no sticky variant, so each page load genuinely is a fresh assignment.
    • Do not compare PostHog exposures against session counts in the events tab. They measure different populations and cannot be reconciled. Sheet events only fire once a visitor scrolls (section_view is driven by an IntersectionObserver) or clicks, so someone who lands and bounces produces one PostHog exposure and zero sheet rows. Use the events tab for the assignment split (it is not ad-blocked and it is session-scoped, matching how the variant is assigned) and PostHog for the funnel.
    • The exposure event carries variant_assigned, marking visitors who were never randomized. ab-test.js can fail to run — dropped by a flaky connection, unparseable on a pre-2020 engine, or a bot on a stripped JS engine — and script.js then falls back to the phone arm. Why the flag: the fallback is deliberate (degrade to the form that was live before the test, not to no form) but silent, and a silent fallback is indistinguishable from a real assignment when you are looking at a lopsided split trying to work out whether the randomizer is broken. These visitors cannot actually skew the split, because posthog.js only sets $feature/homepage-waitlist-contact-field when a real arm was assigned, so PostHog excludes them from both arms rather than counting them as control — but they are traffic the experiment never saw, which matters for the denominator. Break website_waitlist_variant_assigned down by variant_assigned to read the rate. Cross-check without PostHog: in the Grand phone number events tab, a homepage row dated after this shipped with a blank waitlist_variant (column X) is the same signal, and unlike us.i.posthog.com/i/ — which is on EasyPrivacy — script.google.com is not on any blocklist, so that path sees visitors PostHog cannot.
    • PostHog experiment: a draft experiment named Homepage waitlist: phone vs email uses website_waitlist_variant_assigned as its custom exposure, website_waitlist_signup as its primary metric, and qualified website_profile_completed as its secondary metric. posthog.js maps the local phone/email assignment to PostHog's required control/test values on $feature/homepage-waitlist-contact-field, while keeping the readable waitlist_variant property. Keep the experiment in draft until this property is deployed; then launch it so pre-deploy events are excluded automatically. candidate_id is minted by the independent analytics-context.js, so blocking posthog.js no longer leaves the Sheet join key blank.
  • Replaced the Meta pixel with one the ad account can actually see (1624905415813040, was 2025489411398936). Why: an audit of the live site found the install itself was correct — snippet present on all four pixel-bearing pages, fbevents.js returning 200, PageView accepted, and _fbp/_fbc cookies set so fbclid ad-click attribution worked — but the pixel had no connection to the ad account running the ads, which made every conversion event invisible to the campaigns. Before/After: Before — signups fired Lead and CompleteRegistration into a pixel Ads Manager had no access to, so campaigns could not optimize toward or report on them. After — the same events go to a pixel owned by the correct business portfolio with the ad account assigned. No user-visible change; the only difference is on the Meta side. Also added the facebook-domain-verification meta tag to index.html so grandeldercare.com can be verified with Meta. Why verification matters: it gates Aggregated Event Measurement, and without AEM configured Meta reports only the single highest-priority event for iOS users, undercounting iOS conversions — which bites here because alpha qualification screens for iPhone users. Still outstanding: the tag has to be deployed before clicking Verify in Business Settings (Meta fetches the live root URL, so verifying pre-deploy fails in a way that looks like a bad tag), and once verified, event priority must be set to CompleteRegistration > Lead > PageView in Events Manager → Aggregated Event Measurement.

  • Fixed the homepage "Become a tester" anchor overshooting the form on iPhone, and the 760ms scroll hijack behind it. Tapping an in-page anchor on an iPhone could fling the page ~8,000px in under half a second and then stop ~500px short of the target, dumping the visitor in the middle of the emergency-response section with the signup form still off-screen — and for the first 760ms after the tap, scrolling back was impossible. Two causes, both fixed. (1) An 800px layout shift. .screen-showcase and .phone-shot centre themselves with auto margins, which cancels a grid item's default stretch and leaves the box shrink-to-fit. Its track widths therefore resolved against the max-content width of the three iOS phone screenshots, which are loading="lazy" — so the whole caregiver-experience section rendered narrow (1858px tall) until those images arrived and snapped wide (2656px tall), shoving #waitlist 798px down the page mid-scroll. A definite width (100% on the grid, min(260px, 78vw) on the figures) gives the tracks a basis that does not depend on the images having loaded; measured on a connection that delays every image by 2.5s, the document now moves 1px instead of 798px, and the tablet breakpoint's 156px shift is gone too. The loaded layout is pixel-identical at 393px, 768px and 1280px. It changes in one band, 901–1040px (iPad landscape and non-maximised laptop windows), where the showcase used to shrink-wrap below its own max-width: 760px and now holds it: 683px → 760px at 960px, 725px → 760px at 1024px, 734px → 760px at 1039px. That old sizing was an artifact, not a design decision. Because the grid sized to the images' max-content width, the rendered phone width tracked the decoded width of whichever srcset candidate sizes="…42vw, 26vw" happened to select — 249px decoded → 266px rendered at 960px, 266px → 282px at 1024px, 270px → 286px at 1039px. It was also non-monotonic: widening the window from 900px to 960px made the phones 11% smaller, because the narrower sizes condition picked a smaller file. Holding 760px removes that discontinuity. Reviewed and accepted 2026-09-16; if 760px ever reads as too large at iPad landscape, lower the declared max-width deliberately rather than letting the image files decide again. (2) Up to eight stacked scroll animations. setupAnchorScrolling fired scrollToAnchorTarget at 0/120/360/760ms to paper over that shift, but html { scroll-behavior: smooth } means behavior: "auto" animates rather than snapping (the CSSOM "auto" keyword defers to the CSS property), so each retry launched a fresh full-page animation that re-read a target which had moved again, and any scroll the visitor attempted in that window was overridden. With the layout stable, one scroll is enough and the retries are gone. The ads point at the bare URL, so the visitor's tap lands before load fires on a slow connection — which means the click path's four retries and the load path's four both ran. Replaying that exact sequence against the old code (bare URL, real touch at 900ms, every image delayed 2.5s) produces eight scroll animations spanning t=1067ms to t=3343ms: a 2.3-second window in which the page scrolls itself and the visitor cannot steer. The same replay on the new code produces one. Before/After: Before — tap "Become a tester" on a slow phone, get flung past five screens of blank unpainted background at ~40,000px/s, land short of the form, and be unable to scroll for 760ms. After — one smooth scroll landing exactly on the form (verified at the anchor position to the pixel on a deliberately slow connection), and a manual scroll during the animation is respected. Note that the 500px shortfall itself is measured off the recording rather than reproduced locally: headless Chromium shows the layout shift, the moving target and the animation stack, but its scroll anchoring compensates for content growing above the viewport and it still converges on the right pixel. WebKit has no equivalent here, which is why the iPhone lands short. The fix removes the shared root cause, so neither engine has anything left to compensate for. The load-time hash correction is also now instant rather than animated, and is skipped entirely if the visitor has already scrolled, touched, or typed — load can fire seconds late on mobile, and moving the page under someone who has started reading is worse than the ~8px sticky-header misalignment it was correcting. "Instant" is applied by toggling scroll-behavior rather than passing behavior: "instant", which throws on Safari before 15.4. Supersedes the retry mechanism added in "Fix iPhone waitlist anchor scrolling" (#26).

SEO & Indexing

All URLs are canonicalized to https://www.grandeldercare.com/ (the www host, matching CNAME).

  • index.html <head> carries the canonical link, theme-color, Open Graph tags, a twitter:card tag, and JSON-LD (Organization + WebSite). Only twitter:card is set for X/Twitter — scrapers fall back to the og:* tags for title/description/image, so there's one canonical copy of each string instead of hand-synced duplicates.
  • The deploy workflow excludes README.md and google-apps-script/ from the published site. Why: GitHub Pages was serving the whole repo, so internal strategy notes and the waitlist backend source (including the spreadsheet ID) were live URLs — and robots.txt + the sitemap would have invited crawlers to index them.
  • The social share image is assets/og-image-v3.jpg, 1200×630 (the standard large-card size for iMessage, Slack, and social previews). It is a wide shot of a Grand user at home with a sensor visible in the wall outlet, with the grand · wordmark and the headline “You'll know she's okay” set into the empty wall space on the left. Trimmed from 16:9 to 1.91:1 by cropping height only (18px off the top, 45px off the bottom at the 1672px source width) so her face, the sensor, and the headline all clear the frame. Superseded: assets/og-image-v2.jpg (no longer referenced) and assets/og-image.jpg (still referenced by the Grace subpage).
  • Known limitation: the baked-in text does not survive a centred square crop. Because og:image:width/height are declared at 1200×630, the surfaces that matter for a shared link render the full 1.91:1 card — iMessage, X (summary_large_image), Facebook, LinkedIn, Discord, and Slack (Slack only falls back to its small square thumbnail when an image is small or its dimensions are undeclared). WhatsApp's compact preview is the real exception, and it is inconsistent about when it applies one. Measured against the centred 1:1 safe band (x 285–915 of the 1200px canvas): the grand · wordmark sits at x 121–260 and survives 0%; the headline spans x 118–694 and survives 51%, i.e. it cuts mid-word, which reads as a broken render rather than as a plain photo. This is structural, not a crop-tuning problem — the subject occupies the right half of the frame and the square-safe zone is the middle 630px, so the 576px-wide text block would have to shift 165px right onto her face to survive. The trade was taken deliberately: the large-card surfaces are where shared links actually land.
  • Safe-area rule for future share cards. Keep the wordmark and any words inside the centre 630×630 of the 1200×630 canvas (x 285–915). Anything outside that band is large-card-only by definition. If a future card needs its text to survive every surface, the photo has to be composed for it — subject further right and smaller, leaving centre wall free — which costs sensor visibility, so decide it at art-brief time rather than at review time.
  • robots.txt allows all crawlers and points at the sitemap.
  • sitemap.xml lists the public pages (/, /privacy.html, /terms.html); bump a page's <lastmod> when its content changes meaningfully, and add entries as the site grows. welcome.html is deliberately excluded because it's noindex.
  • Favicons: favicon.svg is the source of truth (serif "g" on the charcoal --surface-charcoal rounded square); favicon.ico (32px) is the legacy fallback that crawlers request blindly, and apple-touch-icon.png (180px, square-cornered because iOS applies its own mask) covers iOS home-screen bookmarks.
  • Google Search Console: the site is verified via the google-site-verification meta tag in index.html (URL-prefix property for https://www.grandeldercare.com/). Don't remove that tag — verification lapses without it. After content changes, the sitemap doesn't need resubmitting; Google re-crawls on its own.
  • Meta (Facebook) domain verification: the site is verified via the facebook-domain-verification meta tag in index.html. Don't remove it — verification lapses without it, and losing verification disables Aggregated Event Measurement, which is what keeps iOS ad conversions from being undercounted. Meta only checks the domain root, so this tag belongs in index.html only; it is deliberately not duplicated onto the other pages.

Ad Pixels

  • Meta Pixel is installed in index.html, welcome.html, grace/index.html, and grace/welcome.html with pixel ID 1624905415813040, and tracks the standard PageView event on load.
  • The Meta pixel ID changed on 2026-09-11, from 2025489411398936 to 1624905415813040. Why: the original pixel was created outside the business portfolio that owns the ad account, so the ad account had no access to it. The pixel itself was healthy the whole time — fbevents.js loaded, PageView and the conversion events fired and were accepted by Meta with a 200 — but the campaigns could not read it, so roughly 51 days of Lead and CompleteRegistration data landed somewhere Ads Manager could not use for optimization or attribution. This is the failure mode worth remembering: a pixel can be installed perfectly and still be wired to nothing. Verify asset assignment in Events Manager, not just that the events fire. Nothing was migrated off the old pixel, including its ~67-day retargeting audience pool; it was never feeding the campaigns, so abandoning it costs no optimization history.
  • Reddit Pixel is installed in index.html and welcome.html with pixel ID a2_jb07ge9fad9n and tracks the standard PageVisit event on load.
  • Conversion events fire from script.js (not the pixel snippets), and only after Apps Script returns { ok: true }: a waitlist signup fires fbq('track','Lead') + rdt('track','SignUp'), and completing the profile page fires fbq('track','CompleteRegistration') + rdt('track','Lead'). All track calls are guarded, so a blocked or absent pixel never throws. Live QA URLs using ?qa=1 or utm_source=qa suppress conversion events so checks cannot train either ad platform.

PostHog Website Analytics

The site sends privacy-conscious website analytics to the same PostHog Cloud US project as the Grand iOS app, with a strict namespace boundary. posthog.js loads the US ingestion endpoint in cookieless_mode: "always", limits person profiles to identified alpha candidates, disables general autocapture, exception capture, feature-flag requests, and session recording, then manually captures pageviews. Web-vitals capture is explicitly enabled and emits PostHog's $web_vitals events independently of general autocapture. Every website event carries platform = "web" and analytics_surface = "marketing_website"; custom events also use a website_ prefix so they cannot be confused with the iOS taxonomy.

The deliberate website events are website_cta_clicked, website_contact_clicked, website_waitlist_started, website_waitlist_signup, website_waitlist_submission_failed, website_profile_completed, website_profile_submission_failed, and website_onboarding_call_clicked. analytics-context.js stores the session's first-touch UTM values, inferred channel, referring domain, landing path and click-ID-presence flags in sessionStorage; posthog.js attaches that context to pageviews and every custom event, so a later form event does not lose its Reddit/Meta source. The browser uses two random, non-personal UUIDs at submission: a session-scoped candidate_id identifies the visitor in PostHog, while a per-signup submission_id identifies one immutable form submission and is reused only when that same value is retried. Both go to the Google Sheet payload, making the confirmed browser event joinable to the written row while keeping phone numbers, email addresses, names, ZIP codes, free-text answers and raw ad click IDs out of PostHog. Earlier anonymous PostHog events from the same session are linked to the identified candidate; the candidate ID deliberately does not persist across a later browser session or another device.

PostHog project dependency: in the shared PostHog project's Project settings → Web analytics, enable Cookieless server hash mode before deploying this integration. Web vitals are enabled explicitly in the JavaScript SDK configuration, so they work while the general autocapture and remote feature-flag requests remain disabled.

Open Locally

Full-page review captures are saved in docs/screenshots/homepage: desktop at 1440px and mobile at 390px, including the supplied mobile layout.

The redesigned homepage uses index.html, homepage.css, and assets/imessage-screen.png alongside the existing assets and signup scripts. Preview it with a local HTTP server, for example python3 -m http.server 8765, then open http://localhost:8765/?qa=1. Add &variant=phone or &variant=email to inspect either signup arm.

Redesign validation: checked desktop, tablet, and mobile layouts (including 320px width), image loading and section links, both form variants, mocked signup success and error/retry, and the welcome-page handoff. The existing analytics-context and waitlist-backend tests pass. Initial layout QA used mocked signup responses. Subsequent live QA reproduced and fixed the signup timeout using a marked example.com test entry; retry confirmed the existing row without duplication.

Open index.html in a browser. No build step is required.

Production

Production is served by GitHub Pages at:

https://www.grandeldercare.com/

Because this is a static site with no build step, the Deploy GitHub Pages workflow copies main to the gh-pages branch on every push to main. The CNAME file keeps the custom domain attached to the Pages deployment.

Waitlist Backend

The waitlist form posts to a Google Apps Script web app and appends rows to a Google Sheet. The current configured Sheet is Grand Waitlist: https://docs.google.com/spreadsheets/d/1i2_lUmRSIVA1iN3zaE8mLrR-8QEpOUPKtM8Y6aHfw1w/edit

For a fresh setup:

  1. Create a Google Sheet for the waitlist.
  2. In the sheet, open Extensions -> Apps Script.
  3. Paste the contents of google-apps-script/waitlist.gs.
  4. Optional but recommended: paste the Google Sheet ID into SPREADSHEET_ID at the top of the Apps Script file.
  5. Deploy as a Web app.
  6. Set "Execute as" to yourself and "Who has access" to anyone.
  7. Copy the /exec web app URL.
  8. Paste that URL into index.html on the waitlist form's data-waitlist-endpoint attribute.

When updating the Apps Script code, use Deploy -> Manage deployments -> Edit -> New version. Saving the code alone does not update the deployed web app. Visiting the /exec URL directly returns JSON with code_version, spreadsheet_url, and a per-product sheets object giving each tab's name and last row; compare code_version against the constant at the top of waitlist.gs to confirm the deployment is current.

Verify a deploy with curl and the browser. The form sends a simple CORS text/plain request, follows Apps Script's redirect, reads the JSON response, and only records waitlist_submit_success, PostHog conversion events and ad-pixel conversions after { ok: true }. The browser stops waiting after 10 seconds; a timeout, network failure, non-2xx response, invalid JSON response or { ok: false } leaves the visitor on the form with a retry message. Apps Script returns the per-signup submission_id and treats a retry with that same ID as idempotent rather than appending a duplicate row. Editing the contact value creates a new submission ID, so a corrected or second signup in the same session cannot be silently swallowed. curl remains the quickest way to verify which backend version is deployed (-L is required because Apps Script redirects to script.googleusercontent.com):

EXEC='https://script.google.com/macros/s/AKfycbznF16TQs1AShix3v0rp6DCZVs7P844Cp0KCfeCoApbPh2FF2sCC6M97vKxu_s2YmuY/exec'

# Health: which code is live, and are the grandphone tabs actually growing?
curl -sL "$EXEC"

# Phone-arm signup
curl -sL "$EXEC" -H 'Content-Type: text/plain' --data '{"type":"waitlist_signup","product":"grandphone","phone":"+1 (555) 010-0001","source":"qa-preflight","waitlist_variant":"phone"}'

# Email-arm signup
curl -sL "$EXEC" -H 'Content-Type: text/plain' --data '{"type":"waitlist_signup","product":"grandphone","email":"qa+ab@example.com","source":"qa-preflight","waitlist_variant":"email"}'

# Email-arm profile that volunteers a phone — the case that used to duplicate
curl -sL "$EXEC" -H 'Content-Type: text/plain' --data '{"type":"waitlist_profile","product":"grandphone","email":"qa+ab@example.com","phone":"+1 (555) 010-0002","full_name":"QA","zipcode":"94110","phone_type":"iphone","lives_alone":"yes","has_pets":"no","source":"qa-preflight","waitlist_variant":"email"}'

Signups should return "sheet_name":"Grand phone number alpha list", not {"ok":false,"error":"invalid_email"}. The profile call should return "matched":truematched:false means it is appending duplicate rows instead of updating the signup row. Delete the source: "qa-preflight" rows afterwards.

The client sends whichever identifier the A/B arm asked for — a phone number or an email address, with the other field left blank and waitlist_variant recording which (the Grand site is tagged product = "grandphone", so both arms land in the Grand phone number alpha list tab — see the product-routing note below) — a random non-personal session-scoped candidate_id, a separate per-signup submission_id, source, page URL, referrer, first-touch attribution, user agent, user-agent client hints where available, language, timezone, viewport, screen, connection hints, a coarse IP-derived geo object when the lookup has already completed (city/region/country/postal, best-effort), and other browser metadata. The request is a simple text/plain CORS POST, which avoids a preflight while allowing the browser to read Apps Script's JSON response before advancing.

Privacy note: the IP geolocation lookup sends the visitor's IP to a third party (ipapi.co) and we store their coarse location. If the site gains a privacy policy, it should disclose this. The free ipapi.co tier is ~1,000 lookups/day, which is ample at current volume — revisit (or add an API key) if traffic grows.

Profile fields and the profile page

After Apps Script confirms the signup row, index.html stores the captured identifier in sessionStorage under grand_signup_phone or grand_signup_email (and clears the other key) and redirects to welcome.html, a noindex page. It POSTs a { type: "waitlist_profile", product: "grandphone", phone, email, waitlist_variant, submission_id, ... } payload to the same endpoint and waits for { ok: true } before revealing either completion panel. Required there: full_name, zipcode, phone_type, lives_alone, has_pets — the qualifier answers that decide who is routed to the scheduling call, plus how we follow up. Optional there: reason_interested, and the second contact method (the phone arm is offered an email, the email arm a phone; a direct visit with nothing stored is offered both). The optional contact value is validated only when something has been typed into it, and is sent only when non-empty, so a blank submission never clears what the signup already captured. The alpha tester question is no longer a yes/no field — instead, the confirmation screen shown after the profile form is submitted (headed "Fast track getting set up as a test user of Grand") invites the tester to book an onboarding call via a primary "Schedule a 20-minute call with us" button linking to Calendly (https://calendly.com/d/dz47-vkm-rb2/grand-early-tester-program), so no alpha_tester value is collected from the form anymore. The backend still tolerates the field for older submissions.

handleWaitlistProfile_ in waitlist.gs looks up the person's existing row and updates that row in place — one row per person, no duplicates. signupIdentities_ builds an ordered list of candidate keys and findSignupRow_ tries each in turn: the per-signup submission_id stored in raw_payload, the session-scoped candidate_id, then the column that arm's signup actually wrote, then phone, then email. Phone matches on digits only, so formatting differences don't matter; the most-recent match wins. Ordering by variant is what stops the email arm duplicating: an email-arm signup row has a blank phone column, so a phone-first lookup would miss every time and append a second row for anyone who volunteered a phone on the profile page. The profile handler retains its short lookup retry for older clients that still submit optimistically, then appends a fallback row if nothing matches. Current clients await the response and only show completion after Apps Script confirms the profile was saved.

ensureHeaders_ now auto-migrates the live sheet: because new columns are only ever appended to the end of HEADERS (geo_*, then the profile fields, phone, and candidate_id), it rewrites the header row in place when the sheet has fewer columns than HEADERS, so no manual column setup is needed after deploying a new version.

The same Apps Script endpoint also receives anonymous interaction analytics. For the Grand site these route to the Grand phone number events tab (via the grandphone product tag); the legacy Events tab is the email-era archive. The site records section views, link/button clicks, and waitlist funnel events (waitlist_phone_focus, waitlist_submit_attempt, waitlist_submit_success, waitlist_submit_error, and the profile-page equivalents waitlist_profile_submit_attempt, waitlist_profile_submit_success, waitlist_profile_submit_error). These events use a per-browser-tab session_id stored in sessionStorage; the event tab also receives the non-personal candidate/submission ID, delivery-confirmation flag and first-touch attribution fields for direct funnel reconciliation. It never receives the tester's phone number or email address.

Current Sections

Every content section leads with a standardized eyebrow (.eyebrow, uppercase, 13px, green --green) above its title. The hero itself has no eyebrow — the headline leads directly. Product cards use a quieter second-tier kicker (.kicker, uppercase, 12px, --muted); routine and emergency-response rows use green numbered labels. On the dark waitlist band the eyebrow is lifted to --band-soft; the green token is illegible there.

.eyebrow carries no margin of its own — spacing comes from the parent .stack gap. homepage.css is shared with the legal pages, and legal.css sets its own .legal-header .eyebrow margin, so adding one here double-spaces those headers.

  • Hero promise ("You'll know she's okay.") with a primary "See how Grand works" CTA and a secondary "Become a tester" waitlist CTA.
  • "The worry" problem section: a two-column narrative with the independence/worry copy on the left and an iMessage-style multi-day concern graphic on the right, followed by a compact strikethrough list dismissing pendants/watches, call-for-help buttons, in-home carers, and cameras with one-line stories.
  • Historical product-card layout (superseded by the v3 combined scene): with a combined lead ("There's a better way to know they're okay. A small hub and a few sensors. No cameras, nothing to wear, nothing to charge.") and two product cards, each showing a real product photo: assets/grand-sensor.jpg (sensor in a wall outlet) and assets/grand-hub.jpg (hub on a kitchen counter), both optimized to ~120–210KB JPGs. The .card-media slot renders a cover-fit image via :has(img), falling back to a dashed placeholder when no image is present.
  • “How Grand works” (#system): a wide home scene with floating hub/sensor captions on desktop and a dark caption band on tablet/mobile. “What Grand learns” (#attention) follows within the same section, covering wake-up time, meals, bathroom visits, and indicators of a potential fall.
  • "Caregiver experience": the daily "she's okay" app view with real iOS app screens, plus a parent-perspective dignity note.
  • "When something's wrong" (#response): what happens in an emergency — a two-column section (copy left, caregiver photo right) with a three-step numbered process (Grand pings the caregiver circle → you talk to her directly through the hub and sensors → you decide if emergency services are needed, with Grand surfacing the right local number). There is no human in the loop: Grand alerts the circle, the family makes the call. Any copy describing a Grand agent who phones the parent or dials EMS is wrong — see the "no call center" entry in the changelog above.
  • Waitlist form with validation and Google Sheets handoff. On success it redirects to the post-signup profile page.
  • Post-signup profile page (welcome.html, noindex): optional full name, ZIP, reason for interest, whether the person Grand is for lives alone, and alpha-tester interest, styled with homepage.css and the welcome.css .profile-* rules.
  • Site footer: a top row ("Contact us" label + hello@grandeldercare.com on the left, nav links — including Privacy Policy and Terms of Service — as a right-aligned single column) above a bottom bar with the grand wordmark bottom-left (linked home) and the copyright bottom-right. The footer now sits on the dark --band surface, so it uses the text wordmark rather than assets/grand-logo.png — that PNG is dark artwork drawn for the old cream footer and would be invisible here.

Grace Product Subpage

/grace is an independent product-experiment site for the Grace companion — a voice companion that sits on a parent's kitchen counter. It began as a de-branded duplicate of the main Grand page and is now the only Grace page (the earlier /gracecompanion and /gracephone experiments were removed; see the consolidation note in Status).

  • Independent, no cross-links. There are deliberately no navigational links between Grand and /grace — the only way to reach it is to type/visit its URL. It is also left out of sitemap.xml so it isn't publicly discoverable via SEO.
  • Self-contained folder (isolation over DRY). grace/ has its own index.html, welcome.html, and own copies of styles.css and script.js, so editing it can never affect the Grand site. The tradeoff: style changes made to the root site must be re-applied here by hand. Shared images use root-absolute paths (/assets/...) so they resolve from the subfolder. GitHub Pages serves /grace from its in-folder index.html automatically; the deploy workflow needs no config.
  • Page structure. Hero, The worry (#worry, centered serif + three real interview quotes, grounded by assets/worry-alone.jpg in a .worry-lead two-column grid that collapses to one column ≤900px), What Grace does (#what-grace-does), How Grace works (#how-grace-works), Grace helps her reach out (#reach-out), Caregiver experience (#caregiver), What Grace isn't (#what-grace-isnt), and the Early access waitlist (#waitlist). CTA/profiling copy is tester-focused ("Become a tester", an early Grace tester note, "ZIP code of your loved one").
  • "What Grace does" is list-formatted. All three blocks use the .does-list bold-lead-in style (see the Status note) so the section scans consistently.
  • Post-signup flow. After a waitlist signup /grace redirects to its in-folder welcome.html profiling page (same five optional fields as Grand, rebranded to Grace).
  • Ad pixels. The Meta/Reddit pixels reuse Grand's pixel IDs; Grace conversions can be split from Grand's by filtering on URL in Ads Manager. The footer links to the shared root /privacy.html and /terms.html (no Grace-specific legal pages).
  • Known a11y note: the terracotta eyebrow labels (#b85f4a) sit at ~3.9:1 on cream and ~3.4:1 on the stone band, below WCAG AA 4.5:1; left unchanged pending a brand-color decision (body text and dark pill buttons pass).

Grace waitlist storage (shared endpoint)

/grace POSTs to the same Apps Script endpoint as the Grand site. Its signups carry product = "gracecompanion" (set in grace/script.js, and also on the profile and analytics payloads plus product-scoped sessionStorage keys), so waitlist.gs routes them to the dedicated GraceCompanion Waitlist/GraceCompanion Events tabs in the shared spreadsheet — kept as-is after the consolidation for data continuity.

  • PRODUCT_SHEETS + sheetNamesForProduct_() map each product to its own tabs: gracecompanion → the GraceCompanion tabs, and grandphone → the Grand phone number alpha list/Grand phone number events tabs (the Grand site's current tag). Payloads with no/unknown product still fall through to the legacy Waitlist/Events tabs, which now hold only the historical email-era data. (The gracephone mapping remains in waitlist.gs but is unused.)
  • New tabs are auto-created by getSheet_ + ensureHeaders_ on first write.

Deploy step (required for routing to take effect): after changing waitlist.gs, re-deploy the existing web app via Deploy → Manage deployments → Edit → New version so the endpoint URL stays identical.

Legal Pages

privacy.html and terms.html share the homepage header, footer, fonts, and homepage.css. Their legal.css layout uses a centered 760px reading column, Newsreader headings, Figtree body text, green links, and a contact section separated by a hairline rule. Legal wording and effective dates are preserved.

  • Privacy Policy covers what's collected (waitlist email; optional full-name/ZIP/reason/lives-alone/alpha-tester answers; automatic technical data; cookies/pixels), how it's used and shared, retention, security, children's privacy, and GDPR/CCPA-style choices. Its "Advertising and conversion tracking" section states plainly that joining the waitlist is treated as a conversion event and shared with advertising partners (Meta, Reddit) to measure campaigns — the fact the team asked to disclose.
  • Terms of Service is pre-launch boilerplate: it makes clear the product/service is not yet available and the waitlist is not a purchase or a guarantee, plus eligibility, acceptable use, IP, an explicit "not an emergency/medical service" clause, disclaimers, limitation of liability, and Delaware governing law.
  • Unlike index.html/welcome.html, the legal pages do not include the Meta/Reddit pixel snippets. They do include cookieless PostHog pageview analytics so overall site navigation is complete. They are indexable (canonical tags set, listed in sitemap.xml).
  • Both use the correctly-spelled contact address hello@grandeldercare.com. Confirm the governing-law jurisdiction (Delaware placeholder) and have counsel review before relying on these.

Releases

Packages

Used by

Contributors

Languages