feat(web): build read-only operator views
This commit is contained in:
@@ -24,9 +24,12 @@
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.1"
|
||||
}
|
||||
|
||||
+153
-56
@@ -6,24 +6,65 @@ import {
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
useRouterState,
|
||||
} from '@tanstack/react-router';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
import { cn } from './cadence-ui/lib/cn';
|
||||
import {
|
||||
BlockedQueuePage,
|
||||
RunDetailPage,
|
||||
RunsPage,
|
||||
ThreadTimelinePage,
|
||||
} from './features/operator-console';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5_000,
|
||||
refetchOnWindowFocus: true,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: RootLayout,
|
||||
});
|
||||
|
||||
const indexRoute = createRoute({
|
||||
const runsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: HomePage,
|
||||
component: RunsPage,
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([indexRoute]);
|
||||
const blockedQueueRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/blocked',
|
||||
component: BlockedQueuePage,
|
||||
});
|
||||
|
||||
const runDetailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/runs/$runId',
|
||||
component: RunDetailRoute,
|
||||
});
|
||||
|
||||
const threadTimelineRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/threads/$threadId',
|
||||
component: ThreadTimelineRoute,
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
runsRoute,
|
||||
blockedQueueRoute,
|
||||
runDetailRoute,
|
||||
threadTimelineRoute,
|
||||
]);
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: 'intent',
|
||||
});
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -42,63 +83,119 @@ export function App() {
|
||||
|
||||
function RootLayout() {
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="masthead">
|
||||
<div>
|
||||
<p className="eyebrow">AI Workflow Skill</p>
|
||||
<Link className="brand" to="/">
|
||||
Orch Control Plane
|
||||
</Link>
|
||||
<div className="relative min-h-screen overflow-hidden" data-theme="brand">
|
||||
<div className="pointer-events-none fixed inset-0">
|
||||
<div className="absolute inset-x-0 top-0 h-[38rem] bg-[radial-gradient(circle_at_top,oklch(0.9_0.06_174/.72),transparent_60%)]" />
|
||||
<div className="absolute inset-y-0 right-0 w-[32rem] bg-[radial-gradient(circle_at_center,oklch(0.82_0.14_88/.18),transparent_56%)]" />
|
||||
<div className="absolute inset-0 opacity-35 [background-image:linear-gradient(to_right,color-mix(in_oklch,var(--color-border)_46%,transparent)_1px,transparent_1px),linear-gradient(to_bottom,color-mix(in_oklch,var(--color-border)_46%,transparent)_1px,transparent_1px)] [background-size:72px_72px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto flex min-h-screen w-full max-w-[1500px] flex-col gap-6 px-4 py-4 lg:flex-row lg:px-6 lg:py-6">
|
||||
<aside className="hidden lg:flex lg:w-[18rem] lg:shrink-0">
|
||||
<div className="flex h-[calc(100vh-3rem)] w-full flex-col rounded-[2rem] border border-[color-mix(in_oklch,var(--color-border)_80%,white)] bg-[color-mix(in_oklch,var(--color-card)_84%,white_16%)] p-5 shadow-[var(--shadow-md)] backdrop-blur">
|
||||
<div className="space-y-4">
|
||||
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.28em] text-[var(--color-primary)]">
|
||||
AI Workflow Skill
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Link
|
||||
className="type-display text-[2.4rem] leading-[0.92] tracking-[-0.05em] text-[var(--color-foreground)]"
|
||||
to="/"
|
||||
>
|
||||
Orch Control Plane
|
||||
</Link>
|
||||
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
|
||||
Read-only operator surfaces for runs, blocked work, and thread history.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="mt-8 grid gap-2">
|
||||
<NavLink exact label="Runs overview" to="/" />
|
||||
<NavLink label="Blocked queue" to="/blocked" />
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto rounded-[1.5rem] border border-[color-mix(in_oklch,var(--color-border)_78%,transparent)] bg-[color-mix(in_oklch,var(--color-surface)_82%,white_18%)] p-4">
|
||||
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.24em] text-[var(--color-muted-foreground)]">
|
||||
Current slice
|
||||
</p>
|
||||
<ul className="mt-3 grid gap-2 text-sm leading-6 text-[var(--color-foreground)]">
|
||||
<li>Runs list with health counts</li>
|
||||
<li>Run detail with task board</li>
|
||||
<li>Global blocked queue</li>
|
||||
<li>Thread timeline and artifacts</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-4">
|
||||
<header className="rounded-[1.75rem] border border-[color-mix(in_oklch,var(--color-border)_80%,white)] bg-[color-mix(in_oklch,var(--color-card)_84%,white_16%)] px-5 py-4 shadow-[var(--shadow-sm)] backdrop-blur lg:hidden">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.28em] text-[var(--color-primary)]">
|
||||
AI Workflow Skill
|
||||
</p>
|
||||
<Link
|
||||
className="type-display block text-[2rem] leading-[0.94] tracking-[-0.05em] text-[var(--color-foreground)]"
|
||||
to="/"
|
||||
>
|
||||
Orch Control Plane
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<NavLink exact label="Runs overview" to="/" />
|
||||
<NavLink label="Blocked queue" to="/blocked" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="min-w-0 flex-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
<p className="masthead-copy">
|
||||
Phase 1 keeps the UI thin while the backend contract settles around
|
||||
`orchd`.
|
||||
</p>
|
||||
</header>
|
||||
<main className="content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HomePage() {
|
||||
function NavLink({
|
||||
exact = false,
|
||||
label,
|
||||
to,
|
||||
}: {
|
||||
exact?: boolean;
|
||||
label: string;
|
||||
to: string;
|
||||
}) {
|
||||
const pathname = useRouterState({
|
||||
select: (state) => state.location.pathname,
|
||||
});
|
||||
const isActive = exact ? pathname === to : pathname === to || pathname.startsWith(`${to}/`);
|
||||
|
||||
return (
|
||||
<section className="hero-grid">
|
||||
<article className="hero-card hero-card-primary">
|
||||
<p className="eyebrow">Current slice</p>
|
||||
<h1>Read-only operator shell for a future multi-user web product.</h1>
|
||||
<p className="lede">
|
||||
The monorepo now has a dedicated React app, a Go HTTP service, and a
|
||||
first API contract for runs, blocked work, and thread history.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article className="hero-card hero-card-secondary">
|
||||
<h2>Backend spine</h2>
|
||||
<ul className="detail-list">
|
||||
<li>`cmd/orchd` serves `chi` routes against the existing SQLite state.</li>
|
||||
<li>`internal/query` shapes run, blocked-task, and thread reads.</li>
|
||||
<li>`api/openapi.yaml` is the contract anchor for future typed clients.</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="hero-card">
|
||||
<h2>Frontend posture</h2>
|
||||
<p>
|
||||
React, Vite, TanStack Router, and TanStack Query are present now so
|
||||
Phase 2 can focus on actual operator views instead of build plumbing.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article className="hero-card">
|
||||
<h2>Next UI targets</h2>
|
||||
<ul className="detail-list">
|
||||
<li>Runs dashboard with task-count health signals.</li>
|
||||
<li>Run detail board grouped by orchestration state.</li>
|
||||
<li>Blocked queue and thread timeline wired to live refresh.</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
<Link
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-full border px-3.5 py-2 text-sm font-medium transition-colors',
|
||||
'border-[color-mix(in_oklch,var(--color-border)_76%,transparent)] bg-transparent text-[var(--color-muted-foreground)]',
|
||||
'hover:border-[var(--color-border-strong)] hover:bg-[color-mix(in_oklch,var(--color-surface)_78%,white_22%)] hover:text-[var(--color-foreground)]',
|
||||
isActive &&
|
||||
'border-[color-mix(in_oklch,var(--color-primary)_32%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-primary)_12%,white)] text-[var(--color-primary)]',
|
||||
)}
|
||||
to={to}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current" />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function RunDetailRoute() {
|
||||
const { runId } = runDetailRoute.useParams();
|
||||
return <RunDetailPage runId={runId} />;
|
||||
}
|
||||
|
||||
function ThreadTimelineRoute() {
|
||||
const { threadId } = threadTimelineRoute.useParams();
|
||||
return <ThreadTimelinePage threadId={threadId} />;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,198 @@
|
||||
export type Run = {
|
||||
run_id: string;
|
||||
goal: string;
|
||||
summary: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type RunListItem = {
|
||||
run: Run;
|
||||
task_counts: Record<string, number>;
|
||||
total_tasks: number;
|
||||
};
|
||||
|
||||
export type Task = {
|
||||
run_id: string;
|
||||
task_id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
status: string;
|
||||
default_to?: string;
|
||||
priority: string;
|
||||
acceptance_json: unknown;
|
||||
latest_attempt_no?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type TaskAttempt = {
|
||||
run_id: string;
|
||||
task_id: string;
|
||||
attempt_no: number;
|
||||
assigned_to: string;
|
||||
thread_id: string;
|
||||
base_ref?: string;
|
||||
base_commit?: string;
|
||||
branch_name?: string;
|
||||
worktree_path?: string;
|
||||
workspace_status?: string;
|
||||
result_commit?: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type Artifact = {
|
||||
artifact_id: string;
|
||||
message_id: string;
|
||||
path: string;
|
||||
kind: string;
|
||||
metadata_json: unknown;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Message = {
|
||||
message_id: string;
|
||||
thread_id: string;
|
||||
from_agent: string;
|
||||
to_agent: string;
|
||||
kind: string;
|
||||
summary: string;
|
||||
body: string;
|
||||
payload_json: unknown;
|
||||
created_at: string;
|
||||
artifacts: Artifact[];
|
||||
};
|
||||
|
||||
export type Thread = {
|
||||
thread_id: string;
|
||||
run_id: string;
|
||||
task_id: string;
|
||||
subject: string;
|
||||
created_by: string;
|
||||
assigned_to: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
latest_message_id?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ThreadDetail = {
|
||||
thread: Thread;
|
||||
messages: Message[];
|
||||
};
|
||||
|
||||
export type BlockedTask = {
|
||||
task: Task;
|
||||
attempt: TaskAttempt;
|
||||
question: Message;
|
||||
};
|
||||
|
||||
export type BlockedQueueItem = BlockedTask & {
|
||||
run: Run;
|
||||
};
|
||||
|
||||
export type RunDetail = {
|
||||
run: Run;
|
||||
task_counts: Record<string, number>;
|
||||
total_tasks: number;
|
||||
tasks: Task[];
|
||||
blocked_tasks: BlockedTask[];
|
||||
};
|
||||
|
||||
type ErrorEnvelope = {
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
class ApiError extends Error {
|
||||
code?: string;
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number, code?: string) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
const apiBaseUrl = (import.meta.env.VITE_ORCH_API_BASE_URL ?? '').replace(/\/$/, '');
|
||||
|
||||
async function request<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `Request failed with status ${response.status}`;
|
||||
let code: string | undefined;
|
||||
|
||||
try {
|
||||
const payload = (await response.json()) as ErrorEnvelope;
|
||||
message = payload.error?.message ?? message;
|
||||
code = payload.error?.code;
|
||||
} catch {
|
||||
// Ignore malformed error payloads and fall back to the HTTP status.
|
||||
}
|
||||
|
||||
throw new ApiError(message, response.status, code);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function listRuns() {
|
||||
const payload = await request<{ runs: RunListItem[] }>('/api/runs');
|
||||
return payload.runs;
|
||||
}
|
||||
|
||||
export async function getRunDetail(runId: string) {
|
||||
const payload = await request<{ run: RunDetail }>(`/api/runs/${encodeURIComponent(runId)}`);
|
||||
return payload.run;
|
||||
}
|
||||
|
||||
export async function listRunBlocked(runId: string) {
|
||||
const payload = await request<{ blocked: BlockedTask[] }>(
|
||||
`/api/runs/${encodeURIComponent(runId)}/blocked`,
|
||||
);
|
||||
return payload.blocked;
|
||||
}
|
||||
|
||||
export async function getThreadDetail(threadId: string) {
|
||||
const payload = await request<{ thread: ThreadDetail }>(
|
||||
`/api/threads/${encodeURIComponent(threadId)}`,
|
||||
);
|
||||
return {
|
||||
...payload.thread,
|
||||
messages: payload.thread.messages.map((message) => ({
|
||||
...message,
|
||||
artifacts: message.artifacts ?? [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listBlockedQueue() {
|
||||
const runs = await listRuns();
|
||||
const blockedByRun = await Promise.all(
|
||||
runs.map(async (item) => {
|
||||
const blocked = await listRunBlocked(item.run.run_id);
|
||||
return blocked.map((entry) => ({
|
||||
...entry,
|
||||
run: item.run,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
return blockedByRun
|
||||
.flat()
|
||||
.sort((left, right) => Date.parse(right.question.created_at) - Date.parse(left.question.created_at));
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export function formatDateTime(value: string) {
|
||||
const parsed = new Date(value);
|
||||
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(parsed);
|
||||
}
|
||||
|
||||
export function statusLabel(value: string) {
|
||||
return value.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
export function pluralize(count: number, singular: string) {
|
||||
return `${count} ${count === 1 ? singular : `${singular}s`}`;
|
||||
}
|
||||
|
||||
export function hasStructuredData(value: unknown): boolean {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value.trim().length > 0 && value.trim() !== '{}' && value.trim() !== '[]';
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return Object.keys(value as Record<string, unknown>).length > 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function formatJson(value: unknown) {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(trimmed), null, 2);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function uniqueCount<T>(items: T[], select: (item: T) => string | undefined) {
|
||||
const values = items
|
||||
.map(select)
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
return new Set(values).size;
|
||||
}
|
||||
+15
-133
@@ -1,33 +1,25 @@
|
||||
@import "tailwindcss";
|
||||
@source "./**/*.{ts,tsx}";
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 196, 98, 0.32), transparent 28%),
|
||||
radial-gradient(circle at bottom right, rgba(8, 145, 178, 0.16), transparent 26%),
|
||||
linear-gradient(135deg, #f4efe3 0%, #f9f7f1 55%, #eef3f4 100%);
|
||||
color: #172026;
|
||||
--border: rgba(23, 32, 38, 0.12);
|
||||
--panel: rgba(255, 255, 255, 0.82);
|
||||
--panel-strong: rgba(255, 255, 255, 0.92);
|
||||
--ink-soft: rgba(23, 32, 38, 0.7);
|
||||
--accent: #c96f20;
|
||||
--accent-deep: #0f766e;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
html {
|
||||
min-height: 100%;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top, oklch(0.92 0.06 175 / 0.86), transparent 35%),
|
||||
radial-gradient(circle at right center, oklch(0.84 0.13 88 / 0.22), transparent 30%),
|
||||
linear-gradient(135deg, oklch(0.975 0.012 172) 0%, oklch(0.962 0.015 164) 45%, oklch(0.988 0.006 92) 100%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -35,120 +27,10 @@ a {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
padding: 32px 20px 48px;
|
||||
.type-display {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.masthead {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
align-items: end;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto 28px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-block;
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", serif;
|
||||
font-size: clamp(2rem, 4vw, 3.2rem);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.masthead-copy {
|
||||
max-width: 34rem;
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
grid-column: span 12;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 24px;
|
||||
background: var(--panel);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 20px 50px rgba(23, 32, 38, 0.08);
|
||||
}
|
||||
|
||||
.hero-card-primary {
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.92), rgba(255, 248, 237, 0.94));
|
||||
}
|
||||
|
||||
.hero-card-secondary {
|
||||
background:
|
||||
linear-gradient(145deg, rgba(240, 249, 255, 0.95), rgba(240, 253, 250, 0.9));
|
||||
}
|
||||
|
||||
.hero-card h1,
|
||||
.hero-card h2 {
|
||||
margin: 0 0 12px;
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", serif;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.hero-card h1 {
|
||||
font-size: clamp(2rem, 4.4vw, 4.4rem);
|
||||
line-height: 0.94;
|
||||
max-width: 12ch;
|
||||
}
|
||||
|
||||
.hero-card h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.lede,
|
||||
.hero-card p {
|
||||
margin: 0;
|
||||
max-width: 48rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.detail-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.detail-list li + li {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@media (min-width: 840px) {
|
||||
.masthead {
|
||||
grid-template-columns: 1.2fr 0.8fr;
|
||||
}
|
||||
|
||||
.hero-card-primary {
|
||||
grid-column: span 7;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.hero-card-secondary {
|
||||
grid-column: span 5;
|
||||
}
|
||||
|
||||
.hero-card:not(.hero-card-primary):not(.hero-card-secondary) {
|
||||
grid-column: span 6;
|
||||
}
|
||||
.type-mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
Vendored
+8
@@ -1 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_ORCH_API_BASE_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
const proxyTarget = process.env.ORCHD_PROXY_TARGET ?? 'http://127.0.0.1:8080';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [tailwindcss(), react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080',
|
||||
'/health': 'http://localhost:8080',
|
||||
'/api': proxyTarget,
|
||||
'/health': proxyTarget,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user