Skip to content

Commit bdbae5b

Browse files
committed
update
1 parent bdb339f commit bdbae5b

13 files changed

Lines changed: 217 additions & 168 deletions

File tree

apps/ops-harbor/DESIGN.md

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
Airtable's website is a clean, enterprise-friendly platform that communicates "sophisticated simplicity" through a white canvas with deep navy text (`#181d26`) and Airtable Blue (`#1b61c9`) as the primary interactive accent. The Haas font family (display + text variants) creates a Swiss-precision typography system with positive letter-spacing throughout.
66

77
**Key Characteristics:**
8+
89
- White canvas with deep navy text (`#181d26`)
910
- Airtable Blue (`#1b61c9`) as primary CTA and link color
1011
- Haas + Haas Groot Disp dual font system
@@ -16,74 +17,87 @@ Airtable's website is a clean, enterprise-friendly platform that communicates "s
1617
## 2. Color Palette & Roles
1718

1819
### Primary
20+
1921
- **Deep Navy** (`#181d26`): Primary text
2022
- **Airtable Blue** (`#1b61c9`): CTA buttons, links
2123
- **White** (`#ffffff`): Primary surface
2224
- **Spotlight** (`rgba(249,252,255,0.97)`): `--theme_button-text-spotlight`
2325

2426
### Semantic
27+
2528
- **Success Green** (`#006400`): `--theme_success-text`
2629
- **Weak Text** (`rgba(4,14,32,0.69)`): `--theme_text-weak`
2730
- **Secondary Active** (`rgba(7,12,20,0.82)`): `--theme_button-text-secondary-active`
2831

2932
### Neutral
33+
3034
- **Dark Gray** (`#333333`): Secondary text
3135
- **Mid Blue** (`#254fad`): Link/accent blue variant
3236
- **Border** (`#e0e2e6`): Card borders
3337
- **Light Surface** (`#f8fafc`): Subtle surface
3438

3539
### Shadows
40+
3641
- **Blue-tinted** (`rgba(0,0,0,0.32) 0px 0px 1px, rgba(0,0,0,0.08) 0px 0px 2px, rgba(45,127,249,0.28) 0px 1px 3px, rgba(0,0,0,0.06) 0px 0px 0px 0.5px inset`)
3742
- **Soft** (`rgba(15,48,106,0.05) 0px 0px 20px`)
3843

3944
## 3. Typography Rules
4045

4146
### Font Families
47+
4248
- **Primary**: `Haas`, fallbacks: `-apple-system, system-ui, Segoe UI, Roboto`
4349
- **Display**: `Haas Groot Disp`, fallback: `Haas`
4450

4551
### Hierarchy
4652

47-
| Role | Font | Size | Weight | Line Height | Letter Spacing |
48-
|------|------|------|--------|-------------|----------------|
49-
| Display Hero | Haas | 48px | 400 | 1.15 | normal |
50-
| Display Bold | Haas Groot Disp | 48px | 900 | 1.50 | normal |
51-
| Section Heading | Haas | 40px | 400 | 1.25 | normal |
52-
| Sub-heading | Haas | 32px | 400–500 | 1.15–1.25 | normal |
53-
| Card Title | Haas | 24px | 400 | 1.20–1.30 | 0.12px |
54-
| Feature | Haas | 20px | 400 | 1.25–1.50 | 0.1px |
55-
| Body | Haas | 18px | 400 | 1.35 | 0.18px |
56-
| Body Medium | Haas | 16px | 500 | 1.30 | 0.08–0.16px |
57-
| Button | Haas | 16px | 500 | 1.25–1.30 | 0.08px |
58-
| Caption | Haas | 14px | 400–500 | 1.25–1.35 | 0.07–0.28px |
53+
| Role | Font | Size | Weight | Line Height | Letter Spacing |
54+
| --------------- | --------------- | ---- | ------- | ----------- | -------------- |
55+
| Display Hero | Haas | 48px | 400 | 1.15 | normal |
56+
| Display Bold | Haas Groot Disp | 48px | 900 | 1.50 | normal |
57+
| Section Heading | Haas | 40px | 400 | 1.25 | normal |
58+
| Sub-heading | Haas | 32px | 400–500 | 1.15–1.25 | normal |
59+
| Card Title | Haas | 24px | 400 | 1.20–1.30 | 0.12px |
60+
| Feature | Haas | 20px | 400 | 1.25–1.50 | 0.1px |
61+
| Body | Haas | 18px | 400 | 1.35 | 0.18px |
62+
| Body Medium | Haas | 16px | 500 | 1.30 | 0.08–0.16px |
63+
| Button | Haas | 16px | 500 | 1.25–1.30 | 0.08px |
64+
| Caption | Haas | 14px | 400–500 | 1.25–1.35 | 0.07–0.28px |
5965

6066
## 4. Component Stylings
6167

6268
### Buttons
69+
6370
- **Primary Blue**: `#1b61c9`, white text, 16px 24px padding, 12px radius
6471
- **White**: white bg, `#181d26` text, 12px radius, 1px border white
6572
- **Cookie Consent**: `#1b61c9` bg, 2px radius (sharp)
6673

6774
### Cards: `1px solid #e0e2e6`, 16px–24px radius
75+
6876
### Inputs: Standard Haas styling
6977

7078
## 5. Layout
79+
7180
- Spacing: 1–48px (8px base)
7281
- Radius: 2px (small), 12px (buttons), 16px (cards), 24px (sections), 32px (large), 50% (circles)
7382

7483
## 6. Depth
84+
7585
- Blue-tinted multi-layer shadow system
7686
- Soft ambient: `rgba(15,48,106,0.05) 0px 0px 20px`
7787

7888
## 7. Do's and Don'ts
89+
7990
### Do: Use Airtable Blue for CTAs, Haas with positive tracking, 12px radius buttons
91+
8092
### Don't: Skip positive letter-spacing, use heavy shadows
8193

8294
## 8. Responsive Behavior
95+
8396
Breakpoints: 425–1664px (23 breakpoints)
8497

8598
## 9. Agent Prompt Guide
99+
86100
- Text: Deep Navy (`#181d26`)
87101
- CTA: Airtable Blue (`#1b61c9`)
88102
- Background: White (`#ffffff`)
89-
- Border: `#e0e2e6`
103+
- Border: `#e0e2e6`

apps/ops-harbor/src/client/app/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useEffect, useState } from "react";
2+
import { ActivityPage } from "../pages/activity";
23
import { DashboardPage } from "../pages/dashboard";
34
import { SettingsPage } from "../pages/settings";
45

@@ -18,5 +19,8 @@ export function App() {
1819
if (hash === "#/settings") {
1920
return <SettingsPage />;
2021
}
22+
if (hash === "#/activity") {
23+
return <ActivityPage />;
24+
}
2125
return <DashboardPage />;
2226
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ActivityPage } from "./ui/ActivityPage";
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { useEffect, useState } from "react";
2+
import type { ActivityEvent } from "@repo/ops-harbor-core";
3+
import { fetchJson } from "@/shared/lib";
4+
5+
type AutomationRun = {
6+
id: string;
7+
workItemId: string;
8+
repository: string;
9+
triggerType: string;
10+
status: "pending" | "running" | "completed" | "failed" | "canceled" | "leased";
11+
startedAt: string;
12+
finishedAt: string;
13+
summary: string;
14+
};
15+
16+
function useActivityData() {
17+
const [activity, setActivity] = useState<ActivityEvent[]>([]);
18+
const [runs, setRuns] = useState<AutomationRun[]>([]);
19+
20+
async function refresh(): Promise<void> {
21+
const [activityData, runsData] = await Promise.all([
22+
fetchJson<ActivityEvent[]>("/api/activity?limit=200"),
23+
fetchJson<AutomationRun[]>("/api/automation-runs"),
24+
]);
25+
setActivity(activityData);
26+
setRuns(runsData);
27+
}
28+
29+
useEffect(() => {
30+
void refresh().catch((error) => {
31+
console.error("activity refresh failed", error);
32+
});
33+
34+
const eventSource = new EventSource("/api/events");
35+
const handleUpdate = () => {
36+
void refresh().catch((error) => {
37+
console.error("SSE-triggered activity refresh failed", error);
38+
});
39+
};
40+
eventSource.addEventListener("sync_completed", handleUpdate);
41+
eventSource.addEventListener("automation_completed", handleUpdate);
42+
return () => eventSource.close();
43+
}, []);
44+
45+
return { activity, runs };
46+
}
47+
48+
export { useActivityData, type AutomationRun };
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { useTheme } from "@/shared/lib";
2+
import { ActivityPanel } from "../../dashboard/ui/ActivityPanel";
3+
import { AutomationPanel } from "../../dashboard/ui/AutomationPanel";
4+
import { useActivityData } from "../model/activity";
5+
6+
function ActivityPage() {
7+
const { activity, runs } = useActivityData();
8+
const { theme, toggleTheme } = useTheme();
9+
10+
return (
11+
<div className="h-screen flex flex-col bg-bg-base">
12+
<header className="flex items-center justify-between px-6 py-4 border-b border-border-default shrink-0">
13+
<div className="flex items-center gap-4">
14+
<a
15+
href="#/"
16+
className="text-text-secondary text-[0.82rem] no-underline hover:text-text-primary transition-colors duration-150"
17+
>
18+
&larr; Dashboard
19+
</a>
20+
<h1 className="font-display font-extrabold text-[1.2rem] tracking-tight m-0 text-text-primary">
21+
Activity &amp; Automation
22+
</h1>
23+
</div>
24+
<button
25+
className="px-2.5 py-1.5 bg-transparent border border-border-default text-text-secondary text-[0.78rem] rounded-sm hover:bg-bg-hover hover:text-text-primary transition-all duration-150"
26+
onClick={toggleTheme}
27+
>
28+
{theme === "dark" ? "Light" : "Dark"}
29+
</button>
30+
</header>
31+
<main className="flex-1 overflow-y-auto p-6">
32+
<div className="grid grid-cols-2 gap-5 max-md:grid-cols-1">
33+
<ActivityPanel activity={activity} selectedWorkItemId={null} />
34+
<AutomationPanel runs={runs} />
35+
</div>
36+
</main>
37+
</div>
38+
);
39+
}
40+
41+
export { ActivityPage };

apps/ops-harbor/src/client/pages/dashboard/model/dashboard.test.tsx

Lines changed: 8 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,7 @@ describe("useDashboardData", () => {
6767

6868
it("fetches initial data on mount", async () => {
6969
const workItems = [makeWorkItem()];
70-
mockFetchJson
71-
.mockResolvedValueOnce(workItems)
72-
.mockResolvedValueOnce([])
73-
.mockResolvedValueOnce([])
74-
.mockResolvedValueOnce([]);
70+
mockFetchJson.mockResolvedValueOnce(workItems).mockResolvedValueOnce([]);
7571

7672
const { result } = renderHook(() => useDashboardData());
7773

@@ -81,17 +77,11 @@ describe("useDashboardData", () => {
8177

8278
expect(result.current.workItems[0]?.id).toBe("wi-1");
8379
expect(result.current.alerts).toEqual([]);
84-
expect(result.current.activity).toEqual([]);
85-
expect(result.current.runs).toEqual([]);
8680
});
8781

8882
it("auto-selects the first work item", async () => {
8983
const workItems = [makeWorkItem({ id: "first" }), makeWorkItem({ id: "second" })];
90-
mockFetchJson
91-
.mockResolvedValueOnce(workItems)
92-
.mockResolvedValueOnce([])
93-
.mockResolvedValueOnce([])
94-
.mockResolvedValueOnce([]);
84+
mockFetchJson.mockResolvedValueOnce(workItems).mockResolvedValueOnce([]);
9585

9686
const { result } = renderHook(() => useDashboardData());
9787

@@ -104,11 +94,7 @@ describe("useDashboardData", () => {
10494

10595
it("allows manual selection of a work item", async () => {
10696
const workItems = [makeWorkItem({ id: "a" }), makeWorkItem({ id: "b" })];
107-
mockFetchJson
108-
.mockResolvedValueOnce(workItems)
109-
.mockResolvedValueOnce([])
110-
.mockResolvedValueOnce([])
111-
.mockResolvedValueOnce([]);
97+
mockFetchJson.mockResolvedValueOnce(workItems).mockResolvedValueOnce([]);
11298

11399
const { result } = renderHook(() => useDashboardData());
114100

@@ -135,24 +121,18 @@ describe("useDashboardData", () => {
135121

136122
it("syncNow calls POST /api/sync and refreshes", async () => {
137123
// Initial load
138-
mockFetchJson
139-
.mockResolvedValueOnce([makeWorkItem()])
140-
.mockResolvedValueOnce([])
141-
.mockResolvedValueOnce([])
142-
.mockResolvedValueOnce([]);
124+
mockFetchJson.mockResolvedValueOnce([makeWorkItem()]).mockResolvedValueOnce([]);
143125

144126
const { result } = renderHook(() => useDashboardData());
145127

146128
await waitFor(() => {
147129
expect(result.current.workItems).toHaveLength(1);
148130
});
149131

150-
// syncNow: POST + refresh (4 fetches)
132+
// syncNow: POST + refresh (2 fetches)
151133
mockFetchJson
152134
.mockResolvedValueOnce({ workItems: 5, events: 10 })
153135
.mockResolvedValueOnce([makeWorkItem(), makeWorkItem({ id: "wi-2", number: 43 })])
154-
.mockResolvedValueOnce([])
155-
.mockResolvedValueOnce([])
156136
.mockResolvedValueOnce([]);
157137

158138
await act(async () => {
@@ -165,11 +145,7 @@ describe("useDashboardData", () => {
165145

166146
it("syncNow sets error message on failure", async () => {
167147
// Initial load
168-
mockFetchJson
169-
.mockResolvedValueOnce([])
170-
.mockResolvedValueOnce([])
171-
.mockResolvedValueOnce([])
172-
.mockResolvedValueOnce([]);
148+
mockFetchJson.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
173149

174150
const { result } = renderHook(() => useDashboardData());
175151

@@ -188,11 +164,7 @@ describe("useDashboardData", () => {
188164

189165
it("selected falls back to first item when selectedId is null", async () => {
190166
const workItems = [makeWorkItem({ id: "only" })];
191-
mockFetchJson
192-
.mockResolvedValueOnce(workItems)
193-
.mockResolvedValueOnce([])
194-
.mockResolvedValueOnce([])
195-
.mockResolvedValueOnce([]);
167+
mockFetchJson.mockResolvedValueOnce(workItems).mockResolvedValueOnce([]);
196168

197169
const { result } = renderHook(() => useDashboardData());
198170

@@ -202,11 +174,7 @@ describe("useDashboardData", () => {
202174
});
203175

204176
it("selected is null when no work items exist", async () => {
205-
mockFetchJson
206-
.mockResolvedValueOnce([])
207-
.mockResolvedValueOnce([])
208-
.mockResolvedValueOnce([])
209-
.mockResolvedValueOnce([]);
177+
mockFetchJson.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
210178

211179
const { result } = renderHook(() => useDashboardData());
212180

apps/ops-harbor/src/client/pages/dashboard/model/dashboard.ts

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,10 @@
11
import { useEffect, useMemo, useState } from "react";
2-
import type { ActivityEvent, AlertSummaryRow, WorkItem } from "@repo/ops-harbor-core";
2+
import type { AlertSummaryRow, WorkItem } from "@repo/ops-harbor-core";
33
import { fetchJson } from "@/shared/lib";
44

5-
export type AutomationRun = {
6-
id: string;
7-
workItemId: string;
8-
repository: string;
9-
triggerType: string;
10-
status: "pending" | "running" | "completed" | "failed" | "canceled" | "leased";
11-
startedAt: string;
12-
finishedAt: string;
13-
summary: string;
14-
};
15-
165
export function useDashboardData() {
176
const [workItems, setWorkItems] = useState<WorkItem[]>([]);
187
const [alerts, setAlerts] = useState<AlertSummaryRow[]>([]);
19-
const [activity, setActivity] = useState<ActivityEvent[]>([]);
20-
const [runs, setRuns] = useState<AutomationRun[]>([]);
218
const [selectedId, setSelectedId] = useState<string | null>(null);
229
const [message, setMessage] = useState<string>("");
2310
const [loadError, setLoadError] = useState<string | null>(null);
@@ -28,16 +15,12 @@ export function useDashboardData() {
2815
);
2916

3017
async function refresh(): Promise<void> {
31-
const [workItemsData, alertsData, activityData, runsData] = await Promise.all([
18+
const [workItemsData, alertsData] = await Promise.all([
3219
fetchJson<WorkItem[]>("/api/work-items"),
3320
fetchJson<AlertSummaryRow[]>("/api/alerts"),
34-
fetchJson<ActivityEvent[]>("/api/activity?limit=200"),
35-
fetchJson<AutomationRun[]>("/api/automation-runs"),
3621
]);
3722
setWorkItems(workItemsData);
3823
setAlerts(alertsData);
39-
setActivity(activityData);
40-
setRuns(runsData);
4124
setLoadError(null);
4225
if (!selectedId && workItemsData[0]) {
4326
setSelectedId(workItemsData[0].id);
@@ -80,8 +63,6 @@ export function useDashboardData() {
8063
return {
8164
workItems,
8265
alerts,
83-
activity,
84-
runs,
8566
selected,
8667
selectedId,
8768
setSelectedId,

0 commit comments

Comments
 (0)