Skip to content

Commit 62fb97b

Browse files
OpenSource03claude
andcommitted
feat: responsive sidebar arrow on welcome screen
- Arrow tail anchors to subtitle text and scales with container width - Arrow head direction computed from curve tangent for correct orientation - ResizeObserver + font-ready listener keep layout in sync Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 153e676 commit 62fb97b

2 files changed

Lines changed: 126 additions & 13 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "harnss",
3-
"version": "0.17.1",
3+
"version": "0.17.2",
44
"productName": "Harnss",
55
"description": "Harness your AI coding agents — one desktop app for Claude Code, Codex, and any ACP agent",
66
"author": {

src/components/WelcomeScreen.tsx

Lines changed: 125 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,30 @@
1-
import { memo } from "react";
1+
import { memo, useEffect, useRef, useState, type RefObject } from "react";
22
import { motion } from "motion/react";
33
import { ArrowRight, FolderOpen } from "lucide-react";
44

55
// ── Constants ─────────────────────────────────────────────────────────
66

77
const EASE_OUT: [number, number, number, number] = [0.22, 0.68, 0, 1];
88
const DISPLAY_FONT = "'Instrument Serif', Georgia, serif";
9+
const SIDEBAR_ARROW_HEIGHT = 360;
10+
const SIDEBAR_ARROW_TIP_INSET = 18;
11+
const SIDEBAR_ARROW_BASE_SPAN = 642;
12+
const SIDEBAR_ARROW_TAIL_GAP = 36;
13+
const SIDEBAR_ARROW_HEAD_LENGTH = 34;
14+
const SIDEBAR_ARROW_HEAD_SPREAD = 19;
15+
16+
function clamp(value: number, min: number, max: number) {
17+
return Math.min(Math.max(value, min), max);
18+
}
19+
20+
function rotateVector(x: number, y: number, radians: number) {
21+
const cos = Math.cos(radians);
22+
const sin = Math.sin(radians);
23+
return {
24+
x: x * cos - y * sin,
25+
y: x * sin + y * cos,
26+
};
27+
}
928

1029
// ── Ambient Gradient Orbs ─────────────────────────────────────────────
1130

@@ -133,17 +152,106 @@ function SkeletonChat() {
133152
* Placed as a direct child of the outer `relative` container so the arrow
134153
* tip can sit on the sidebar boundary while the tail starts beneath the
135154
* centered caption. */
136-
function SidebarArrow() {
137-
// Matches the hand-drawn reference more closely:
138-
// tail on the right, long sweep underneath, then a sharper rise into the sidebar.
155+
interface SidebarArrowProps {
156+
anchorRef: RefObject<HTMLElement | null>;
157+
}
158+
159+
function SidebarArrow({ anchorRef }: SidebarArrowProps) {
160+
const containerRef = useRef<HTMLDivElement | null>(null);
161+
const [metrics, setMetrics] = useState({ svgWidth: 900, tailX: 660 });
162+
163+
useEffect(() => {
164+
const container = containerRef.current;
165+
const anchor = anchorRef.current;
166+
if (!container || !anchor) {
167+
return;
168+
}
169+
170+
const updateMetrics = () => {
171+
const containerRect = container.getBoundingClientRect();
172+
const anchorRect = anchor.getBoundingClientRect();
173+
const nextWidth = Math.max(containerRect.width, 1);
174+
const maxTailX = Math.max(nextWidth - 24, SIDEBAR_ARROW_TIP_INSET + 120);
175+
const minTailX = Math.min(660, maxTailX);
176+
const anchoredTailX =
177+
anchorRect.right - containerRect.left + SIDEBAR_ARROW_TAIL_GAP;
178+
const nextTailX = clamp(anchoredTailX, minTailX, maxTailX);
179+
180+
setMetrics((prevMetrics) => {
181+
const widthChanged = Math.abs(prevMetrics.svgWidth - nextWidth) >= 1;
182+
const tailChanged = Math.abs(prevMetrics.tailX - nextTailX) >= 1;
183+
if (!widthChanged && !tailChanged) {
184+
return prevMetrics;
185+
}
186+
return { svgWidth: nextWidth, tailX: nextTailX };
187+
});
188+
};
189+
190+
updateMetrics();
191+
192+
const observer = new ResizeObserver(() => {
193+
updateMetrics();
194+
});
195+
196+
observer.observe(container);
197+
observer.observe(anchor);
198+
199+
const fontReady = document.fonts?.ready;
200+
if (fontReady) {
201+
void fontReady.then(() => {
202+
updateMetrics();
203+
});
204+
}
205+
206+
window.addEventListener("resize", updateMetrics);
207+
return () => {
208+
observer.disconnect();
209+
window.removeEventListener("resize", updateMetrics);
210+
};
211+
}, [anchorRef]);
212+
213+
const usableWidth = Math.max(metrics.svgWidth, 1);
214+
const tipX = SIDEBAR_ARROW_TIP_INSET;
215+
const tailX = metrics.tailX;
216+
const span = Math.max(tailX - tipX, 1);
217+
const scaleX = (offset: number) => tipX + (offset / SIDEBAR_ARROW_BASE_SPAN) * span;
218+
const endPoint = { x: tipX, y: 18 };
219+
const endControlPoint = { x: scaleX(92), y: 136 };
220+
221+
// Keep the tip pinned near the sidebar while the sweep length expands.
139222
const curvePath = [
140-
"M 660 104",
141-
"C 760 122, 790 235, 720 272",
142-
"C 635 318, 470 312, 330 258",
143-
"C 210 212, 110 136, 18 18",
223+
`M ${tailX.toFixed(2)} 104`,
224+
`C ${scaleX(742).toFixed(2)} 122, ${scaleX(772).toFixed(2)} 235, ${scaleX(702).toFixed(2)} 272`,
225+
`C ${scaleX(617).toFixed(2)} 318, ${scaleX(452).toFixed(2)} 312, ${scaleX(312).toFixed(2)} 258`,
226+
`C ${scaleX(192).toFixed(2)} 212, ${endControlPoint.x.toFixed(2)} 136, ${endPoint.x} ${endPoint.y}`,
144227
].join(" ");
145228

146-
const arrowHead = "M 72 44 L 18 18 L 44 74";
229+
const tangentX = endPoint.x - endControlPoint.x;
230+
const tangentY = endPoint.y - endControlPoint.y;
231+
const tangentLength = Math.hypot(tangentX, tangentY) || 1;
232+
const unitTangentX = tangentX / tangentLength;
233+
const unitTangentY = tangentY / tangentLength;
234+
const headLength = clamp(span * 0.055, SIDEBAR_ARROW_HEAD_LENGTH, 48);
235+
const headSpread = clamp(headLength * 0.56, SIDEBAR_ARROW_HEAD_SPREAD, 26);
236+
const baseCenter = {
237+
x: endPoint.x - unitTangentX * headLength,
238+
y: endPoint.y - unitTangentY * headLength,
239+
};
240+
const upperWingDirection = rotateVector(unitTangentX, unitTangentY, Math.PI / 2);
241+
const lowerWingDirection = rotateVector(unitTangentX, unitTangentY, -Math.PI / 2);
242+
const upperWing = {
243+
x: baseCenter.x + upperWingDirection.x * headSpread,
244+
y: baseCenter.y + upperWingDirection.y * headSpread,
245+
};
246+
const lowerWing = {
247+
x: baseCenter.x + lowerWingDirection.x * headSpread,
248+
y: baseCenter.y + lowerWingDirection.y * headSpread,
249+
};
250+
const arrowHead = [
251+
`M ${upperWing.x.toFixed(2)} ${upperWing.y.toFixed(2)}`,
252+
`L ${endPoint.x} ${endPoint.y}`,
253+
`L ${lowerWing.x.toFixed(2)} ${lowerWing.y.toFixed(2)}`,
254+
].join(" ");
147255

148256
return (
149257
<>
@@ -165,14 +273,15 @@ function SidebarArrow() {
165273

166274
{/* Arrow */}
167275
<motion.div
276+
ref={containerRef}
168277
className="pointer-events-none absolute inset-x-0 z-[2] h-[360px]"
169278
style={{ top: "calc(50% - 42px)" }}
170279
initial={{ opacity: 0 }}
171280
animate={{ opacity: 1 }}
172281
transition={{ delay: 0.5, duration: 0.4 }}
173282
>
174283
<svg
175-
viewBox="0 0 900 360"
284+
viewBox={`0 0 ${usableWidth} ${SIDEBAR_ARROW_HEIGHT}`}
176285
preserveAspectRatio="none"
177286
fill="none"
178287
className="h-full w-full text-foreground/[0.16]"
@@ -217,6 +326,7 @@ export const WelcomeScreen = memo(function WelcomeScreen({
217326
hasProjects,
218327
onCreateProject,
219328
}: WelcomeScreenProps) {
329+
const subtitleRef = useRef<HTMLParagraphElement | null>(null);
220330

221331
// --- No projects state ---
222332
if (!hasProjects) {
@@ -284,7 +394,7 @@ export const WelcomeScreen = memo(function WelcomeScreen({
284394
/>
285395

286396
{/* Hand-drawn arrow from center to sidebar edge */}
287-
<SidebarArrow />
397+
<SidebarArrow anchorRef={subtitleRef} />
288398

289399
{/* Central content */}
290400
<div className="relative z-10 flex flex-1 flex-col items-center justify-center px-6">
@@ -307,7 +417,10 @@ export const WelcomeScreen = memo(function WelcomeScreen({
307417
>
308418
Continue building
309419
</h1>
310-
<p className="max-w-[320px] text-center text-base leading-relaxed text-muted-foreground">
420+
<p
421+
ref={subtitleRef}
422+
className="max-w-[320px] text-center text-base leading-relaxed text-muted-foreground"
423+
>
311424
Pick up an existing thread or start fresh.
312425
</p>
313426
</motion.div>

0 commit comments

Comments
 (0)