diff --git a/TASKS.md b/TASKS.md
index 2ca7024..bdf4da4 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -2,17 +2,17 @@
## Current
-### `feat/publish-host-config`
+### `feat/ui-ux-pass`
Status: completed
DoD:
-- публикация портов в `docker-compose` не завязана жестко на `127.0.0.1`
-- отдельный reverse proxy на другом хосте может достучаться до `ui`, `admin-api` и `mcp-server`
-- локальный сценарий с loopback тоже остается доступным через env-конфиг
-- deployment docs и `.env.example` синхронизированы с новой моделью
-- после фикса внешний reverse proxy перестает получать `502` из-за недоступного upstream
+- shell выглядит как цельный продуктовый интерфейс, а не как набор отдельных секций
+- operation list показывает обзор состояния системы, а не только список карточек
+- create page объясняет оператору различия между `REST`, `GraphQL` и `gRPC`
+- ключевые сценарии имеют пустые состояния и более понятную визуальную иерархию
+- UI build и tests остаются зелеными после UX-pass
## Next
diff --git a/apps/ui/src/pages/operation-create/page.tsx b/apps/ui/src/pages/operation-create/page.tsx
index 143e1a9..2a53e38 100644
--- a/apps/ui/src/pages/operation-create/page.tsx
+++ b/apps/ui/src/pages/operation-create/page.tsx
@@ -1,5 +1,26 @@
-import { PageSection } from "../../shared/ui/page-section";
import { OperationForm } from "../../features/operation-form/operation-form";
+import { PageSection } from "../../shared/ui/page-section";
+
+const protocolCards = [
+ {
+ protocol: "rest",
+ title: "REST",
+ description:
+ "One tool maps to one method and one path, with body, query and header mapping.",
+ },
+ {
+ protocol: "graphql",
+ title: "GraphQL",
+ description:
+ "One tool maps to one fixed query or mutation with stable variables and response shape.",
+ },
+ {
+ protocol: "grpc",
+ title: "gRPC",
+ description:
+ "One tool maps to one unary method backed by a descriptor-set contract.",
+ },
+] as const;
export function OperationCreatePage() {
return (
@@ -7,6 +28,23 @@ export function OperationCreatePage() {
+
+ {protocolCards.map((card) => (
+
+
+ {card.protocol}
+
+ {card.title}
+ {card.description}
+
+ ))}
+
+
+
+
diff --git a/apps/ui/src/pages/operation-list/page.tsx b/apps/ui/src/pages/operation-list/page.tsx
index be84039..461e7dc 100644
--- a/apps/ui/src/pages/operation-list/page.tsx
+++ b/apps/ui/src/pages/operation-list/page.tsx
@@ -28,6 +28,17 @@ export function OperationListPage() {
);
}, [deferredSearchTerm, operationsQuery.data?.items]);
+ const metrics = useMemo(() => {
+ const items = operationsQuery.data?.items ?? [];
+
+ return {
+ total: items.length,
+ published: items.filter((item) => item.status === "published").length,
+ drafts: items.filter((item) => item.status === "draft").length,
+ protocols: Array.from(new Set(items.map((item) => item.protocol))).length,
+ };
+ }, [operationsQuery.data?.items]);
+
return (
}
>
-
- Search
- setSearchTerm(event.target.value)}
- />
-
+
+
+
Catalog health
+
{metrics.total} tools registered
+
+ Published and draft operations share one operator surface and one
+ MCP publication path.
+
+
+
+
+
+ Published
+ {metrics.published}
+
+
+ Drafts
+ {metrics.drafts}
+
+
+ Protocols
+ {metrics.protocols}
+
+
+
+
- {operationsQuery.isLoading ? Loading operations...
: null}
+
+
+ Search
+ setSearchTerm(event.target.value)}
+ />
+
- {operationsQuery.data ? (
+
+ {filteredItems.length} visible
+
+ {operationsQuery.data?.items.length ?? 0} total
+
+
+
+
+ {operationsQuery.isLoading ? (
+
+
Loading operations
+
Fetching the current registry snapshot from the admin backend.
+
+ ) : null}
+
+ {operationsQuery.data && filteredItems.length === 0 ? (
+
+
No matching operations
+
+ Adjust the search term or create a new contract for REST, GraphQL
+ or unary gRPC.
+
+
+ Create operation
+
+
+ ) : null}
+
+ {operationsQuery.data && filteredItems.length > 0 ? (
{filteredItems.map((item) => (
@@ -63,8 +128,10 @@ export function OperationListPage() {
{item.status}
- {item.display_name}
- {item.name}
+
+
{item.display_name}
+
{item.name}
+
Draft
@@ -79,6 +146,10 @@ export function OperationListPage() {
{new Date(item.updated_at).toLocaleString()}
+
+ Open workspace
+ →
+
))}
diff --git a/apps/ui/src/shared/ui/app-shell.tsx b/apps/ui/src/shared/ui/app-shell.tsx
index f65fe24..fbd810a 100644
--- a/apps/ui/src/shared/ui/app-shell.tsx
+++ b/apps/ui/src/shared/ui/app-shell.tsx
@@ -1,11 +1,38 @@
-import { NavLink } from "react-router-dom";
+import { NavLink, useLocation } from "react-router-dom";
import { ReactNode } from "react";
type AppShellProps = {
children: ReactNode;
};
+function pageTitle(pathname: string) {
+ if (pathname.startsWith("/operations/new")) {
+ return {
+ title: "Create tool contract",
+ subtitle:
+ "Configure one fixed upstream contract and publish it as one MCP tool.",
+ };
+ }
+
+ if (pathname.startsWith("/operations/")) {
+ return {
+ title: "Operation workspace",
+ subtitle:
+ "Refine target metadata, validate mappings and publish the current draft.",
+ };
+ }
+
+ return {
+ title: "Operation catalog",
+ subtitle:
+ "Track draft and published tools across REST, GraphQL and unary gRPC.",
+ };
+}
+
export function AppShell({ children }: AppShellProps) {
+ const location = useLocation();
+ const currentPage = pageTitle(location.pathname);
+
return (
@@ -17,14 +44,17 @@ export function AppShell({ children }: AppShellProps) {
onboarding, draft generation and publish flow.
+
+ Workspace
isActive ? "nav-link nav-link-active" : "nav-link"
}
>
- Operations
+ Operations
+ Catalog, status, search and entry points.
- New Operation
+ New Operation
+ Create a protocol-specific contract and publishable tool.
+
+
+
+
Protocols
+
+ rest
+ graphql
+ grpc
+
+
+
+
+
Lifecycle
+
+ draft
+ published
+
+
+
- {children}
+
+
+
+
+ {children}
+
);
}
diff --git a/apps/ui/src/styles.css b/apps/ui/src/styles.css
index e8d5fd6..8da77c1 100644
--- a/apps/ui/src/styles.css
+++ b/apps/ui/src/styles.css
@@ -53,7 +53,9 @@ button {
.layout-sidebar {
padding: 32px 28px;
border-right: 1px solid rgba(30, 26, 23, 0.08);
- background: rgba(255, 250, 244, 0.78);
+ background:
+ linear-gradient(180deg, rgba(255, 248, 239, 0.94), rgba(246, 238, 228, 0.86)),
+ rgba(255, 250, 244, 0.78);
backdrop-filter: blur(18px);
}
@@ -61,6 +63,46 @@ button {
padding: 32px;
}
+.workspace-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 20px;
+ margin-bottom: 28px;
+}
+
+.workspace-header h2 {
+ margin: 4px 0 8px;
+ font-size: clamp(2rem, 4vw, 3rem);
+ line-height: 0.94;
+}
+
+.workspace-header p {
+ margin: 0;
+ max-width: 760px;
+ color: #62534b;
+}
+
+.workspace-badges {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.workspace-badge {
+ display: inline-flex;
+ align-items: center;
+ min-height: 34px;
+ padding: 0 14px;
+ border-radius: 999px;
+ border: 1px solid rgba(30, 26, 23, 0.08);
+ background: rgba(255, 254, 250, 0.78);
+ color: #5e514a;
+ font-size: 0.82rem;
+ font-weight: 600;
+}
+
.brand-block {
display: grid;
gap: 12px;
@@ -91,13 +133,33 @@ button {
gap: 12px;
}
+.nav-section-title {
+ margin: 0 0 4px;
+ color: #8d5e3d;
+ font-size: 0.76rem;
+ font-weight: 700;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+}
+
.nav-link {
+ display: grid;
+ gap: 4px;
padding: 14px 16px;
- border-radius: 18px;
+ border-radius: 20px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(30, 26, 23, 0.08);
}
+.nav-link strong {
+ font-size: 0.96rem;
+}
+
+.nav-link span {
+ font-size: 0.84rem;
+ color: #6a5b53;
+}
+
.nav-link-active {
background: linear-gradient(135deg, #0f7567, #19735d);
color: white;
@@ -105,6 +167,31 @@ button {
box-shadow: 0 20px 38px rgba(12, 84, 73, 0.18);
}
+.nav-link-active span {
+ color: rgba(255, 255, 255, 0.82);
+}
+
+.sidebar-meta {
+ margin-top: 28px;
+ display: grid;
+ gap: 14px;
+}
+
+.sidebar-card {
+ display: grid;
+ gap: 10px;
+ padding: 16px;
+ border-radius: 22px;
+ border: 1px solid rgba(30, 26, 23, 0.08);
+ background: rgba(255, 255, 255, 0.62);
+}
+
+.sidebar-pill-row {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
.page-stack {
display: grid;
gap: 24px;
@@ -239,6 +326,89 @@ button {
gap: 18px;
}
+.hero-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 16px;
+}
+
+.hero-card {
+ display: grid;
+ gap: 12px;
+ padding: 22px;
+ border-radius: 24px;
+ border: 1px solid rgba(30, 26, 23, 0.08);
+ background: rgba(255, 252, 246, 0.92);
+}
+
+.hero-card-accent {
+ background:
+ radial-gradient(circle at top right, rgba(15, 117, 103, 0.12), transparent 34%),
+ linear-gradient(180deg, rgba(248, 242, 233, 0.98), rgba(255, 252, 246, 0.92));
+}
+
+.hero-card h3 {
+ margin: 0;
+ font-size: 1.3rem;
+}
+
+.hero-metrics {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.hero-metrics div {
+ display: grid;
+ gap: 6px;
+}
+
+.hero-metrics span {
+ color: #71625b;
+ font-size: 0.82rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.hero-metrics strong {
+ font-size: 1.7rem;
+ line-height: 1;
+}
+
+.toolbar-row {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+
+.toolbar-meta {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.empty-state {
+ display: grid;
+ gap: 12px;
+ justify-items: start;
+ padding: 26px;
+ border-radius: 24px;
+ border: 1px dashed rgba(30, 26, 23, 0.18);
+ background: rgba(253, 249, 243, 0.74);
+}
+
+.empty-state h3 {
+ margin: 0;
+ font-size: 1.15rem;
+}
+
+.empty-state p {
+ margin: 0;
+ color: #65574f;
+}
+
.operation-card,
.summary-card,
.feedback-card,
@@ -273,6 +443,11 @@ button {
font-size: 1.15rem;
}
+.operation-card-header {
+ display: grid;
+ gap: 6px;
+}
+
.operation-name {
margin: 0;
color: #65574f;
@@ -280,6 +455,15 @@ button {
font-size: 0.9rem;
}
+.card-link-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: 4px;
+ color: #0f7567;
+ font-weight: 700;
+}
+
.protocol-pill,
.status-pill {
display: inline-flex;
@@ -527,6 +711,10 @@ pre {
border-right: none;
border-bottom: 1px solid rgba(30, 26, 23, 0.08);
}
+
+ .workspace-header {
+ flex-direction: column;
+ }
}
@media (max-width: 820px) {
@@ -548,4 +736,8 @@ pre {
.section-header {
flex-direction: column;
}
+
+ .hero-metrics {
+ grid-template-columns: 1fr;
+ }
}