diff --git a/docs/STYLE.md b/docs/STYLE.md index 34e2e390..11c4dbb2 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 your account." 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, @@ -88,8 +95,10 @@ of Asia*, *The Birds of Great Britain*. **"Bird."** In the webapp, never use "bird" for a detection, a species or an update: "a new detection reaches the frame", not "a new bird shows up". -Names and titles that contain the word are fine. The wiki, README and -marketing page may say "bird" where a person would. +Names and titles that contain the word are fine. The wiki, README, +marketing page and the first-run setup pages (the kit's Wi-Fi page, Cloud's +"Set up your frame") may say "bird" where a person would: "Your frame will +show the birds heard by a station near you." **"Plate."** A plate is a numbered sheet of a printed book. The webapp never says it (an owner sees illustrations). Docs may, once the page has said what @@ -119,6 +128,12 @@ a plate is, and the corner mark cites one ("Plate CLIX"). - **Errors say what to do**, not what broke inside. - **No architecture.** A section never opens with an intro about how the system works. +- **First run is written for someone who has never heard of BirdNET** (a kit + opened as a gift). The default path needs no technical word and can be + taken with confidence. A technical choice (a detector of your own, a + server of your own) is a link off the default, for those who know to look + for it: "I have my own BirdNET station". Never make "No" the default + answer. ## The frame's own screens diff --git a/firmware/src/ff_portal.h b/firmware/src/ff_portal.h index 234bf5ea..5c816667 100644 --- a/firmware/src/ff_portal.h +++ b/firmware/src/ff_portal.h @@ -45,12 +45,14 @@ // - 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 -// address, or blank to find the server on the network (posted as "find"). +// - 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). The server is the frame's own current +// one, else the one this build starts on. On Cloud a new owner sees only +// their Wi-Fi: "I have my own Featherframe server" under Save opens one +// address field (blank finds the server on the network, posted as "find") +// and becomes "Use Featherframe Cloud instead". // 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). // - The page after Save asks the frame how joining goes (/ffstate) and says @@ -69,12 +71,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…'; @@ -96,38 +98,42 @@ 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')+ -'
'+ -'Your server’s address. Leave blank to find it on your network.
'; +var pick=cur==CLOUD?'cloud':(cur?'self':DEF),mode=pick,box=el('div','ffserver'); +box.innerHTML=''+ +'Leave it blank to find it on your network.'; inp.parentNode.insertBefore(box,inp);inp.type='hidden';gone(q('label[for="server"]')); -var url=q('#ffurl'),self=q('#ffself'),after=el('p','ffnote'); +var url=q('#ffurl'),after=el('p','ffnote'),sw=el('button','fflink');sw.type='button'; if(pick=='self')url.value=cur; function next(c){return window.ffAdded&&c==pick&&(c=='cloud'||url.value.trim().replace(/\/+$/,'')==cur)?'back':c;} -function show(){var c=q('input[name=ffserver]:checked',f).value;self.style.display=c=='self'?'':'none';after.textContent=AFTER[next(c)];} -var rs=qa('input[name=ffserver]',box);for(i=0;i)JS" +function show(){box.style.display=mode=='self'?'':'none';sw.textContent=mode=='self'?'Use Featherframe Cloud instead':'I have my own Featherframe server';after.textContent=AFTER[next(mode)];} +sw.onclick=function(){mode=mode=='self'?'cloud':'self';show();if(mode=='self')url.focus();}; +url.oninput=show;var sv=q('button[type=submit]',f);if(sv){f.insertBefore(after,sv);sv.parentNode.insertBefore(sw,sv.nextSibling);}show(); +f.addEventListener('submit',function(){inp.value=mode=='cloud'?CLOUD:(url.value.trim()||'find');try{st&&st.setItem('ffserver',next(mode));}catch(e){}});});)JS" // The webapp's own tokens (server/templates/index.html), light and dark, // after WiFiManager's stock sheet so these rules win. The stock sheet pads and @@ -170,32 +176,18 @@ 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} .ffpw input:disabled+.ffshow{display:none} #showpass{margin:12px 6px 0 2px;vertical-align:middle} label[for=showpass]{display:inline-block;margin:12px 0 0;vertical-align:middle;color:var(--ink-2)} -.ffchoices{border:1px solid var(--border);border-radius:8px;overflow:hidden;background:var(--field)} -.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)} -#ffself{padding:0 12px 12px 40px} -#ffself small{display:block;margin-top:6px;font-size:13px;line-height:1.45;color:var(--muted)} +button.fflink{display:block;width:auto;margin:14px auto 0;padding:4px 2px;font-size:14px;font-weight:500;color:var(--accent);background:none;text-decoration:underline;text-underline-offset:3px} +button.fflink:hover{background:none;color:var(--accent-press)} +.ffserver 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} .ffhead+.ffnote{margin:0} .msg{display:block;width:auto;margin:0 0 16px;padding:10px 12px;font-size:14px;line-height:1.45;color:var(--ink);background:var(--inset);border:1px solid var(--border);border-radius:8px} diff --git a/hosted/src/art/frame-10.webp b/hosted/src/art/frame-10.webp new file mode 100644 index 00000000..3938d398 Binary files /dev/null and b/hosted/src/art/frame-10.webp differ diff --git a/hosted/src/art/frame-13.webp b/hosted/src/art/frame-13.webp new file mode 100644 index 00000000..75175313 Binary files /dev/null and b/hosted/src/art/frame-13.webp differ diff --git a/hosted/src/art/screen-10-bluebird.webp b/hosted/src/art/screen-10-bluebird.webp new file mode 100644 index 00000000..ff12deaf Binary files /dev/null and b/hosted/src/art/screen-10-bluebird.webp differ diff --git a/hosted/src/art/screen-10-cardinal.webp b/hosted/src/art/screen-10-cardinal.webp new file mode 100644 index 00000000..e2573ca3 Binary files /dev/null and b/hosted/src/art/screen-10-cardinal.webp differ diff --git a/hosted/src/art/screen-10-chickadee.webp b/hosted/src/art/screen-10-chickadee.webp new file mode 100644 index 00000000..1893119d Binary files /dev/null and b/hosted/src/art/screen-10-chickadee.webp differ diff --git a/hosted/src/art/screen-10-titmouse.webp b/hosted/src/art/screen-10-titmouse.webp new file mode 100644 index 00000000..182bc313 Binary files /dev/null and b/hosted/src/art/screen-10-titmouse.webp differ diff --git a/hosted/src/art/screen-13-bluebird.webp b/hosted/src/art/screen-13-bluebird.webp new file mode 100644 index 00000000..81199423 Binary files /dev/null and b/hosted/src/art/screen-13-bluebird.webp differ diff --git a/hosted/src/art/screen-13-cardinal.webp b/hosted/src/art/screen-13-cardinal.webp new file mode 100644 index 00000000..0c959f33 Binary files /dev/null and b/hosted/src/art/screen-13-cardinal.webp differ diff --git a/hosted/src/art/screen-13-chickadee.webp b/hosted/src/art/screen-13-chickadee.webp new file mode 100644 index 00000000..731e8f13 Binary files /dev/null and b/hosted/src/art/screen-13-chickadee.webp differ diff --git a/hosted/src/art/screen-13-titmouse.webp b/hosted/src/art/screen-13-titmouse.webp new file mode 100644 index 00000000..94b1c8e7 Binary files /dev/null and b/hosted/src/art/screen-13-titmouse.webp differ diff --git a/hosted/src/art/setup-birds.mp4 b/hosted/src/art/setup-birds.mp4 new file mode 100644 index 00000000..ca269939 Binary files /dev/null and b/hosted/src/art/setup-birds.mp4 differ diff --git a/hosted/src/art/setup-photo.webp b/hosted/src/art/setup-photo.webp new file mode 100644 index 00000000..6bdeb72e Binary files /dev/null and b/hosted/src/art/setup-photo.webp differ diff --git a/hosted/src/assets.d.ts b/hosted/src/assets.d.ts index bc40d4a7..9e14d233 100644 --- a/hosted/src/assets.d.ts +++ b/hosted/src/assets.d.ts @@ -3,3 +3,5 @@ declare module "*.html" { const text: string; export default text; } declare module "*.png" { const data: ArrayBuffer; export default data; } declare module "*.ico" { const data: ArrayBuffer; export default data; } +declare module "*.webp" { const data: ArrayBuffer; export default data; } +declare module "*.mp4" { const data: ArrayBuffer; export default data; } diff --git a/hosted/src/index.ts b/hosted/src/index.ts index 8eea0198..141f50f7 100644 --- a/hosted/src/index.ts +++ b/hosted/src/index.ts @@ -17,7 +17,7 @@ import { confirmVerification, resendVerification } from "./accounts"; import { auth, confirmEmailChange, confirmWaitlist, logout, pendingEmail, sessionUser, settingsForm } from "./accounts"; import { checkCode, login, resendCode, showCode, signInState } from "./signin"; import { adminRoute, apiRoute, waitlistRoute } from "./admin"; -import { isViewerPath, pageIcon, viewerRoute } from "./viewers"; +import { isViewerPath, pageIcon, setupMedia, viewerRoute } from "./viewers"; import { LOBBY_DRAWING, expiryText, pairingCode, setupUrl } from "./pairing"; import { Household } from "./household"; import { isSetupPath, setupRoute } from "./setup"; @@ -85,6 +85,8 @@ export default { if (path === "/_ff/script.ttf" || path === "/fonts/script.ttf") { return plates(request, env, new URL("/assets/script.ttf", url)); } + const media = setupMedia(request, path); + if (media) return media; const icon = pageIcon(path); if (icon) return icon; if (path === "/login") return login(request, env, ctx); diff --git a/hosted/src/pages.ts b/hosted/src/pages.ts index dfa99ca5..4b253dc0 100644 --- a/hosted/src/pages.ts +++ b/hosted/src/pages.ts @@ -14,7 +14,7 @@ const STYLE = ` --bg:#171614; --surface:#211f1c; --ink:#ecebe7; --ink-2:#c9c6bf; --muted:#8f8b83; --border:#34312c; --border-strong:#4a463f; --field:#2c2b27; --accent:#b08a63; --on-accent:#1b140d; --ring:rgba(176,138,99,.3); --bad:#d9705a; --good:#7fae66; --sh-card:0 1px 2px rgba(0,0,0,.3), 0 4px 14px rgba(0,0,0,.28); --sh-sm:0 1px 1px rgba(0,0,0,.25); color-scheme:dark; } } - @font-face { font-family:"Featherframe Script"; src:url("/_ff/script.ttf") format("truetype"); font-display:swap; } + @font-face { font-family:"Featherframe Script"; src:url("/_ff/script.ttf") format("truetype"); font-display:block; } * { box-sizing:border-box; } body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center; padding:16px; background:var(--bg); color:var(--ink); font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; } @@ -143,6 +143,7 @@ function page(title: string, body: string): Response { function shell(title: string, main: string, style = ""): Response { return new Response(` ${escapeHtml(title)} + ${main}`, { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } }); } @@ -586,140 +587,422 @@ export function confirmEmailEmail(link: string): { subject: string; text: string } // -- setting up a frame from the phone (W-888, W-889) --------------------------------- -const SETUP_STYLE = ``; + .choices label, .stations label { display:flex; gap:12px; align-items:flex-start; margin:0; padding:15px 14px; font-size:17px; font-weight:400; color:var(--ink); cursor:pointer; } + .choices input, .stations input { -webkit-appearance:none; appearance:none; margin:1px 0 0; width:22px; height:22px; flex:none; border-radius:50%; + border:2px solid var(--border-strong); background:var(--field); } + .choices input:checked, .stations input:checked { border:0; background:radial-gradient(circle, var(--on-accent) 0 3.5px, transparent 4px), var(--accent-fill); + box-shadow:var(--accent-lift); } + .choices input:focus-visible, .stations input:focus-visible { outline:2px solid var(--accent); outline-offset:2px; } + .meta { display:block; margin-top:2px; font-size:14px; color:var(--muted); } + /* The chosen row is plain at a glance: a warm tint, and its radio filled. */ + .choices label:has(input:checked), .stations label:has(input:checked) { background:var(--accent-tint); } + .stations label { align-items:center; } + .stations input { margin:0; } + .stations .nm { flex:1 1 auto; min-width:0; } + .stations .far { flex:none; margin-left:4px; text-align:right; font-size:17px; font-weight:600; line-height:1.15; font-variant-numeric:tabular-nums; } + .stations .far small { display:block; font-size:13px; font-weight:400; color:var(--muted); } + .closest { display:inline-block; margin-left:6px; padding:2px 8px; border-radius:999px; background:var(--accent-fill); color:var(--on-accent); text-shadow:var(--on-accent-shadow); + font-size:11.5px; font-weight:600; line-height:1.4; vertical-align:2px; white-space:nowrap; } + .then { margin:18px 0 0; padding:14px 16px; border-radius:12px; background:var(--surface); border:1px solid var(--border); + font-size:15px; line-height:1.45; color:var(--ink-2); animation:rise .35s ease; } + .then p { margin:0 0 6px; font-weight:600; color:var(--ink); } + .then ol { margin:0; padding-left:20px; } + .then li + li { margin-top:4px; } + @keyframes rise { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:none; } } +`; 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"; + /** The kit asking is the colour one (EE02): the page shows that frame. */ + color?: boolean; } +// Setting up from the phone (W-1073), for someone who has never heard of +// BirdNET: a welcome, a station near them, their email. Their own detector +// is a link off the station; "I already have an account" goes straight to the +// email. One form and one POST; without its script every screen shows. 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 chosen = src === "apprise" ? "BirdNET-Pi" : src === "birdnet_go" ? "BirdNET-Go" : ""; + // The kit's own frame: the 13" colour one, or the 10" gray one. + const kit = v.color ? "13" : "10"; const us = v.country === "US"; - 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 -
      - ${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.

    -
    - - + const start = v.step === "email" ? "email" : v.step ? "source" : "welcome"; + const back = ``; + const error = v.error ? `

    ${e(v.error)}

    ` : ""; + // The scene the marketing page tells it with, and its four birds: each one's + // video, then the phone's "New detection" for it, then the frame, hanging + // off the video's foot, repainting to its illustration. The page follows the + // video's clock (six seconds a bird). With reduced motion, or no script, it + // is the cardinal, still. + // Each clip's credit as the marketing page gives it; three are CC BY-SA, so + // the one playing is always named. + const BIRDS: [string, string, string][] = [ + ["cardinal", "Northern Cardinal", "Video by Courtney Celley, U.S. Fish and Wildlife Service"], + ["bluebird", "Eastern Bluebird", "Video by Paul Danese, Wikimedia Commons, CC BY-SA 4.0 (excerpt)"], + ["titmouse", "Tufted Titmouse", "Video by Víctor Martínez, Wikimedia Commons, CC BY-SA 4.0 (excerpt)"], + ["chickadee", "Black-capped Chickadee", "Video by Cephas, Wikimedia Commons, CC BY-SA 3.0 (excerpt)"]]; + const scene = `
    + + ${BIRDS[0][2]} + + +
    `; + return shell("Set up your frame · Featherframe", `
    + +
    + ${scene} +
    +

    Featherframe

    +

    Hear a bird outside. See it on your frame.

    +

    Your frame will show the latest bird heard near you, as a 19th-century illustration. Setting it up takes about two minutes.

    - - -
      -
      - - - +
      + + +
      + +
      +
      ${back}1 of 2
      + ${v.step === "station" ? error : ""} +
      + +

      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 BirdNET station

        +

        Your frame will show the birds your station hears. Which one do you have?

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

        What happens next

        +
          +
        1. You'll create your account.
        2. +
        3. The Featherframe webapp will show you how to connect your ${chosen}. It takes a few minutes.
        4. +
        5. Your frame will show the next bird your station hears.
        6. +
        +
        +
        +
        + + +
        +
        +
        +
        ${back}2 of 2
        + ${v.step === "email" ? error : ""} +

        Create your account

        + + +

        We'll send you a link to confirm your account. Your email address will never be shared or sold to anyone.

        + + +
        +
        + `); + `, SETUP_STYLE); } export function setupAddPage(code: string, token: string, email: string): Response { @@ -753,7 +1036,7 @@ export function welcomeEmail(station: { name: string; distance: string } | null) const opening = station ? `Your frame is set up. It shows the latest species heard by ${station.name}, a BirdWeather station ${station.distance ? `${station.distance} from you` : "near you"}, as a 19th-century illustration.` : "Your frame is set up. One step is left: connect your detector. Open the Featherframe webapp, go to Settings → Detection source and follow the steps there. Once it is connected, the frame shows the latest species your detector hears as a 19th-century illustration."; - const first = station ? "Its first picture appears within a minute." : ""; + const first = station ? "Its first picture will appear within a minute." : ""; const more = "There you can name the frame and set its rotation and update interval, change the detection source, switch Content to Collage (every species heard today on one sheet), set quiet hours (overnight, every frame shows the day's collage), add an OpenAI API key under Settings → AI image generation for species with no historical illustration, and add more frames."; const signIn = "To sign in, enter your email. We send you a link. There is no password."; const help = "Help: featherframe.app/help"; diff --git a/hosted/src/setup.ts b/hosted/src/setup.ts index d4118b71..89fe18d9 100644 --- a/hosted/src/setup.ts +++ b/hosted/src/setup.ts @@ -99,6 +99,14 @@ export async function pairLinked(env: Env, pairCode: string, hid: string): Promi return true; } +/** Which kit is asking, from what it said of its panel: the colour kit + * (EE02, Spectra 6) or the gray one. The page shows that frame. */ +export function colorKit(report: string): boolean { + let r: Record = {}; + try { r = JSON.parse(report || "{}"); } catch { /* an odd report is the gray kit's page */ } + return r["x-panel-format"] === "spectra6" || /spectra6|T133A01/i.test(r["x-panel"] || "") || /EE02/i.test(r["x-board"] || ""); +} + async function registeredKit(env: Env, row: PairingRow): Promise { return !!(await env.DB.prepare("SELECT 1 FROM kits WHERE device_id = ? AND key_hash = ? AND used_at IS NULL") .bind(row.device_id, row.key_hash).first()); @@ -133,7 +141,7 @@ export async function setupRoute(request: Request, env: Env, url: URL, ctx: Exec if (!row) return setupExpiredPage(); const user = await sessionUser(request, env); if (user?.hid) return setupAddPage(code, token, user.email); - return setupPage({ code, token, miles: miles(request), + return setupPage({ code, token, miles: miles(request), color: colorKit(row.report), place: placeGuess(request), country: cfOf(request).country || "" }); } if (request.method !== "POST") return new Response(null, { status: 405 }); @@ -155,10 +163,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, color: colorKit(row.report), 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 +190,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/src/util.ts b/hosted/src/util.ts index f96c7302..7e9e3cbe 100644 --- a/hosted/src/util.ts +++ b/hosted/src/util.ts @@ -137,3 +137,23 @@ export function firmwareWaiting(push: string | null, files: string[]): boolean { } return files.some((f) => f === "firmware.bin" || /^firmware-[^/]*\.bin$/.test(f)); } + +/** A file answered by byte range (W-1073): Safari asks for a video in ranges + * and plays nothing from a server that answers each with the whole file. */ +export function rangeResponse(request: Request, bytes: ArrayBuffer, type: string): Response { + const size = bytes.byteLength; + const headers: Record = { "Content-Type": type, "Accept-Ranges": "bytes", "Cache-Control": "max-age=86400" }; + const head = request.method === "HEAD"; + const m = /^bytes=(\d*)-(\d*)$/.exec((request.headers.get("Range") || "").trim()); + if (!m || (m[1] === "" && m[2] === "")) { + return new Response(head ? null : bytes, { headers: { ...headers, "Content-Length": String(size) } }); + } + const start = m[1] === "" ? Math.max(0, size - Number(m[2])) : Number(m[1]); + const end = m[1] === "" || m[2] === "" ? size - 1 : Math.min(Number(m[2]), size - 1); + if (!(start <= end && start < size)) { + return new Response(null, { status: 416, headers: { ...headers, "Content-Range": `bytes */${size}` } }); + } + return new Response(head ? null : bytes.slice(start, end + 1), { + status: 206, headers: { ...headers, "Content-Range": `bytes ${start}-${end}/${size}`, "Content-Length": String(end - start + 1) }, + }); +} diff --git a/hosted/src/viewers.ts b/hosted/src/viewers.ts index 076ac6cf..860ee47b 100644 --- a/hosted/src/viewers.ts +++ b/hosted/src/viewers.ts @@ -14,12 +14,39 @@ import type { Env } from "./index"; import { LOBBY_DRAWING, expiryText, pairingCode } from "./pairing"; -import { randomHex, sha256 } from "./util"; +import { randomHex, rangeResponse, sha256 } from "./util"; import viewPage from "../../server/templates/view.html"; import favicon192 from "../../server/static/favicon-192.png"; import favicon32 from "../../server/static/favicon-32.png"; import faviconIco from "../../server/static/favicon.ico"; import touchIcon from "../../server/static/apple-touch-icon.png"; +// The phone setup page's pictures (W-1073), the marketing page's own: a +// still of its cardinal video (U.S. Fish and Wildlife Service, public domain) +// for a poster, its four birds' videos joined into one 24 s loop (six seconds +// each, cropped to 4:3, 720 wide, no sound), and each kit's walnut frame +// (site/public/img/wall's renders) cut in two: the frame with its screen +// open, and the screen showing each bird (the renders' own, or laid in from +// site/public/models/screens, toned to match), so only the screen repaints. +import setupPhoto from "./art/setup-photo.webp"; +import setupVideo from "./art/setup-birds.mp4"; +import frame13 from "./art/frame-13.webp"; +import frame10 from "./art/frame-10.webp"; +import screen13Cardinal from "./art/screen-13-cardinal.webp"; +import screen13Bluebird from "./art/screen-13-bluebird.webp"; +import screen13Titmouse from "./art/screen-13-titmouse.webp"; +import screen13Chickadee from "./art/screen-13-chickadee.webp"; +import screen10Cardinal from "./art/screen-10-cardinal.webp"; +import screen10Bluebird from "./art/screen-10-bluebird.webp"; +import screen10Titmouse from "./art/screen-10-titmouse.webp"; +import screen10Chickadee from "./art/screen-10-chickadee.webp"; + +const SETUP_ART = new Map(Object.entries({ + "frame-13": frame13, "frame-10": frame10, + "screen-13-cardinal": screen13Cardinal, "screen-13-bluebird": screen13Bluebird, + "screen-13-titmouse": screen13Titmouse, "screen-13-chickadee": screen13Chickadee, + "screen-10-cardinal": screen10Cardinal, "screen-10-bluebird": screen10Bluebird, + "screen-10-titmouse": screen10Titmouse, "screen-10-chickadee": screen10Chickadee, +}).map(([name, bytes]) => [`/_ff/setup-${name}.webp`, bytes])); // While a viewer waits to be claimed it asks this often (a TRMNL on battery // too: pairing is a minute or two, once). @@ -212,10 +239,17 @@ function manifest(): Response { /** The page's icons, for a browser with no session (a tablet on a wall), and * so that a browser asking for /favicon.ico (the admin page, any tab) never * wakes a household's server for it (W-915). */ +/** The setup welcome's video, by range (Safari plays nothing otherwise). */ +export function setupMedia(request: Request, path: string): Response | null { + return path === "/_ff/setup-birds.mp4" ? rangeResponse(request, setupVideo, "video/mp4") : null; +} + export function pageIcon(path: string): Response | null { const [body, type] = path === "/static/favicon-192.png" ? [favicon192, "image/png"] : path === "/static/favicon-32.png" ? [favicon32, "image/png"] : path === "/static/apple-touch-icon.png" ? [touchIcon, "image/png"] - : path === "/favicon.ico" ? [faviconIco, "image/x-icon"] : [null, ""]; + : path === "/favicon.ico" ? [faviconIco, "image/x-icon"] + : path === "/_ff/setup-photo.webp" ? [setupPhoto, "image/webp"] + : SETUP_ART.has(path) ? [SETUP_ART.get(path)!, "image/webp"] : [null, ""]; return body ? new Response(body, { headers: { "Content-Type": type, "Cache-Control": "max-age=86400" } }) : null; } diff --git a/hosted/test/setup.test.ts b/hosted/test/setup.test.ts index 09e0c814..e5c2b0e3 100644 --- a/hosted/test/setup.test.ts +++ b/hosted/test/setup.test.ts @@ -6,11 +6,11 @@ import { readFileSync, readdirSync } from "node:fs"; import { DatabaseSync } from "node:sqlite"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { auth, confirmVerification, sessionUser } from "../src/accounts"; -import { releaseFrame, setupRoute } from "../src/setup"; +import { colorKit, releaseFrame, setupRoute } from "../src/setup"; import { checkCode, showCode } from "../src/signin"; import { rankStations, regionFor } from "../src/stations"; import { setupToken, setupUrl } from "../src/pairing"; -import { sha256 } from "../src/util"; +import { rangeResponse, sha256 } from "../src/util"; function d1(db: DatabaseSync) { const stmt = (sql: string, args: unknown[] = []) => ({ @@ -226,12 +226,71 @@ describe("setting up", () => { expect((await auth(new Request(link), env, link)).headers.get("Location")).toBe("/?paired=1"); }); + it("is a welcome, then 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(["welcome", "source", "email"]); + expect(page).toContain('data-start="welcome"'); + expect(page).toContain("I already have an account"); + // A station is the way in; a detector of their own is a link off it (W-1073). + expect(page).toContain('
        '); + expect(page).toContain('