Skip to content

Commit 3d71869

Browse files
constkclaude
andauthored
feat: SSE client primitive + hello page + CSS palette + component test (#22, #23) (#59)
Bundles two tickets that share the same frontend touch surface: #22 — typed SSE client primitive (frontend/src/lib/api/client.ts) - Port frontend/src/lib/api/client.js from Teller, rewritten as TS: generic over the parsed event shape (`SseEvent` interface), POST + body + AbortSignal supported, CRLF normalisation, malformed-chunk recovery (warns, doesn't throw). - Add typed SseError with optional status; createSession() and sendMessage<TEvent>(...) exports. - SseError uses an explicit field declaration instead of a parameter property — tsconfig's `erasableSyntaxOnly: true` rejects the latter. #23 — hello page + CSS palette + sample component test - Replace the Vite welcome screen with a real App.tsx that fetches /api/v1/health and renders `loading | ok | error` states with semantic ARIA roles + data-testids. - Port the Teller CSS-variable palette to frontend/src/styles/palette.css (light + dark via [data-theme='dark']); index.css imports it; App.css consumes the tokens (no raw hex). - Drop the Vite welcome assets (react.svg, vite.svg, hero.png, public/icons.svg). - frontend/src/App.test.tsx (Vitest + jsdom + Testing Library): mock fetch for the happy path (renders the ok badge with version) and the 500 path (renders the error message). Both PRs in one commit since the App page consumes the styles and lib shape; landing them separately would mean a half-shipped page in between. Closes #22 Closes #23 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6cbcde5 commit 3d71869

10 files changed

Lines changed: 360 additions & 379 deletions

File tree

frontend/public/icons.svg

Lines changed: 0 additions & 24 deletions
This file was deleted.

frontend/src/App.css

Lines changed: 42 additions & 165 deletions
Original file line numberDiff line numberDiff line change
@@ -1,182 +1,59 @@
1-
.counter {
2-
font-size: 16px;
3-
padding: 5px 10px;
4-
border-radius: 5px;
5-
color: var(--accent);
6-
background: var(--accent-bg);
7-
border: 2px solid transparent;
8-
transition: border-color 0.3s;
9-
margin-bottom: 24px;
10-
11-
&:hover {
12-
border-color: var(--accent-border);
13-
}
14-
&:focus-visible {
15-
outline: 2px solid var(--accent);
16-
outline-offset: 2px;
17-
}
1+
.app {
2+
max-width: 56rem;
3+
margin: 0 auto;
4+
padding: var(--space-2xl) var(--space-xl);
185
}
196

20-
.hero {
21-
position: relative;
22-
23-
.base,
24-
.framework,
25-
.vite {
26-
inset-inline: 0;
27-
margin: 0 auto;
28-
}
29-
30-
.base {
31-
width: 170px;
32-
position: relative;
33-
z-index: 0;
34-
}
35-
36-
.framework,
37-
.vite {
38-
position: absolute;
39-
}
40-
41-
.framework {
42-
z-index: 1;
43-
top: 34px;
44-
height: 28px;
45-
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) scale(1.4);
46-
}
47-
48-
.vite {
49-
z-index: 0;
50-
top: 107px;
51-
height: 26px;
52-
width: auto;
53-
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) scale(0.8);
54-
}
7+
.app h1 {
8+
margin: 0 0 var(--space-sm);
9+
color: var(--text-primary);
5510
}
5611

57-
#center {
58-
display: flex;
59-
flex-direction: column;
60-
gap: 25px;
61-
place-content: center;
62-
place-items: center;
63-
flex-grow: 1;
64-
65-
@media (max-width: 1024px) {
66-
padding: 32px 20px 24px;
67-
gap: 18px;
68-
}
12+
.app .subtitle {
13+
margin: 0 0 var(--space-2xl);
14+
color: var(--text-secondary);
6915
}
7016

71-
#next-steps {
72-
display: flex;
73-
border-top: 1px solid var(--border);
74-
text-align: left;
75-
76-
& > div {
77-
flex: 1 1 0;
78-
padding: 32px;
79-
@media (max-width: 1024px) {
80-
padding: 24px 20px;
81-
}
82-
}
83-
84-
.icon {
85-
margin-bottom: 16px;
86-
width: 22px;
87-
height: 22px;
88-
}
89-
90-
@media (max-width: 1024px) {
91-
flex-direction: column;
92-
text-align: center;
93-
}
17+
.health {
18+
border: 1px solid var(--border-default);
19+
border-radius: var(--radius-md);
20+
padding: var(--space-lg) var(--space-xl);
21+
background: var(--bg-secondary);
9422
}
9523

96-
#docs {
97-
border-right: 1px solid var(--border);
98-
99-
@media (max-width: 1024px) {
100-
border-right: none;
101-
border-bottom: 1px solid var(--border);
102-
}
24+
.health h2 {
25+
margin: 0 0 var(--space-md);
26+
font-size: 1.05rem;
27+
color: var(--text-secondary);
10328
}
10429

105-
#next-steps ul {
106-
list-style: none;
107-
padding: 0;
108-
display: flex;
109-
gap: 8px;
110-
margin: 32px 0 0;
111-
112-
.logo {
113-
height: 18px;
114-
}
115-
116-
a {
117-
color: var(--text-h);
118-
font-size: 16px;
119-
border-radius: 6px;
120-
background: var(--social-bg);
121-
display: flex;
122-
padding: 6px 12px;
123-
align-items: center;
124-
gap: 8px;
125-
text-decoration: none;
126-
transition: box-shadow 0.3s;
127-
128-
&:hover {
129-
box-shadow: var(--shadow);
130-
}
131-
.button-icon {
132-
height: 18px;
133-
width: 18px;
134-
}
135-
}
136-
137-
@media (max-width: 1024px) {
138-
margin-top: 20px;
139-
flex-wrap: wrap;
140-
justify-content: center;
141-
142-
li {
143-
flex: 1 1 calc(50% - 8px);
144-
}
145-
146-
a {
147-
width: 100%;
148-
justify-content: center;
149-
box-sizing: border-box;
150-
}
151-
}
30+
.health p {
31+
margin: 0;
15232
}
15333

154-
#spacer {
155-
height: 88px;
156-
border-top: 1px solid var(--border);
157-
@media (max-width: 1024px) {
158-
height: 48px;
159-
}
34+
.badge {
35+
display: inline-block;
36+
padding: var(--space-xs) var(--space-md);
37+
border-radius: var(--radius-sm);
38+
font-size: 0.85rem;
39+
font-weight: 600;
40+
text-transform: uppercase;
41+
letter-spacing: 0.04em;
16042
}
16143

162-
.ticks {
163-
position: relative;
164-
width: 100%;
44+
.badge--ok {
45+
background: var(--accent-success);
46+
color: var(--bg-primary);
47+
}
16548

166-
&::before,
167-
&::after {
168-
content: '';
169-
position: absolute;
170-
top: -4.5px;
171-
border: 5px solid transparent;
172-
}
49+
.badge--error {
50+
background: var(--accent-error);
51+
color: var(--bg-primary);
52+
}
17353

174-
&::before {
175-
left: 0;
176-
border-left-color: var(--border);
177-
}
178-
&::after {
179-
right: 0;
180-
border-right-color: var(--border);
181-
}
54+
code {
55+
background: var(--bg-tertiary);
56+
padding: 0 var(--space-xs);
57+
border-radius: var(--radius-sm);
58+
font-size: 0.95em;
18259
}

frontend/src/App.test.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
2+
import { render, screen, waitFor } from '@testing-library/react'
3+
import App from './App'
4+
5+
describe('App health probe', () => {
6+
beforeEach(() => {
7+
vi.restoreAllMocks()
8+
})
9+
afterEach(() => {
10+
vi.restoreAllMocks()
11+
})
12+
13+
it('renders the loading state, then the health badge once /api/v1/health resolves', async () => {
14+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
15+
ok: true,
16+
status: 200,
17+
json: async () => ({ status: 'ok', version: '0.1.0' }),
18+
} as Response)
19+
20+
render(<App />)
21+
22+
expect(screen.getByRole('status')).toHaveTextContent(/Checking/i)
23+
24+
await waitFor(() => {
25+
expect(screen.getByTestId('health-ok')).toBeInTheDocument()
26+
})
27+
expect(screen.getByTestId('health-ok')).toHaveTextContent(/ok/i)
28+
expect(screen.getByTestId('health-ok')).toHaveTextContent(/v0\.1\.0/)
29+
expect(fetchSpy).toHaveBeenCalledWith(
30+
'/api/v1/health',
31+
expect.objectContaining({ signal: expect.any(AbortSignal) })
32+
)
33+
})
34+
35+
it('renders the error state when /api/v1/health 500s', async () => {
36+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
37+
ok: false,
38+
status: 500,
39+
json: async () => ({}),
40+
} as Response)
41+
42+
render(<App />)
43+
44+
await waitFor(() => {
45+
expect(screen.getByTestId('health-error')).toBeInTheDocument()
46+
})
47+
expect(screen.getByTestId('health-error')).toHaveTextContent(/HTTP 500/)
48+
})
49+
})

0 commit comments

Comments
 (0)