Skip to content

Commit 23e5251

Browse files
committed
feat: score-ring favicon, header brand mark, and homepage polish
- Replace the placeholder favicon with a score-ring "S" mark (+ apple-icon) built from the report-page score gauge, so the browser-tab icon matches the site. - Add the brand mark + "Skill Issue" wordmark to the header, linking home. - Brighten the homepage 100/6/0 stats to full strength so "0 hallucinations" reads as a real trust signal. - Fall back to the person's initial when a GitHub example-profile avatar fails to load, instead of a broken-image glyph. - Centralize the version string in lib/site.ts (was hard-coded in two spots). Tooling: scripts/gen_favicon.py (icon generation), scripts/check_overflow.py (CDP mobile-overflow check). Docs staged under CHANGELOG [Unreleased]; no version bump.
1 parent f840b37 commit 23e5251

11 files changed

Lines changed: 321 additions & 13 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,20 @@ Every version listed here must correspond to a slice in [`PLAN.md`](./PLAN.md) w
88

99
---
1010

11+
## [Unreleased]
12+
13+
### Added
14+
- **A brand mark in the header.** Every page now carries the Skill Issue logo — a score-ring "S" matching the report-page score gauge — plus the wordmark in the top-left, linking home.
15+
- **A new favicon and Apple touch icon** built from the same score-ring motif, so the browser-tab icon finally matches the site instead of the old generic placeholder.
16+
17+
### Changed
18+
- **The homepage 100 / 6 / 0 stats now read at full strength** instead of half-dimmed, so "0 hallucinations" lands as the trust signal it's meant to be.
19+
20+
### Fixed
21+
- **Example-profile photos fall back to the person's initial** when GitHub's avatar fails to load, instead of showing a broken-image icon.
22+
23+
---
24+
1125
## [1.0.0] — 2026-05-29
1226

1327
First stable release. Caps the v0.9.x beta-hardening line with launch polish.

‎docs/PROGRESS_LOG.md‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,37 @@ Format:
1919

2020
---
2121

22+
## 2026-06-03 — Claude (Opus 4.8) — Post-1.0 UI polish: brand mark, favicon, hero stats, avatar fallback (unreleased)
23+
24+
**Slice:** post-v1.0.0 polish (unreleased — staged under CHANGELOG `[Unreleased]`; no version bump/tag, release paused per operator).
25+
26+
**Done (user asked: "change favicon to match the site" + "improve the UI"):**
27+
- **New favicon + apple-icon** built from the product's score-ring motif: `#27272a` track + 78% `#60a5fa` gauge arc (rounded cap, from top) wrapping a bold Inter "S" on the `#09090b` tile. Replaced the old generic black-circle/white-triangle `favicon.ico`. Generated via `frontend/scripts/gen_favicon.py` (Pillow, 4× supersample → LANCZOS); outputs `src/app/favicon.ico` (16/32/48/64/256) + `src/app/apple-icon.png` (180, square for iOS masking). Next auto-injects both `<link>` tags.
28+
- **Header brand mark** — `site-header.tsx` gained a `BrandLink` (inline-SVG ring-"S" `BrandMark`, matching the favicon + the report score ring) and wordmark, linking `/`. Header went `justify-end` → `justify-between`. Closes the empty top-left and gives report pages a one-click route home.
29+
- **Hero stats brightened** — `page.tsx` stat row `animate` opacity `0.5 → 1`; numbers (`text-foreground`) now pop, labels stay `text-muted-foreground`. "0 hallucinations" now reads as a real trust signal.
30+
- **Avatar fallback** — `example-profiles.tsx` extracted a `ProfileAvatar` client subcomponent: on `<img onError>` it swaps to the person's initial on a muted disc (+ `bg-white/5` load placeholder). No more broken-image glyph when GitHub avatars 404/rate-limit.
31+
- **Version string centralized** — new `src/lib/site.ts` `APP_VERSION`; consumed by the hero badge and the results footer (was hard-coded `v1.0.0` in two spots).
32+
33+
**Decisions:**
34+
- Favicon direction chosen by the user from three rendered candidates (pure ring / ring+dot / ring+"S"); picked **ring+"S"** for unambiguous brand identity (a bare gauge ring reads like a tab spinner).
35+
- Header mark reuses the favicon motif as inline SVG (not an `<img>`) so it inherits `currentColor`/theme and stays crisp — same `strokeDasharray` gauge technique as the report score ring.
36+
- Logged under CHANGELOG `[Unreleased]`, **not** a version bump — these are reactive tweaks, and release is operator-paused. Becomes `v1.0.1` whenever the operator cuts it.
37+
38+
**Verified:**
39+
- `eslint` clean on all touched files; **vitest 58/58 pass** (incl. `example-profiles.test.tsx`).
40+
- Favicon served at `/favicon.ico` (HTTP 200, multi-size ICO) and `apple-icon.png` `<link>` injected; confirmed in rendered head.
41+
- **No mobile horizontal overflow** — measured via CDP device-metrics (`frontend/scripts/check_overflow.py`) at 360/390/414/768: `scrollWidth == innerWidth` everywhere.
42+
- Live desktop screenshot confirms header logo + brighter stats.
43+
44+
**Learned / surprises:**
45+
- `next dev` (Turbopack) **OOM-crashed on first cold compile** on this machine (~2.7 GB free of 15.7) — `Fatal process out of memory` / `paging file too small` (Windows commit/thread-spawn limit). Restarting with `NODE_OPTIONS=--max-old-space-size=2048` compiled and served fine. Not a code bug; environment memory pressure.
46+
47+
**Blocked / open:** none. Unreleased — operator decides if/when this becomes `v1.0.1`.
48+
49+
**Next:** operator to review visually + decide on releasing as `v1.0.1`. Beyond v1.0: GitLab / LinkedIn / Resume checkers (PLAN "Beyond v1.0").
50+
51+
---
52+
2253
## 2026-05-29 — Claude (Opus 4.7) — v1.0.0 shipped (first stable release + launch polish)
2354

2455
**Slice:** v1.0.0 — the 1.0 code release. Per the user: v1.0.0 = the stable release + launch polish; the *public launch* (marketing, domain, posts, traffic) they run themselves (see `docs/LAUNCH.md`). They picked four polish items, then "make it v1.0.0 then ship it."

‎frontend/scripts/check_overflow.py‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Measure horizontal overflow at mobile/desktop widths via Chrome DevTools Protocol.
2+
3+
Launches headless Chrome with remote debugging, sets a mobile device metric
4+
override, navigates to the page, and reports documentElement.scrollWidth vs the
5+
viewport width. scrollWidth > innerWidth => horizontal overflow (bad on mobile).
6+
"""
7+
import json
8+
import subprocess
9+
import time
10+
import urllib.request
11+
12+
import websocket # websocket-client
13+
14+
CHROME = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
15+
URL = "http://localhost:3000/"
16+
PORT = 9333
17+
WIDTHS = [360, 390, 414, 768]
18+
19+
proc = subprocess.Popen(
20+
[CHROME, "--headless", "--disable-gpu", f"--remote-debugging-port={PORT}",
21+
"--remote-allow-origins=*",
22+
"--no-first-run", "--user-data-dir=" + r"C:\Users\shaan\AppData\Local\Temp\cdp-overflow",
23+
"about:blank"],
24+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
25+
)
26+
try:
27+
# Wait for the debugging endpoint.
28+
ws_url = None
29+
for _ in range(50):
30+
try:
31+
data = json.load(urllib.request.urlopen(f"http://127.0.0.1:{PORT}/json"))
32+
pages = [t for t in data if t["type"] == "page"]
33+
if pages:
34+
ws_url = pages[0]["webSocketDebuggerUrl"]
35+
break
36+
except Exception:
37+
time.sleep(0.2)
38+
if not ws_url:
39+
raise SystemExit("could not reach CDP endpoint")
40+
41+
ws = websocket.create_connection(ws_url, max_size=None)
42+
mid = 0
43+
44+
def cmd(method, params=None):
45+
global mid
46+
mid += 1
47+
ws.send(json.dumps({"id": mid, "method": method, "params": params or {}}))
48+
while True:
49+
msg = json.loads(ws.recv())
50+
if msg.get("id") == mid:
51+
return msg
52+
53+
cmd("Page.enable")
54+
for w in WIDTHS:
55+
cmd("Emulation.setDeviceMetricsOverride",
56+
{"width": w, "height": 844, "deviceScaleFactor": 0, "mobile": w < 768})
57+
cmd("Page.navigate", {"url": URL})
58+
time.sleep(2.5) # let it compile/render
59+
r = cmd("Runtime.evaluate", {
60+
"expression": "JSON.stringify({sw:document.documentElement.scrollWidth,"
61+
"iw:window.innerWidth,"
62+
"over:document.documentElement.scrollWidth-window.innerWidth})",
63+
"returnByValue": True})
64+
val = json.loads(r["result"]["result"]["value"])
65+
flag = " <-- OVERFLOW" if val["over"] > 0 else " ok"
66+
print(f"width={w:>4} scrollWidth={val['sw']:>4} innerWidth={val['iw']:>4} overflow={val['over']:>3}px{flag}")
67+
ws.close()
68+
finally:
69+
proc.terminate()

‎frontend/scripts/gen_favicon.py‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Generate the Skill Issue favicon from the product's score-ring motif.
2+
3+
Final mark (candidate C): score-ring gauge (#27272a track + #60a5fa arc with a
4+
rounded cap, 78% sweep from top) wrapping a bold Inter "S" on the app's #09090b
5+
tile. Rendered supersampled (4x) then downscaled with LANCZOS per icon size for
6+
crisp edges down to 16px.
7+
8+
Outputs:
9+
src/app/favicon.ico multi-size 16/32/48/64/256
10+
src/app/apple-icon.png 180x180 square tile for iOS
11+
.next/shots/fav-final-* preview PNGs (legibility check)
12+
"""
13+
import math
14+
import os
15+
from PIL import Image, ImageDraw, ImageFont
16+
17+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
18+
APP = os.path.join(ROOT, "src", "app")
19+
SHOTS = os.path.join(ROOT, ".next", "shots")
20+
FONT_BOLD = os.path.join(ROOT, "public", "fonts", "Inter-Bold.ttf")
21+
os.makedirs(SHOTS, exist_ok=True)
22+
23+
S = 1024 # supersample canvas
24+
BG = (9, 9, 11, 255) # #09090b app background
25+
TRACK = (39, 39, 42, 255) # #27272a app border / faint ring
26+
ACCENT = (96, 165, 250, 255) # #60a5fa app accent
27+
LIGHT = (250, 250, 250, 255) # #fafafa foreground
28+
29+
30+
def draw_arc(d, cx, cy, radius, width, color, start_deg, sweep_deg, rounded=True):
31+
"""0deg = top (12 o'clock); positive sweep = clockwise."""
32+
pil_start = start_deg - 90
33+
pil_end = pil_start + sweep_deg
34+
bbox = [cx - radius, cy - radius, cx + radius, cy + radius]
35+
d.arc(bbox, pil_start, pil_end, fill=color, width=width)
36+
if rounded:
37+
cap = width / 2
38+
for ang in (pil_start, pil_end):
39+
a = math.radians(ang)
40+
ex = cx + radius * math.cos(a)
41+
ey = cy + radius * math.sin(a)
42+
d.ellipse([ex - cap, ey - cap, ex + cap, ey + cap], fill=color)
43+
44+
45+
def render(radius_frac=0.22, s_scale=1.0, stroke_scale=1.0):
46+
"""Render the mark at the supersample size. radius_frac=0 -> square tile."""
47+
img = Image.new("RGBA", (S, S), (0, 0, 0, 0))
48+
d = ImageDraw.Draw(img)
49+
r = int(S * radius_frac)
50+
if r > 0:
51+
d.rounded_rectangle([0, 0, S - 1, S - 1], radius=r, fill=BG)
52+
else:
53+
d.rectangle([0, 0, S - 1, S - 1], fill=BG)
54+
55+
cx = cy = S / 2
56+
radius = S * 0.345
57+
width = int(S * 0.105 * stroke_scale)
58+
draw_arc(d, cx, cy, radius, width, TRACK, 0, 360, rounded=False)
59+
draw_arc(d, cx, cy, radius, width, ACCENT, 0, 281, rounded=True)
60+
61+
font = ImageFont.truetype(FONT_BOLD, int(S * 0.40 * s_scale))
62+
bb = d.textbbox((0, 0), "S", font=font)
63+
tw, th = bb[2] - bb[0], bb[3] - bb[1]
64+
d.text((cx - tw / 2 - bb[0], cy - th / 2 - bb[1]), "S", font=font, fill=LIGHT)
65+
return img
66+
67+
68+
# Tab-favicon master (rounded tile). At very small sizes nudge the S a touch
69+
# larger and the stroke a touch thicker so both survive the downscale.
70+
master = render(radius_frac=0.22)
71+
master_small = render(radius_frac=0.22, s_scale=1.06, stroke_scale=1.06)
72+
73+
ico_sizes = [16, 32, 48, 64, 256]
74+
frames = []
75+
for sz in ico_sizes:
76+
src = master_small if sz <= 32 else master
77+
frames.append(src.resize((sz, sz), Image.LANCZOS))
78+
79+
ico_path = os.path.join(APP, "favicon.ico")
80+
frames[-1].save(ico_path, format="ICO", sizes=[(s, s) for s in ico_sizes],
81+
append_images=frames[:-1])
82+
print("wrote", ico_path)
83+
84+
# Apple touch icon: square (iOS applies its own mask), opaque, 180px.
85+
apple = render(radius_frac=0.0).resize((180, 180), Image.LANCZOS)
86+
apple_path = os.path.join(APP, "apple-icon.png")
87+
apple.convert("RGB").save(apple_path)
88+
print("wrote", apple_path)
89+
90+
# Legibility previews.
91+
for sz in (16, 32, 48):
92+
(master_small if sz <= 32 else master).resize((sz, sz), Image.LANCZOS).save(
93+
os.path.join(SHOTS, f"fav-final-{sz}.png"))
94+
# Side-by-side strip at native sizes on a neutral chip.
95+
strip = Image.new("RGBA", (16 + 32 + 48 + 64 + 80, 80), (24, 24, 27, 255))
96+
x = 16
97+
for sz in (16, 32, 48):
98+
im = Image.open(os.path.join(SHOTS, f"fav-final-{sz}.png"))
99+
strip.alpha_composite(im, (x, (80 - sz) // 2))
100+
x += sz + 16
101+
strip.alpha_composite(master.resize((64, 64), Image.LANCZOS), (x, 8))
102+
strip.save(os.path.join(SHOTS, "fav-final-strip.png"))
103+
print("preview:", os.path.join(SHOTS, "fav-final-strip.png"))

‎frontend/src/app/apple-icon.png‎

11.7 KB
Loading

‎frontend/src/app/favicon.ico‎

5.27 KB
Binary file not shown.

‎frontend/src/app/page.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { SearchBar } from "@/components/search-bar";
55
import { ExampleProfiles } from "@/components/landing/example-profiles";
66
import { HowItWorks } from "@/components/landing/how-it-works";
77
import { StarCta } from "@/components/landing/star-cta";
8+
import { APP_VERSION } from "@/lib/site";
89

910
export default function Home() {
1011
return (
@@ -27,7 +28,7 @@ export default function Home() {
2728
transition={{ delay: 0.2, duration: 0.5 }}
2829
className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-medium uppercase tracking-wider text-muted-foreground"
2930
>
30-
Deterministic engineering reports · v1.0.0
31+
Deterministic engineering reports · v{APP_VERSION}
3132
</m.span>
3233

3334
<h1 className="text-4xl sm:text-5xl md:text-7xl font-bold tracking-tight text-gradient leading-tight">
@@ -50,7 +51,7 @@ export default function Home() {
5051

5152
<m.div
5253
initial={{ opacity: 0 }}
53-
animate={{ opacity: 0.5 }}
54+
animate={{ opacity: 1 }}
5455
transition={{ delay: 0.8, duration: 1 }}
5556
className="grid grid-cols-3 gap-4 pt-10 text-xs font-medium uppercase tracking-widest text-muted-foreground sm:gap-8 sm:pt-16 sm:text-sm md:gap-16"
5657
>

‎frontend/src/components/landing/example-profiles.tsx‎

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,39 @@
11
"use client";
22

3+
import { useState } from "react";
34
import { m } from "framer-motion";
45
import Link from "next/link";
56
import { ArrowRight } from "lucide-react";
67

8+
// GitHub avatars 404 or rate-limit occasionally; fall back to the person's
9+
// initial on a muted disc instead of a broken-image glyph. bg-white/5 also
10+
// gives the <img> a placeholder tint while it loads.
11+
function ProfileAvatar({ username, name }: { username: string; name: string }) {
12+
const [failed, setFailed] = useState(false);
13+
if (failed) {
14+
return (
15+
<span
16+
aria-hidden="true"
17+
className="grid size-12 place-items-center rounded-full border border-white/10 bg-white/10 text-sm font-semibold text-muted-foreground"
18+
>
19+
{(name || username).trim().charAt(0).toUpperCase()}
20+
</span>
21+
);
22+
}
23+
return (
24+
// eslint-disable-next-line @next/next/no-img-element
25+
<img
26+
src={`https://github.com/${username}.png?size=80`}
27+
alt=""
28+
width={48}
29+
height={48}
30+
loading="lazy"
31+
onError={() => setFailed(true)}
32+
className="size-12 rounded-full border border-white/10 bg-white/5"
33+
/>
34+
);
35+
}
36+
737
const PROFILES = [
838
{ username: "torvalds", name: "Linus Torvalds" },
939
{ username: "gaearon", name: "Dan Abramov" },
@@ -35,15 +65,7 @@ export function ExampleProfiles() {
3565
href={`/u/${p.username}`}
3666
className="glass group flex items-center gap-4 rounded-2xl p-4 transition-colors hover:bg-white/10"
3767
>
38-
{/* eslint-disable-next-line @next/next/no-img-element */}
39-
<img
40-
src={`https://github.com/${p.username}.png?size=80`}
41-
alt=""
42-
width={48}
43-
height={48}
44-
loading="lazy"
45-
className="size-12 rounded-full border border-white/10"
46-
/>
68+
<ProfileAvatar username={p.username} name={p.name} />
4769
<div className="min-w-0 flex-1">
4870
<p className="truncate text-sm font-semibold text-foreground">{p.name}</p>
4971
<p className="truncate text-xs text-muted-foreground">@{p.username}</p>

‎frontend/src/components/results-view.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { PositionBar } from "@/components/position-bar";
2323
import { BadgeRow } from "@/components/badge-row";
2424
import { SaveShareControls } from "@/components/save-share-controls";
2525
import { isCreator } from "@/lib/creator";
26+
import { APP_VERSION } from "@/lib/site";
2627
import { trackAnalyzeSubmitted } from "@/observability/events";
2728

2829
// NarrativeCard pulls a heavy SSE-streaming client that only matters
@@ -357,7 +358,7 @@ export function ResultsView({
357358
</section>
358359

359360
<footer className="space-y-2 pt-8 text-center text-xs uppercase tracking-widest text-muted-foreground sm:pt-12">
360-
<p>Skill Issue — GitHub Reputation Protocol v1.0.0</p>
361+
<p>Skill Issue — GitHub Reputation Protocol v{APP_VERSION}</p>
361362
</footer>
362363
</main>
363364

0 commit comments

Comments
 (0)