Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions docs/STYLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
100 changes: 46 additions & 54 deletions firmware/src/ff_portal.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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…';
Expand All @@ -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<fs.length;i++){var a=fs[i].getAttribute('action'),bt=q('button',fs[i]);
if(bt&&NAMES[a]){bt.textContent=NAMES[a];fs[i].className='ffmenu';if(!/wifi$/.test(a))bt.className+=' ff2';}}
var ssid=q('#s');if(!ssid)return;
var LOCK='<svg class="fflock" viewBox="0 0 12 14" aria-hidden="true"><path d="M3.2 6.2V4.6a2.8 2.8 0 0 1 5.6 0v1.6" fill="none" stroke="currentColor" stroke-width="1.5"/><rect x="1.2" y="6" width="9.6" height="7.2" rx="1.8" fill="currentColor"/></svg>';
function glyph(n){var a=['M2 7.3a15 15 0 0 1 20 0','M5.2 11.2a11 11 0 0 1 13.6 0','M8.6 15a6 6 0 0 1 6.8 0'],s='<svg class="ffwifi" viewBox="0 0 24 22" fill="none" stroke-width="2.3" stroke-linecap="round" aria-hidden="true">';
for(var k=0;k<3;k++)s+='<path class="arc'+(n>=4-k?' lit':'')+'" d="'+a[k]+'"/>';return s+'<circle class="dot" cx="12" cy="18.6" r="1.4"/></svg>';}
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;i<as.length;i++){var row=as[i].parentNode,qi=q('.q[role=img]',row);list.appendChild(row);
if(qi){var lv=/q-(\d)/.exec(qi.className);qi.innerHTML=(/(^|\s)l(\s|$)/.test(qi.className)?LOCK:'')+glyph(lv?+lv[1]:0);qi.className+=' ffq';}}
var c0=window.c;window.c=function(a){c0(a);var rs=list.childNodes;for(var j=0;j<rs.length;j++)rs[j].className=rs[j]==a.parentNode?'on':'';};}
var as=qa('a[data-ssid]',card),list=null,ls=q('label[for=s]'),pw=q('#p'),lp=q('label[for=p]');
for(i=0;i<card.childNodes.length;i++){var tn=card.childNodes[i];if(tn.nodeType==3&&/No networks found/.test(tn.textContent)){
var none=el('p','ffnote');txt(none,'No networks found.');card.replaceChild(none,tn);list=none;}}
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 ls=q('label[for=s]');if(ls)ls.textContent='Network';
var pw=q('#p');if(pw){var pb=el('div','ffpw');pw.parentNode.insertBefore(pb,pw);pb.appendChild(pw);
if(ls)ls.textContent='Network';
if(pw){var pb=el('div','ffpw');pw.parentNode.insertBefore(pb,pw);pb.appendChild(pw);
var sb=el('button','ffshow');sb.type='button';txt(sb,'Show');pb.appendChild(sb);
sb.onclick=function(){var h=pw.type=='password';pw.type=h?'text':'password';sb.textContent=h?'Hide':'Show';};
gone(q('#showpass'));gone(q('label[for=showpass]'));}
if(as.length&&ls){list=el('select','ffsel');list.id='ffnet';list.required=true;
var lk={},o=el('option');o.value='';o.disabled=true;txt(o,'Choose a network');list.appendChild(o);
for(i=0;i<as.length;i++){var nm=as[i].getAttribute('data-ssid')||as[i].textContent,qi=q('.q[role=img]',as[i].parentNode);
if(qi&&/(^|\s)l(\s|$)/.test(qi.className))lk[nm]=1;o=el('option');o.value=nm;txt(o,nm);list.appendChild(o);gone(as[i].parentNode);}
o=el('option');o.value='';o.setAttribute('data-other','1');txt(o,'Other…');list.appendChild(o);
var nl=el('label');nl.htmlFor='ffnet';txt(nl,'Network');ls.parentNode.insertBefore(nl,ls);ls.parentNode.insertBefore(list,ls);
txt((ls.innerHTML='',ls),'Network name');
var pick0=ssid.value||ssid.placeholder,hit=0;for(i=1;i<list.options.length-1;i++)if(list.options[i].value==pick0){list.selectedIndex=i;hit=1;}
if(!hit)list.selectedIndex=ssid.value?list.options.length-1:0;
function net(){var op=list.options[list.selectedIndex],oth=!!op.getAttribute('data-other'),on=list.selectedIndex>0;
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 '<label><input type="radio" name="ffserver" value="'+v+'"><span>'+name+(DEF==v?' <span class="ffmeta">(default)</span>':'')+'</span></label>';}
box.innerHTML='<div class="fflab">Server</div><div class="ffchoices"><div>'+opt('cloud','Featherframe Cloud')+'</div><div>'+opt('self','Self-hosted')+
'<div id="ffself"><input id="ffurl" type="text" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="http://birdnet.local:8181">'+
'<small>Your server’s address. Leave blank to find it on your network.</small></div></div></div>';
var pick=cur==CLOUD?'cloud':(cur?'self':DEF),mode=pick,box=el('div','ffserver');
box.innerHTML='<label for="ffurl">Your Featherframe server</label><input id="ffurl" type="text" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="http://birdnet.local:8181">'+
'<small>Leave it blank to find it on your network.</small>';
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<rs.length;i++){rs[i].checked=rs[i].value==pick;rs[i].onchange=show;}
url.oninput=show;show();var sv=q('button[type=submit]',f);if(sv)f.insertBefore(after,sv);
f.addEventListener('submit',function(){var c=q('input[name=ffserver]:checked',f).value;
inp.value=c=='cloud'?CLOUD:(url.value.trim()||'find');try{st&&st.setItem('ffserver',next(c));}catch(e){}});});</script>)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){}});});</script>)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
Expand Down Expand Up @@ -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}
Expand Down
Binary file added hosted/src/art/frame-10.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added hosted/src/art/frame-13.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added hosted/src/art/screen-10-bluebird.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added hosted/src/art/screen-10-cardinal.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added hosted/src/art/screen-10-chickadee.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added hosted/src/art/screen-10-titmouse.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added hosted/src/art/screen-13-bluebird.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added hosted/src/art/screen-13-cardinal.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added hosted/src/art/screen-13-chickadee.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added hosted/src/art/screen-13-titmouse.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added hosted/src/art/setup-birds.mp4
Binary file not shown.
Binary file added hosted/src/art/setup-photo.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions hosted/src/assets.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
4 changes: 3 additions & 1 deletion hosted/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading