From f355effcf992b0d0883dcd4bbcc9df5b374a386c Mon Sep 17 00:00:00 2001 From: Wells Riley <884715+wr@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:25:02 -0400 Subject: [PATCH 01/14] First-run setup: the Wi-Fi network is a dropdown, Cloud is Recommended, and the phone setup asks one thing at a time (W-1073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The kit's setup portal shows the networks as one dropdown, strongest first, with "Other…" for a hidden network, instead of a list that could run off the screen plus a separate Network field. An open network hides the password. Save waits for a network to be chosen. A frame that knows its network has it chosen already. - Under Server, Featherframe Cloud is tagged Recommended and Self-hosted Advanced. - Cloud's "Set up your frame" page is three steps: detection source, then a BirdWeather station (skipped for a BirdNET-Pi or BirdNET-Go), then the email. Still one form and one POST; a page shown again for an error opens on that error's step. Without its script, every step shows at once. Refs: W-1073 --- firmware/src/ff_portal.h | 63 ++++++++++--------- hosted/src/pages.ts | 124 +++++++++++++++++++++++++++----------- hosted/src/setup.ts | 8 +-- hosted/test/setup.test.ts | 21 ++++++- 4 files changed, 145 insertions(+), 71 deletions(-) diff --git a/firmware/src/ff_portal.h b/firmware/src/ff_portal.h index 234bf5ea..8000cb4f 100644 --- a/firmware/src/ff_portal.h +++ b/firmware/src/ff_portal.h @@ -45,11 +45,12 @@ // - Every page: one card under the wordmark. WiFiManager's status line goes to // the top of it, said plainly after a failed join, and not at all when no // network is saved yet ("No AP set"). -// - The Wi-Fi page: a heading with Refresh beside it, the networks as one list -// (the webapp's Wi-Fi glyph for signal, a lock where a password is needed), -// Network and Password (Show inside the field), then the server as a choice: -// Featherframe Cloud or self-hosted, whichever this build starts on marked -// (default), the frame's own current server chosen. Self-hosted takes an +// - The Wi-Fi page: a heading with Refresh beside it, then the networks as a +// dropdown (W-1073, strongest first, as the scan sorts them; "Other…" shows +// a Network name field for a hidden network), Password (Show inside the +// field; gone for an open network), then the server as a choice: Featherframe +// Cloud (Recommended) or Self-hosted (Advanced), the frame's own current +// server chosen, else the one this build starts on. Self-hosted takes an // address, or blank to find the server on the network (posted as "find"). // Under Save, what the frame shows next for that choice: its picture again // for a frame already set up that stays on its server (W-903). @@ -96,27 +97,34 @@ var NAMES={'/wifi':'Connect to Wi-Fi','/0wifi':'Connect to Wi-Fi','/info':'Info' var fs=qa('form',card);for(var i=0;i';return s+'';} var hd=el('div','ffhead');txt(hd.appendChild(el('h2')),'Connect to Wi-Fi');card.insertBefore(hd,card.firstChild); -var as=qa('a[data-ssid]',card),list=null; -if(as.length){list=el('div','ffnets');card.insertBefore(list,as[0].parentNode); -for(i=0;i0; +ls.style.display=ssid.style.display=oth?'':'none';ssid.required=oth;if(!oth)ssid.value=op.value; +var open=on&&!oth&&!lk[op.value];if(pw){pb.style.display=lp.style.display=open?'none':'';if(open)pw.value='';} +if(oth&&list._u)ssid.focus();list._u=1;} +list.onchange=net;net();} +var rf=q('form[action*="refresh"]',card);if(rf){var rb=q('button',rf);rb.className='ffbtn';if(!list)rb.textContent='Show networks';hd.appendChild(rf);} var inp=q('#server');if(!inp)return;var f=inp.form,cur=inp.value.replace(/\/+$/,''); var pick=cur==CLOUD?'cloud':(cur?'self':DEF),box=el('div','ffserver'); -function opt(v,name){return '';} -box.innerHTML='
Server
'+opt('cloud','Featherframe Cloud')+'
'+opt('self','Self-hosted')+ +function opt(v,name,tag){return '';} +box.innerHTML='
Server
'+opt('cloud','Featherframe Cloud','Recommended')+'
'+opt('self','Self-hosted','Advanced')+ '
'+ 'Your server’s address. Leave blank to find it on your network.
'; inp.parentNode.insertBefore(box,inp);inp.type='hidden';gone(q('label[for="server"]')); @@ -170,19 +178,9 @@ button.ff2:hover,button.ffbtn:hover{color:var(--ink);background:linear-gradient( button.ffbtn{width:auto;padding:6px 12px;font-size:13px} button.D{color:var(--bad);background:none;border:1px solid var(--bad);margin-top:12px} .ffmenu~.ffmenu{margin-top:10px} -.ffnets{border:1px solid var(--border);border-radius:8px;overflow:hidden;background:var(--field)} -.ffnets>div{position:relative;display:flex;align-items:center;gap:10px;min-height:46px;padding:0 12px} -.ffnets>div+div{border-top:1px solid var(--hair)} -.ffnets>div.on{background:var(--inset)} -@media (hover:hover){.ffnets>div:hover{background:var(--inset)}} -.ffnets a{flex:1;min-width:0;padding:12px 0;color:var(--ink);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.ffnets a:after{content:'';position:absolute;top:0;right:0;bottom:0;left:0} -.q{float:none;display:flex;align-items:center;gap:7px;flex:none;height:auto;min-width:0;margin:0;padding:0;color:var(--muted)} -.q.h{display:none} -.ffq:before,.ffq:after{display:none} -.fflock{display:block;width:10px;height:12px} -.ffwifi{display:block;width:18px;height:16px} -.ffwifi .arc{stroke:var(--border-strong)}.ffwifi .arc.lit{stroke:var(--ink-2)}.ffwifi .dot{fill:var(--ink-2)} +select{padding-right:38px;background:var(--field) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5l5 5 5-5' fill='none' stroke='%23827e76' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 14px center/12px 8px} +select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--ring)} +select:invalid{color:var(--faint)}select option{color:var(--ink)} .ffpw{position:relative} .ffpw input{padding-right:66px} button.ffshow{position:absolute;top:0;right:0;bottom:0;width:auto;padding:0 14px;font-size:14px;color:var(--accent);background:none;border-radius:0 8px 8px 0} @@ -193,7 +191,8 @@ label[for=showpass]{display:inline-block;margin:12px 0 0;vertical-align:middle;c .ffchoices>div+div{border-top:1px solid var(--hair)} .ffchoices label{display:flex;align-items:center;gap:10px;margin:0;padding:12px;font-size:15px;color:var(--ink);cursor:pointer} .ffchoices input[type=radio]{flex:none;width:18px;height:18px;margin:0} -.ffmeta{color:var(--muted)} +.fftag{margin-left:auto;flex:none;padding:2px 8px;font-size:12px;font-weight:600;line-height:1.5;border-radius:999px;color:var(--muted);background:var(--inset)} +.fftag.cloud{color:var(--good);background:var(--ok-bg)} #ffself{padding:0 12px 12px 40px} #ffself small{display:block;margin-top:6px;font-size:13px;line-height:1.45;color:var(--muted)} .ffnote{font-size:13px;line-height:1.45;color:var(--muted);margin:14px 0 0} diff --git a/hosted/src/pages.ts b/hosted/src/pages.ts index dfa99ca5..3e6507a4 100644 --- a/hosted/src/pages.ts +++ b/hosted/src/pages.ts @@ -607,13 +607,36 @@ const SETUP_STYLE = ``; export interface SetupView { code: string; token: string; error?: string; email?: string; miles: boolean; place: string; country: string; source?: string; + /** Which step a re-shown page opens on: the one its error is about. */ + step?: "source" | "station" | "email"; } +// One question a step (W-1073): the detection source, then a station for +// BirdWeather, then the email. It is still one form and one POST; without its +// script every step shows at once. export function setupPage(v: SetupView): Response { const e = escapeHtml; const action = `/setup/${e(v.code.toLowerCase())}/${e(v.token)}`; @@ -622,26 +645,27 @@ export function setupPage(v: SetupView): Response { ${name}${meta}`; const us = v.country === "US"; + const nav = (last: boolean, first = false) => ``; return page("Set up your frame · Featherframe", `${SETUP_STYLE}

Set up your frame

+ ${v.error ? `

${e(v.error)}

` : ""} -
-
- - -

This is the email you'll use to sign in to Featherframe to manage your frame and change settings.

-
- -
- Detection source -
    + +
    +

    +

    Detection source

    +
      ${choice("birdweather", "A BirdWeather station near me", "No equipment needed")} ${choice("apprise", "My BirdNET-Pi", "You'll connect it in Settings after setup")} ${choice("birdnet_go", "My BirdNET-Go", "You'll connect it in Settings after setup")}
    -
-
-

Choose a station near where the frame will hang.

+ ${nav(false, true)} + +
+

+

BirdWeather station

+

Choose one near where the frame will hang.

@@ -650,26 +674,56 @@ export function setupPage(v: SetupView): Response {
    -
    - - - + ${nav(false)} +
    +
    +

    +

    + +

    You'll sign in with it to manage your frame. There is no password.

    + + + ${nav(true)} +
    `); } diff --git a/hosted/src/setup.ts b/hosted/src/setup.ts index d4118b71..fab4638f 100644 --- a/hosted/src/setup.ts +++ b/hosted/src/setup.ts @@ -155,10 +155,10 @@ export async function setupRoute(request: Request, env: Env, url: URL, ctx: Exec const email = normEmail(form.get("email")); const kit = await registeredKit(env, row); const source = SOURCES.includes(String(form.get("source"))) ? String(form.get("source")) : "birdweather"; - const again = (error: string) => setupPage({ code, token, error, email: String(form.get("email") || ""), - miles: miles(request), source, + const again = (error: string, step: "station" | "email") => setupPage({ code, token, error, step, + email: String(form.get("email") || ""), miles: miles(request), source, place: placeGuess(request), country: cfOf(request).country || "" }); - if (!email) return again("Enter an email address."); + if (!email) return again("Enter an email address.", "email"); const tz = validTz(String(form.get("tz") || "")); // The code page's "Use a different email" comes back here. @@ -182,7 +182,7 @@ export async function setupRoute(request: Request, env: Env, url: URL, ctx: Exec // A new account on BirdWeather needs its station, or the frame would have // no source at all. if (source === "birdweather" && !String(form.get("station") || "").trim()) { - return again("Choose a BirdWeather station, or another detection source."); + return again("Choose a BirdWeather station, or another detection source.", "station"); } // A new account. The invitation is taken first, and given back if the diff --git a/hosted/test/setup.test.ts b/hosted/test/setup.test.ts index 09e0c814..d6714ca3 100644 --- a/hosted/test/setup.test.ts +++ b/hosted/test/setup.test.ts @@ -226,12 +226,31 @@ describe("setting up", () => { expect((await auth(new Request(link), env, link)).headers.get("Location")).toBe("/?paired=1"); }); + it("asks one question a step: the source, a station for BirdWeather, then the email", async () => { + const f = await frameShowing(); + const url = `https://${HOST}/setup/${f.code.toLowerCase()}/${f.token}`; + const page = await (await setupRoute(new Request(url), env, new URL(url), ctx)).text(); + const steps = [...page.matchAll(/
    m[1]); + expect(steps).toEqual(["source", "station", "email"]); + expect(page).toContain('data-start="source"'); + // Still one form: every field posts together, whichever step is showing. + expect(page.match(/
    Set up frame'); + // A missing email opens the page again on the email step. + const res = await post(f, { email: "", source: "birdweather", station: "7033" }); + expect(await res.text()).toContain('data-start="email"'); + }); + it("asks a new account on BirdWeather for its station, and makes nothing without one", async () => { const f = await frameShowing(); db.prepare("INSERT INTO kits (device_id, key_hash, kit, registered_at) VALUES (?, ?, 'ee03', ?)").run(f.device, f.keyHash, NOW); const res = await post(f, { email: "rural@example.com", source: "birdweather", station: "" }); expect(res.status).toBe(200); - expect(await res.text()).toContain("Choose a BirdWeather station, or another detection source."); + const again = await res.text(); + expect(again).toContain("Choose a BirdWeather station, or another detection source."); + // The page opens again on the step the error is about (W-1073), with the email kept. + expect(again).toContain('data-start="station"'); + expect(again).toContain('value="rural@example.com"'); expect(one("SELECT count(*) AS n FROM users").n).toBe(0); expect(one("SELECT used_at FROM kits").used_at).toBeNull(); // Its own detector needs no station. From 26ce7420620642d60efd04a77cacfd2e3c6a783b Mon Sep 17 00:00:00 2001 From: Wells Riley <884715+wr@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:13:01 -0400 Subject: [PATCH 02/14] Setup reads plainer: "Do you have a bird detector?", what BirdWeather is, a normal sign-up, future tense (W-1073) - Step 1 asks "Do you have a bird detector?" with No (a BirdWeather station near you, no equipment) or Yes, BirdNET-Pi / Yes, BirdNET-Go. - The station step says what BirdWeather is, and "Use my location" is "Find stations near me", above the ZIP code search. - The last step is "Create your account": Email, "We'll send you a link to confirm it." - What happens next is said in the future tense, on the kit's setup page and in the welcome email ("your frame will show a QR code"). - The page sits at the top, so nothing above a step moves between steps. - docs/STYLE.md: future tense for what comes next; sign-ups read like everyone else's. Refs: W-1073 --- docs/STYLE.md | 7 +++++++ firmware/src/ff_portal.h | 12 ++++++------ hosted/src/pages.ts | 30 ++++++++++++++++-------------- 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/docs/STYLE.md b/docs/STYLE.md index 34e2e390..e74ef330 100644 --- a/docs/STYLE.md +++ b/docs/STYLE.md @@ -45,6 +45,13 @@ page is a copy of this file; this file wins if they disagree. - **Typography.** Sentence case for headings, labels and buttons. En dash for number ranges (1–5). Curly quotes are optional; be consistent within a page. +- **Future tense for what comes next.** "After you save, your frame will + show a code", not "your frame shows a code". What is always true stays in + the present: "the frame shows the latest detection". A "when" clause stays + present too: "When the frame shows a code, scan it." +- **Sign-up and sign-in read like everyone else's.** "Create your account", + "Email", "We'll send you a link to confirm it." Not "You'll sign in with + it to manage your frame." - **Times, as people write them.** A 12-hour clock with AM and PM: 7:00 PM, 6:30 AM. A span of time takes an arrow: 10:00 PM → 6:00 AM, Sunset → Sunrise. A recent moment is relative: just now, 4 min ago, 2 hours ago, diff --git a/firmware/src/ff_portal.h b/firmware/src/ff_portal.h index 8000cb4f..85813c5b 100644 --- a/firmware/src/ff_portal.h +++ b/firmware/src/ff_portal.h @@ -70,12 +70,12 @@ function gone(e){if(e&&e.parentNode)e.parentNode.removeChild(e);} d.title='Featherframe'; var mark=q('.ffmark');if(!mark)return; var card=el('div','ffcard');while(mark.nextSibling)card.appendChild(mark.nextSibling);mark.parentNode.appendChild(card); -var NEXT={cloud:'Featherframe-Setup will close, and your phone goes back to its usual Wi‑Fi. Then scan the QR code on your frame with your phone’s camera to finish setting up.', -self:'Featherframe-Setup will close, and your phone goes back to its usual Wi‑Fi. Then add the frame in your Featherframe webapp.', -back:'Featherframe-Setup will close, and your phone goes back to its usual Wi‑Fi. Your frame goes back to its picture.'}; -var AFTER={cloud:'After you save, your frame shows a QR code. Scan it with your phone’s camera to finish setting up.', -self:'After you save, your frame asks to connect in your Featherframe webapp.', -back:'After you save, your frame goes back to its picture.'}; +var NEXT={cloud:'Featherframe-Setup will close, and your phone will go back to its usual Wi‑Fi. Then scan the QR code on your frame with your phone’s camera to finish setting up.', +self:'Featherframe-Setup will close, and your phone will go back to its usual Wi‑Fi. Then add the frame in your Featherframe webapp.', +back:'Featherframe-Setup will close, and your phone will go back to its usual Wi‑Fi. Your frame will go back to its picture.'}; +var AFTER={cloud:'After you save, your frame will show a QR code. Scan it with your phone’s camera to finish setting up.', +self:'After you save, your frame will ask to connect in your Featherframe webapp.', +back:'After you save, your frame will go back to its picture.'}; if(p=='/wifisave'){var m=q('.msg',card);if(!m)return;card.className+=' ffsaved'; var ch=(st&&st.getItem('ffserver'))||(window.ffAdded?'back':DEF); m.className='msg ffwait';m.textContent='Connecting to your Wi‑Fi…'; diff --git a/hosted/src/pages.ts b/hosted/src/pages.ts index 3e6507a4..ec11a0ae 100644 --- a/hosted/src/pages.ts +++ b/hosted/src/pages.ts @@ -618,9 +618,9 @@ const SETUP_STYLE = ``; export interface SetupView { @@ -634,48 +638,51 @@ export interface SetupView { step?: "source" | "station" | "email"; } -// One question a step (W-1073): the detection source, then a station for -// BirdWeather, then the email. It is still one form and one POST; without its -// script every step shows at once. +// Two steps (W-1073), written for someone who has never heard of BirdNET: a +// station near them, then their email. Their own detector is a link off the +// first step for those who have one. Still one form and one POST; without its +// script both steps show at once. export function setupPage(v: SetupView): Response { const e = escapeHtml; const action = `/setup/${e(v.code.toLowerCase())}/${e(v.token)}`; const src = v.source || "birdweather"; - const choice = (value: string, name: string, meta: string) => `
  • `; + const own = src !== "birdweather"; + const detector = (value: string, name: string) => `
  • `; const us = v.country === "US"; - const nav = (last: boolean, first = false) => ``; + const start = v.step === "email" ? "email" : "source"; return page("Set up your frame · Featherframe", `${SETUP_STYLE}

    Set up your frame

    ${v.error ? `

    ${e(v.error)}

    ` : ""} - +

    -

    Do you have a bird detector?

    -

    Such as BirdNET-Pi or BirdNET-Go.

    -
      - ${choice("birdweather", "No", "Your frame will show what a BirdWeather station near you hears. No equipment needed.")} - ${choice("apprise", "Yes, BirdNET-Pi", "You'll connect it in Settings after setup.")} - ${choice("birdnet_go", "Yes, BirdNET-Go", "You'll connect it in Settings after setup.")} -
    - ${nav(false, true)} -
    -
    -

    -

    Choose a station

    -

    BirdWeather is a public network of bird detectors that people run at home. Choose one near where your frame will hang.

    - -
    - - +
    + +

    Choose a station near you

    +

    Your frame will show the birds heard by a station near you. Stations are microphones that people set up at home and share on BirdWeather.

    + +
      + +
      + + +
      + +

      +
      +
      +

      Your detector

      +

      You'll connect it in Settings after setup.

      +
        + ${detector("apprise", "BirdNET-Pi")} + ${detector("birdnet_go", "BirdNET-Go")} +
      + +

      - -
        - ${nav(false)}

        @@ -685,7 +692,7 @@ export function setupPage(v: SetupView): Response {

        We'll send you a link to confirm it.

        - ${nav(true)} +
        `); diff --git a/hosted/test/setup.test.ts b/hosted/test/setup.test.ts index d6714ca3..e837ed0e 100644 --- a/hosted/test/setup.test.ts +++ b/hosted/test/setup.test.ts @@ -226,19 +226,28 @@ describe("setting up", () => { expect((await auth(new Request(link), env, link)).headers.get("Location")).toBe("/?paired=1"); }); - it("asks one question a step: the source, a station for BirdWeather, then the email", async () => { + it("is two steps: a station near them (or their own detector), then the email", async () => { const f = await frameShowing(); const url = `https://${HOST}/setup/${f.code.toLowerCase()}/${f.token}`; const page = await (await setupRoute(new Request(url), env, new URL(url), ctx)).text(); const steps = [...page.matchAll(/
        m[1]); - expect(steps).toEqual(["source", "station", "email"]); + expect(steps).toEqual(["source", "email"]); expect(page).toContain('data-start="source"'); + // A station is the way in; a detector of their own is a link off it (W-1073). + expect(page).toContain('
        '); + expect(page).toContain('