Skip to content

Commit 0e9cc4b

Browse files
sodiumsunclaude
andcommitted
v1.1.48: Sprite Shift race on Mission Control + version stamping fixed
The race board ships to everyone (K.: 'I don't want the sprite race to stay private'): Mission Control renders the REAL /v1/leaderboard — proportional lane (miles / leader), rank list, invite-a-friend — fetched natively off the state-push path; the card hides until real data loads. Preview: onboarding.html?race=1. Version stamping: releases 1.1.45-47 shipped bundles self-identifying as 1.1.24 (three hand-synced version copies drifted). Now heard/ __init__.py is the one source (setup.py derives from it) and the release workflow refuses to publish when the tag, __init__, and pyproject disagree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015291sbAoHDRCaem2jQda3B
1 parent f4f524f commit 0e9cc4b

6 files changed

Lines changed: 210 additions & 3 deletions

File tree

.github/workflows/release.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ jobs:
2525
# Install Heard + its runtime deps so py2app can collect them.
2626
pip install -e ".[dev]"
2727
28+
- name: Version gate (tag must match the code)
29+
if: startsWith(github.ref, 'refs/tags/')
30+
run: |
31+
TAG="${GITHUB_REF#refs/tags/v}"
32+
CODE=$(python -c "import re; print(re.search(r'__version__ = \"([^\"]+)\"', open('heard/__init__.py').read()).group(1))")
33+
PYPROJ=$(python -c "import re; print(re.search(r'^version = \"([^\"]+)\"', open('pyproject.toml').read(), re.M).group(1))")
34+
if [ "$TAG" != "$CODE" ] || [ "$TAG" != "$PYPROJ" ]; then
35+
echo "::error::tag v$TAG vs __init__.py $CODE vs pyproject $PYPROJ — all three must match (releases 1.1.45-47 shipped self-identifying as 1.1.24)"
36+
exit 1
37+
fi
38+
2839
- name: Test gate (don't ship a broken build)
2940
run: |
3041
ruff check heard/ tests/

heard/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
# bundle the updater reads CFBundleShortVersionString from Info.plist
1111
# instead, so a missed bump here won't trigger a phantom "update"
1212
# banner — see heard.updater.resolved_current_version.)
13-
__version__ = "1.1.24"
13+
__version__ = "1.1.48"
1414

1515
# The frozen Python inside Heard.app has no system CA path, so any
1616
# HTTPS call (urllib voice download, anthropic SDK, elevenlabs SDK)

heard/home_window.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,98 @@ def show_home(start: str | None = None) -> None:
9595
pass
9696

9797

98+
# Sprite Shift board cache — fetched in a background thread when the window
99+
# loads (and after an invite), never on the state-push path, so _current_state
100+
# stays network-free. None until the first successful fetch; the page hides
101+
# the race card while it's None (no fake data, ever).
102+
_LEADERBOARD: dict[str, Any] | None = None
103+
104+
105+
def _cloud_auth() -> tuple[str, str] | None:
106+
cfg = config.load()
107+
token = (cfg.get("heard_token") or "").strip()
108+
if not token:
109+
return None
110+
base = (cfg.get("heard_api_base") or "https://api.heard.dev").rstrip("/")
111+
return base, token
112+
113+
114+
def _fetch_leaderboard(on_done) -> None:
115+
"""GET /v1/leaderboard on a worker thread → cache → on_done() on the main
116+
thread. Best-effort: any failure leaves the cache as-is."""
117+
auth = _cloud_auth()
118+
if auth is None:
119+
return
120+
121+
def work():
122+
global _LEADERBOARD
123+
import ssl
124+
import urllib.request
125+
126+
base, token = auth
127+
try:
128+
try:
129+
import certifi # type: ignore
130+
131+
ctx = ssl.create_default_context(cafile=certifi.where())
132+
except ImportError:
133+
ctx = ssl.create_default_context()
134+
req = urllib.request.Request(
135+
f"{base}/v1/leaderboard",
136+
headers={"Authorization": f"Bearer {token}"},
137+
)
138+
with urllib.request.urlopen(req, timeout=8.0, context=ctx) as resp:
139+
data = json.loads(resp.read().decode("utf-8") or "{}")
140+
if isinstance(data, dict) and isinstance(data.get("standings"), list):
141+
_LEADERBOARD = data
142+
from PyObjCTools import AppHelper
143+
144+
AppHelper.callAfter(on_done)
145+
except Exception:
146+
pass
147+
148+
import threading
149+
150+
threading.Thread(target=work, daemon=True).start()
151+
152+
153+
def _send_invite(email: str, on_done) -> None:
154+
"""POST /v1/referrals/invite on a worker thread, then refresh the board."""
155+
auth = _cloud_auth()
156+
if auth is None:
157+
return
158+
159+
def work():
160+
import ssl
161+
import urllib.request
162+
163+
base, token = auth
164+
try:
165+
try:
166+
import certifi # type: ignore
167+
168+
ctx = ssl.create_default_context(cafile=certifi.where())
169+
except ImportError:
170+
ctx = ssl.create_default_context()
171+
req = urllib.request.Request(
172+
f"{base}/v1/referrals/invite",
173+
data=json.dumps({"email": email}).encode(),
174+
method="POST",
175+
headers={
176+
"Authorization": f"Bearer {token}",
177+
"Content-Type": "application/json",
178+
},
179+
)
180+
urllib.request.urlopen(req, timeout=10.0, context=ctx).close()
181+
except Exception:
182+
pass
183+
_fetch_leaderboard(on_done)
184+
185+
import threading
186+
187+
threading.Thread(target=work, daemon=True).start()
188+
189+
98190
def _current_state() -> dict[str, Any]:
99191
"""Snapshot the real app state the page renders from. Pure config reads +
100192
cheap filesystem checks — no network. Never includes anything analytics
@@ -136,6 +228,9 @@ def _current_state() -> dict[str, Any]:
136228
},
137229
"micGranted": _mic_granted(),
138230
"axGranted": _ax_granted(),
231+
# Sprite Shift race — REAL standings from /v1/leaderboard (async cache;
232+
# None hides the card). Same board the Power app renders.
233+
"leaderboard": _LEADERBOARD,
139234
"voice": cfg.get("voice") or None,
140235
"speed": float(cfg.get("speed") or 1.0),
141236
"verbosity": cfg.get("verbosity") or "normal",
@@ -661,6 +756,12 @@ def _push_state(self):
661756
# WKNavigationDelegate — push state once the page is ready
662757
def webView_didFinishNavigation_(self, web, nav):
663758
self._push_state()
759+
_fetch_leaderboard(self._push_state)
760+
761+
def _act_invite_friend(self, body):
762+
email = str(body.get("email") or "").strip()
763+
if "@" in email and "." in email:
764+
_send_invite(email, self._push_state)
664765

665766
# WKScriptMessageHandler — web → native
666767
def userContentController_didReceiveScriptMessage_(self, ucc, message):

heard/onboarding.html

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,11 @@
403403
if(q.get('plan')){ S.plan=q.get('plan'); S.signedIn=q.get('plan')!=='free'; S.email='you@example.com'; }
404404
if(q.get('upgrade')){ S.plan='power'; S.onboardedPlan='pro'; S.signedIn=true; }
405405
if(q.get('trial')){ S.trialDaysLeft=14; }
406+
if(q.get('race')){ S.leaderboard={clubSize:4,currentRank:2,standings:[
407+
{name:'#1 power user',running_seconds:22095,isCurrent:false,rank:1},
408+
{name:'You',running_seconds:18000,isCurrent:true,rank:2},
409+
{name:'Kelly (Founder)',running_seconds:18000,isCurrent:false,rank:3},
410+
{name:'Maya',running_seconds:0,isCurrent:false,rank:4}]}; }
406411
// ?view=home&connect=codex|claude|both — preview the "installed but not
407412
// connected" nudge without a backend.
408413
if(q.get('connect')){ const c=q.get('connect');
@@ -465,6 +470,7 @@
465470
const HOME_PANES = ['mission','transcript','settings'];
466471
const _initialPane = new URLSearchParams(location.search).get('pane');
467472
let navHome = HOME_PANES.includes(_initialPane) ? _initialPane : 'mission';
473+
let raceListOpen = false, raceInviteOpen = false, raceInviteSent = false;
468474
let forceHome = HOME_PANES.includes(_initialPane);
469475
window.__heard.openHome = function(pane){
470476
forceHome = true;
@@ -620,6 +626,82 @@
620626
],
621627
};
622628
const PILL = {speaking:'SPEAKING',building:'BUILDING',await:'AWAITING YOU',blocked:'BLOCKED',idle:'IDLE'};
629+
const RACE_PAL = ['#7898C8','#9A82C2','#5CA4AA','#C4B25E','#7D8EA5','#A89B8E'];
630+
function raceSlime(color, size){
631+
return `<svg viewBox="0 0 16 13" style="width:${size}px;height:${Math.round(size*13/16)}px;display:block" aria-hidden="true">
632+
<path fill="${color}" d="M5 1h6v1h2v1h1v2h1v5H1V5h1V3h1V2h2z"/>
633+
<rect x="2" y="11" width="4" height="1.6" fill="${color}" opacity=".5"/>
634+
<rect x="10" y="11" width="4" height="1.6" fill="${color}" opacity=".5"/>
635+
<rect x="5" y="5" width="2" height="2.6" fill="#20242b"/>
636+
<rect x="9" y="5" width="2" height="2.6" fill="#20242b"/>
637+
</svg>`;
638+
}
639+
function raceCard(){
640+
const LB = S.leaderboard;
641+
if(!LB || !Array.isArray(LB.standings) || !LB.standings.length) return '';
642+
const mono = "font-family:var(--mono);letter-spacing:.12em;text-transform:uppercase";
643+
const sec = s => Number(s.running_seconds)||0;
644+
const mi = s => (sec(s)/3600*5.2);
645+
const mtxt = s => mi(s).toFixed(1)+' mi';
646+
const you = LB.standings.find(s=>s.isCurrent) || LB.standings[0];
647+
const yh = sec(you)/3600;
648+
const hours = Math.floor(yh)+'h '+String(Math.round((yh%1)*60)).padStart(2,'0')+'m';
649+
const visible = LB.standings.slice(0,6);
650+
const maxSec = Math.max(1, ...visible.map(sec));
651+
const seen=[]; let fp=0;
652+
const sprites = visible.map(s=>{
653+
// A true running lane: position = miles / leader's miles; exact ties get a
654+
// tiny stagger so overlapping sprites both stay visible.
655+
let p = 0.05 + 0.86*(sec(s)/maxSec);
656+
while(seen.some(q=>Math.abs(q-p)<0.012)) p-=0.028;
657+
p=Math.max(0.01,p); seen.push(p);
658+
const color = s.isCurrent? RACE_PAL[0] : RACE_PAL[1+(fp++)%(RACE_PAL.length-1)];
659+
const chip = s.isCurrent
660+
? `<span style="position:absolute;bottom:34px;left:50%;transform:translateX(-50%);white-space:nowrap;background:var(--ink);color:#fff;${mono};font-size:8.5px;font-weight:700;padding:4px 7px;border-radius:5px">You · ${mtxt(s)}</span>`
661+
: '';
662+
return `<span title="${s.name} · ${mtxt(s)}" style="position:absolute;left:${(p*100).toFixed(1)}%;bottom:2px;width:36px;margin-left:-18px;z-index:${s.isCurrent?3:2}">${chip}${raceSlime(color,36)}</span>`;
663+
}).join('');
664+
const rows = LB.standings.map(s=>`<div style="display:flex;align-items:center;gap:10px;padding:8px 2px;border-top:1px solid var(--line)">
665+
<span style="${mono};font-size:9px;font-weight:700;color:var(--terra)">${String(s.rank).padStart(2,'0')}</span>
666+
<span style="font-size:12.5px;font-weight:600">${s.isCurrent?s.name+' · you':s.name}</span>
667+
<span style="margin-left:auto;font-size:12px;color:var(--ink-2)">${mtxt(s)}</span>
668+
</div>`).join('');
669+
return `<section style="margin-top:18px;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px 22px">
670+
<div style="display:flex;align-items:flex-start;gap:12px">
671+
<div style="flex:1;min-width:0">
672+
<div style="${mono};font-size:10px;font-weight:500;color:var(--ink-3)">Sprite shift</div>
673+
<div style="font-family:var(--serif);font-size:20px;margin-top:2px">Blue’s monthly run</div>
674+
<div style="font-size:12px;color:var(--ink-3);margin-top:2px">Compare running hours with friends. Who worked hardest?</div>
675+
</div>
676+
<button style="${mono};font-size:8.5px;font-weight:700;color:var(--ink-2);background:none;border:1px solid var(--line);border-radius:6px;padding:0 10px;height:30px;cursor:pointer" onclick="raceListOpen=!raceListOpen;raceInviteOpen=false;renderHome()">Rank ${LB.currentRank} / ${LB.clubSize}</button>
677+
<button style="${mono};font-size:8.5px;font-weight:700;color:#fff;background:var(--ink);border:none;border-radius:6px;padding:0 10px;height:30px;cursor:pointer" onclick="raceInviteOpen=!raceInviteOpen;raceListOpen=false;raceInviteSent=false;renderHome()">+ Invite friends</button>
678+
</div>
679+
<div style="display:flex;gap:22px;align-items:center;margin-top:15px">
680+
<div><div style="font-size:23px;font-weight:700;letter-spacing:-.5px">${hours}</div><div style="font-size:9.5px;color:var(--ink-3)">estimated running</div></div>
681+
<div style="width:1px;height:36px;background:var(--line)"></div>
682+
<div><div style="font-size:23px;font-weight:700;letter-spacing:-.5px">${mtxt(you)}</div><div style="font-size:9.5px;color:var(--ink-3)">miles covered</div></div>
683+
</div>
684+
<div style="position:relative;height:74px;margin-top:8px">
685+
<div style="position:absolute;left:0;right:14px;bottom:14px;border-top:2px dashed var(--line)"></div>
686+
<svg viewBox="0 0 8 24" style="position:absolute;right:0;bottom:8px;width:9px;height:27px"><path fill="var(--ink)" d="M1 0h1v24H1zM2 0h3v3H2zM5 3h3v3H5zM2 6h3v3H2zM5 9h3v3H5z"/></svg>
687+
${sprites}
688+
</div>
689+
${raceListOpen?`<div style="margin-top:10px">${rows}</div>`:''}
690+
${raceInviteOpen?`<div style="margin-top:12px;padding-top:12px;border-top:1px solid var(--line)">
691+
${raceInviteSent
692+
? `<div style="font-size:12.5px;font-weight:600">Challenge sent 💪 <span style="font-weight:400;color:var(--ink-3)">They’ll show up on your board once they join.</span></div>`
693+
: `<div style="display:flex;gap:8px"><input id="race-invite-email" type="email" placeholder="friend@email.com" style="flex:1;padding:9px 12px;border:1px solid var(--line);border-radius:8px;font-size:13px;background:#fff" onkeydown="if(event.key==='Enter')sendRaceInvite()"><button class="brand-button" onclick="sendRaceInvite()">Send</button></div>
694+
<div style="font-size:10px;color:var(--ink-3);margin-top:7px">We’ll email them your invite link and dare them to out-work you. Only rank, miles, and running hours are shared.</div>`}
695+
</div>`:''}
696+
</section>`;
697+
}
698+
function sendRaceInvite(){
699+
const el=document.getElementById('race-invite-email');
700+
const v=(el&&el.value||'').trim();
701+
if(!v||v.indexOf('@')<0||v.indexOf('.')<0) return;
702+
bridge('invite_friend',{email:v});
703+
raceInviteSent=true; renderHome();
704+
}
623705
function renderHome(){
624706
// In the app S.home is REAL (daemon + history). Only use the design sample
625707
// when it's absent (browser preview) — never show fake projects to the user.
@@ -671,6 +753,11 @@
671753
</div>`).join('')}</div>`
672754
: (recap?'':empty('No agents working right now', 'Mission Control lights up when your agents are running.'))}`;
673755

756+
// Sprite Shift — the monthly race, REAL data only (S.leaderboard is fetched
757+
// natively from /v1/leaderboard; while absent the card simply isn't shown).
758+
// Same board + proportional lane as the Power app's native panel.
759+
document.getElementById('v-mission').insertAdjacentHTML('beforeend', raceCard());
760+
674761
// Transcript
675762
document.getElementById('v-transcript').innerHTML = `<div class="h-pad"><div class="h-h">Transcript</div>
676763
${transcript.length

packaging/setup.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,15 @@
2424
from setuptools import setup # noqa: E402
2525

2626
APP_NAME = "Heard"
27-
APP_VERSION = "1.1.24"
27+
# Version comes from heard/__init__.py — the ONE source of truth. The bundle's
28+
# CFBundleShortVersionString is stamped from here, and the release workflow
29+
# refuses to publish when the pushed tag disagrees, so tag / bundle / code can
30+
# never drift again (releases v1.1.45-47 all shipped self-identifying as
31+
# 1.1.24, which would have broken the min_app_version forced-update handshake).
32+
import re as _re
33+
34+
with open(os.path.join(os.path.dirname(HERE), "heard", "__init__.py")) as _fh:
35+
APP_VERSION = _re.search(r'__version__ = "([^"]+)"', _fh.read()).group(1)
2836
APP_BUNDLE_ID = "dev.heard.menubar"
2937

3038
APP = [os.path.join(HERE, "app_entry.py")]

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "heard"
3-
version = "1.1.24"
3+
version = "1.1.48"
44
description = "A voice companion for your AI coding agents. Heard speaks your agent's replies so you can keep working."
55
readme = "README.md"
66
license = { text = "Apache-2.0" }

0 commit comments

Comments
 (0)