Просмотр исходного кода

style(studio): Midnight Manuscript redesign

Complete visual identity overhaul:
- Instrument Serif for literary headings, DM Sans for UI, JetBrains Mono for data
- Warm amber/gold palette (oklch) instead of cold blue — feels like lamplight
- Paper grain texture overlay (SVG noise filter, 3% opacity)
- Parchment light theme + midnight study dark theme
- Serif italic logo "InkOS" with typographic identity
- Empty state: ✦ ornament + serif italic heading
- Writing progress: amber glow panel with gradient pulse bar
- Book cards: warm border hover, uppercase tracking genre labels
- Status as text ("CONNECTED" uppercase) not green dot
- Moon/sun theme toggle as circular button
- Reduced border-radius (0.375rem) for sharper, editorial feel
Ma 7 месяцев назад
Родитель
Сommit
7da9a1d544

+ 46 - 40
packages/studio/src/App.tsx

@@ -25,7 +25,6 @@ export function App() {
 
   const isDark = theme === "dark";
 
-  // Toggle dark class on root for CSS variable switching
   useEffect(() => {
     document.documentElement.classList.toggle("dark", isDark);
   }, [isDark]);
@@ -42,49 +41,56 @@ export function App() {
 
   return (
     <div className="min-h-screen bg-background text-foreground">
-      <header className="sticky top-0 z-10 backdrop-blur-sm border-b border-border bg-background/80 px-6 py-3 flex items-center justify-between">
-        <div className="flex items-center gap-6">
-          <button
-            onClick={nav.toDashboard}
-            className="text-lg font-bold tracking-tight hover:opacity-80 transition-opacity"
-          >
-            <span className={isDark ? "text-blue-400" : "text-blue-600"}>Ink</span>OS Studio
-          </button>
-          <nav className="flex gap-1 text-sm text-muted-foreground">
-            {([
-              { label: t("nav.books"), action: nav.toDashboard, active: route.page === "dashboard" },
-              { label: t("nav.newBook"), action: nav.toBookCreate, active: route.page === "book-create" },
-              { label: t("nav.config"), action: nav.toConfig, active: route.page === "config" },
-            ] as const).map((item) => (
-              <button
-                key={item.label}
-                onClick={item.action}
-                className={`px-3 py-2.5 rounded-md transition-colors ${
-                  item.active
-                    ? "bg-secondary text-foreground"
-                    : "hover:bg-secondary/50 hover:text-foreground"
-                }`}
-              >
-                {item.label}
-              </button>
-            ))}
-          </nav>
-        </div>
-        <div className="flex items-center gap-3">
-          <button
-            onClick={() => setTheme(isDark ? "light" : "dark")}
-            className="px-3 py-2 rounded-md text-xs font-medium bg-secondary text-muted-foreground hover:text-foreground transition-colors"
-          >
-            {isDark ? "☀️" : "🌙"}
-          </button>
-          <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
-            <span className={`inline-block w-1.5 h-1.5 rounded-full ${sse.connected ? "bg-emerald-500" : "bg-zinc-400"}`} />
-            {sse.connected ? t("nav.connected") : t("nav.disconnected")}
+      {/* Header — thin, literary, warm */}
+      <header className="sticky top-0 z-10 border-b border-border/60 bg-background/90 backdrop-blur-md">
+        <div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
+          <div className="flex items-center gap-8">
+            <button
+              onClick={nav.toDashboard}
+              className="group flex items-baseline gap-0.5 hover:opacity-80 transition-opacity"
+            >
+              <span className="font-serif text-xl italic text-primary">Ink</span>
+              <span className="text-lg font-medium tracking-tight">OS</span>
+            </button>
+
+            <nav className="flex gap-1 text-[13px] text-muted-foreground">
+              {([
+                { label: t("nav.books"), action: nav.toDashboard, active: route.page === "dashboard" },
+                { label: t("nav.newBook"), action: nav.toBookCreate, active: route.page === "book-create" },
+                { label: t("nav.config"), action: nav.toConfig, active: route.page === "config" },
+              ] as const).map((item) => (
+                <button
+                  key={item.label}
+                  onClick={item.action}
+                  className={`px-3 py-2 rounded-md transition-all duration-200 ${
+                    item.active
+                      ? "text-foreground bg-secondary"
+                      : "hover:text-foreground hover:bg-secondary/50"
+                  }`}
+                >
+                  {item.label}
+                </button>
+              ))}
+            </nav>
+          </div>
+
+          <div className="flex items-center gap-4">
+            <button
+              onClick={() => setTheme(isDark ? "light" : "dark")}
+              className="w-8 h-8 flex items-center justify-center rounded-full bg-secondary text-muted-foreground hover:text-foreground transition-colors text-xs"
+            >
+              {isDark ? "☀" : "☽"}
+            </button>
+            {sse.connected && (
+              <span className="text-[11px] text-muted-foreground/60 tracking-wide uppercase">
+                {t("nav.connected")}
+              </span>
+            )}
           </div>
         </div>
       </header>
 
-      <main className="max-w-5xl mx-auto px-6 py-8">
+      <main className="max-w-5xl mx-auto px-6 py-10">
         {route.page === "dashboard" && <Dashboard nav={nav} sse={sse} theme={theme} t={t} />}
         {route.page === "book" && <BookDetail bookId={route.bookId} nav={nav} theme={theme} t={t} />}
         {route.page === "book-create" && <BookCreate nav={nav} theme={theme} t={t} />}

+ 14 - 16
packages/studio/src/hooks/use-colors.ts

@@ -1,26 +1,24 @@
 import type { Theme } from "./use-theme";
 
-// Semantic color tokens — now uses CSS variables via shadcn theme
-// No more light/dark branching — handled by .dark class on <html>
 export function useColors(_theme: Theme) {
   return {
-    card: "border-border hover:border-ring/40 transition-colors",
-    cardStatic: "border-border",
+    card: "border-border/60 hover:border-primary/30 transition-all duration-200",
+    cardStatic: "border-border/60",
     surface: "bg-card",
     muted: "text-muted-foreground",
     subtle: "text-muted-foreground",
-    link: "hover:text-foreground transition-colors cursor-pointer",
-    input: "bg-background border border-input text-foreground focus:border-ring focus:ring-1 focus:ring-ring/30 transition-colors",
-    btnPrimary: "bg-primary text-primary-foreground hover:bg-primary/90 active:bg-primary/80 transition-colors",
-    btnSecondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 active:bg-secondary/70 transition-colors",
-    btnSuccess: "bg-emerald-600 text-white hover:bg-emerald-700 active:bg-emerald-800 dark:bg-emerald-700 dark:hover:bg-emerald-600 transition-colors",
-    btnDanger: "bg-destructive text-destructive-foreground hover:bg-destructive/90 active:bg-destructive/80 transition-colors",
-    tableHeader: "bg-muted text-muted-foreground",
-    tableDivide: "divide-border",
-    tableHover: "hover:bg-muted/50 transition-colors",
-    error: "border-destructive/50 bg-destructive/10 text-destructive",
-    info: "border-ring/50 bg-primary/10 text-primary",
-    code: "bg-muted text-muted-foreground",
+    link: "hover:text-primary transition-colors cursor-pointer",
+    input: "bg-input/30 border border-border text-foreground focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all duration-200",
+    btnPrimary: "bg-primary text-primary-foreground hover:opacity-90 active:opacity-80 transition-opacity",
+    btnSecondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 transition-colors",
+    btnSuccess: "bg-emerald-600 text-white hover:bg-emerald-700 dark:bg-emerald-700 dark:hover:bg-emerald-600 transition-colors",
+    btnDanger: "bg-destructive text-destructive-foreground hover:opacity-90 transition-opacity",
+    tableHeader: "bg-muted/50 text-muted-foreground text-xs uppercase tracking-wider",
+    tableDivide: "divide-border/60",
+    tableHover: "hover:bg-muted/30 transition-colors",
+    error: "border-destructive/30 bg-destructive/5 text-destructive",
+    info: "border-primary/20 bg-primary/5 text-primary",
+    code: "bg-muted text-muted-foreground font-mono text-sm",
     active: "text-emerald-500",
     paused: "text-amber-500",
     mono: "font-mono text-sm",

+ 69 - 39
packages/studio/src/index.css

@@ -1,5 +1,7 @@
 @import "tailwindcss";
-@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
+
+/* Instrument Serif for literary headings + DM Sans for clean UI */
+@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=JetBrains+Mono:wght@400;500&display=swap');
 
 @theme inline {
   --color-background: var(--background);
@@ -21,61 +23,89 @@
   --color-border: var(--border);
   --color-input: var(--input);
   --color-ring: var(--ring);
+  --color-amber: var(--amber);
+  --color-amber-foreground: var(--amber-foreground);
   --radius-sm: calc(var(--radius) - 4px);
   --radius-md: calc(var(--radius) - 2px);
   --radius-lg: var(--radius);
   --radius-xl: calc(var(--radius) + 4px);
+  --font-serif: 'Instrument Serif', Georgia, serif;
+  --font-sans: 'DM Sans', system-ui, sans-serif;
+  --font-mono: 'JetBrains Mono', monospace;
 }
 
+/* ── Light: warm parchment ── */
 :root {
-  --radius: 0.5rem;
-  --background: oklch(0.98 0 0);
-  --foreground: oklch(0.14 0.005 285);
-  --card: oklch(1 0 0);
-  --card-foreground: oklch(0.14 0.005 285);
-  --popover: oklch(1 0 0);
-  --popover-foreground: oklch(0.14 0.005 285);
-  --primary: oklch(0.55 0.2 260);
-  --primary-foreground: oklch(0.98 0 0);
-  --secondary: oklch(0.94 0.005 285);
-  --secondary-foreground: oklch(0.2 0.01 285);
-  --muted: oklch(0.94 0.005 285);
-  --muted-foreground: oklch(0.45 0.01 285);
-  --accent: oklch(0.94 0.005 285);
-  --accent-foreground: oklch(0.2 0.01 285);
-  --destructive: oklch(0.55 0.2 27);
+  --radius: 0.375rem;
+  --background: oklch(0.97 0.008 80);
+  --foreground: oklch(0.18 0.015 55);
+  --card: oklch(0.99 0.005 80);
+  --card-foreground: oklch(0.18 0.015 55);
+  --popover: oklch(0.99 0.005 80);
+  --popover-foreground: oklch(0.18 0.015 55);
+  --primary: oklch(0.62 0.14 55);
+  --primary-foreground: oklch(0.99 0.005 80);
+  --secondary: oklch(0.93 0.01 80);
+  --secondary-foreground: oklch(0.25 0.02 55);
+  --muted: oklch(0.93 0.008 80);
+  --muted-foreground: oklch(0.50 0.02 55);
+  --accent: oklch(0.93 0.012 55);
+  --accent-foreground: oklch(0.25 0.02 55);
+  --destructive: oklch(0.55 0.2 25);
   --destructive-foreground: oklch(0.98 0 0);
-  --border: oklch(0.88 0.005 285);
-  --input: oklch(0.88 0.005 285);
-  --ring: oklch(0.55 0.2 260);
+  --border: oklch(0.88 0.012 75);
+  --input: oklch(0.88 0.012 75);
+  --ring: oklch(0.62 0.14 55);
+  --amber: oklch(0.75 0.15 70);
+  --amber-foreground: oklch(0.25 0.05 55);
 }
 
+/* ── Dark: midnight study ── */
 .dark {
-  --background: oklch(0.12 0.005 285);
-  --foreground: oklch(0.92 0.005 285);
-  --card: oklch(0.16 0.008 285);
-  --card-foreground: oklch(0.92 0.005 285);
-  --popover: oklch(0.16 0.008 285);
-  --popover-foreground: oklch(0.92 0.005 285);
-  --primary: oklch(0.65 0.22 260);
-  --primary-foreground: oklch(0.98 0 0);
-  --secondary: oklch(0.24 0.012 285);
-  --secondary-foreground: oklch(0.92 0.005 285);
-  --muted: oklch(0.24 0.012 285);
-  --muted-foreground: oklch(0.58 0.01 285);
-  --accent: oklch(0.22 0.01 285);
-  --accent-foreground: oklch(0.9 0.005 285);
-  --destructive: oklch(0.55 0.2 27);
+  --background: oklch(0.13 0.012 55);
+  --foreground: oklch(0.88 0.02 75);
+  --card: oklch(0.17 0.015 55);
+  --card-foreground: oklch(0.88 0.02 75);
+  --popover: oklch(0.17 0.015 55);
+  --popover-foreground: oklch(0.88 0.02 75);
+  --primary: oklch(0.72 0.15 65);
+  --primary-foreground: oklch(0.13 0.012 55);
+  --secondary: oklch(0.22 0.018 55);
+  --secondary-foreground: oklch(0.82 0.025 75);
+  --muted: oklch(0.22 0.015 55);
+  --muted-foreground: oklch(0.55 0.02 70);
+  --accent: oklch(0.25 0.02 55);
+  --accent-foreground: oklch(0.85 0.025 75);
+  --destructive: oklch(0.60 0.2 25);
   --destructive-foreground: oklch(0.98 0 0);
-  --border: oklch(0.28 0.01 285);
-  --input: oklch(0.28 0.01 285);
-  --ring: oklch(0.6 0.2 260);
+  --border: oklch(0.28 0.02 55);
+  --input: oklch(0.25 0.018 55);
+  --ring: oklch(0.72 0.15 65);
+  --amber: oklch(0.72 0.15 65);
+  --amber-foreground: oklch(0.13 0.012 55);
 }
 
 body {
-  font-family: 'Inter', system-ui, -apple-system, sans-serif;
+  font-family: var(--font-sans);
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
   background-color: var(--background);
   color: var(--foreground);
 }
+
+/* Subtle grain texture */
+body::before {
+  content: '';
+  position: fixed;
+  inset: 0;
+  pointer-events: none;
+  z-index: 50;
+  opacity: 0.03;
+  background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
+}
+
+/* Serif headings */
+h1, h2, h3 {
+  font-family: var(--font-serif);
+  letter-spacing: -0.01em;
+}

+ 72 - 44
packages/studio/src/pages/Dashboard.tsx

@@ -26,7 +26,6 @@ export function Dashboard({ nav, sse, theme, t }: { nav: Nav; sse: { messages: R
   const { data, loading, error, refetch } = useApi<{ books: ReadonlyArray<BookSummary> }>("/books");
   const [writingBooks, setWritingBooks] = useState<Set<string>>(new Set());
 
-  const recentEvents = sse.messages.filter((m) => m.event !== "ping" && m.event !== "llm:progress");
   const logEvents = sse.messages.filter((m) => m.event === "log").slice(-8);
   const progressEvent = sse.messages.filter((m) => m.event === "llm:progress").slice(-1)[0];
 
@@ -45,98 +44,127 @@ export function Dashboard({ nav, sse, theme, t }: { nav: Nav; sse: { messages: R
     }
   }, [sse.messages.length]);
 
-  if (loading) return <div className={c.muted}>Loading...</div>;
-  if (error) return <div className="text-red-400">Error: {error}</div>;
+  if (loading) return <div className="text-muted-foreground py-20 text-center text-sm">Loading...</div>;
+  if (error) return <div className="text-destructive py-20 text-center">Error: {error}</div>;
+
+  /* ── Empty state ── */
   if (!data?.books.length) {
     return (
-      <div className={`text-center py-24 ${c.muted}`}>
-        <p className="text-2xl mb-3 font-medium text-foreground/60">{t("dash.noBooks")}</p>
-        <p className="text-sm mb-6">{t("dash.createFirst")}</p>
-        <button onClick={nav.toBookCreate} className={`px-6 py-3 rounded-lg text-sm font-medium ${c.btnPrimary}`}>
+      <div className="flex flex-col items-center justify-center py-32">
+        <div className="text-6xl mb-6 opacity-20">✦</div>
+        <h2 className="font-serif text-3xl italic text-foreground/50 mb-3">{t("dash.noBooks")}</h2>
+        <p className="text-sm text-muted-foreground mb-8">{t("dash.createFirst")}</p>
+        <button
+          onClick={nav.toBookCreate}
+          className="px-6 py-3 rounded-md text-sm font-medium bg-primary text-primary-foreground hover:opacity-90 transition-opacity"
+        >
           {t("nav.newBook")}
         </button>
       </div>
     );
   }
 
+  /* ── Book list ── */
   return (
-    <div className="space-y-8">
-      <div className="flex items-center justify-between">
-        <h1 className="text-2xl font-semibold">{t("dash.title")}</h1>
-        <button onClick={nav.toBookCreate} className={`px-3 py-1.5 text-sm rounded-md ${c.btnPrimary}`}>
+    <div className="space-y-10">
+      <div className="flex items-baseline justify-between">
+        <h1 className="font-serif text-3xl">{t("dash.title")}</h1>
+        <button
+          onClick={nav.toBookCreate}
+          className="px-4 py-2.5 rounded-md text-sm font-medium bg-primary text-primary-foreground hover:opacity-90 transition-opacity"
+        >
           {t("nav.newBook")}
         </button>
       </div>
 
-      <div className="grid gap-4">
+      <div className="space-y-3">
         {data.books.map((book) => {
           const isWriting = writingBooks.has(book.id);
           return (
-            <div key={book.id} className={`border rounded-lg p-5 transition-colors ${c.card}`}>
-              <div className="flex items-start justify-between">
-                <div>
-                  <button onClick={() => nav.toBook(book.id)} className={`text-lg font-medium ${c.link} transition-colors text-left`}>
+            <div
+              key={book.id}
+              className={`group border ${c.card} rounded-lg overflow-hidden`}
+            >
+              <div className="px-5 py-4 flex items-start justify-between">
+                <div className="min-w-0">
+                  <button
+                    onClick={() => nav.toBook(book.id)}
+                    className="font-serif text-lg hover:text-primary transition-colors text-left truncate block"
+                  >
                     {book.title}
                   </button>
-                  <div className={`flex gap-3 mt-1 text-sm ${c.muted}`}>
-                    <span>{book.genre}</span>
+                  <div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
+                    <span className="uppercase tracking-wider">{book.genre}</span>
+                    <span className="text-border">|</span>
                     <span>{book.chaptersWritten} {t("dash.chapters")}</span>
-                    <span className={book.status === "active" ? c.active : book.status === "paused" ? c.paused : c.subtle}>
+                    <span className="text-border">|</span>
+                    <span className={
+                      book.status === "active" ? "text-emerald-500" :
+                      book.status === "paused" ? "text-amber-500" :
+                      "text-muted-foreground"
+                    }>
                       {book.status}
                     </span>
-                    {book.language === "en" && <span className="text-blue-400">EN</span>}
-                    {book.fanficMode && <span className="text-purple-400">fanfic:{book.fanficMode}</span>}
+                    {book.language === "en" && <span className="text-primary/70">EN</span>}
+                    {book.fanficMode && <span className="text-purple-400">{book.fanficMode}</span>}
                   </div>
                 </div>
-                <div className="flex gap-2">
+
+                <div className="flex gap-2 shrink-0 ml-4">
                   <button
                     onClick={() => postApi(`/books/${book.id}/write-next`)}
                     disabled={isWriting}
-                    className={`px-3 py-1.5 text-sm rounded-md transition-colors disabled:opacity-50 disabled:cursor-wait ${c.btnSecondary}`}
+                    className={`px-3 py-2 text-xs rounded-md transition-all ${
+                      isWriting
+                        ? "bg-primary/20 text-primary cursor-wait"
+                        : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
+                    }`}
                   >
                     {isWriting ? t("dash.writing") : t("dash.writeNext")}
                   </button>
-                  <button onClick={() => nav.toAnalytics(book.id)} className={`px-3 py-1.5 text-sm rounded-md transition-colors ${c.btnSecondary}`}>
+                  <button
+                    onClick={() => nav.toAnalytics(book.id)}
+                    className="px-3 py-2 text-xs rounded-md bg-secondary text-secondary-foreground hover:bg-secondary/80 transition-colors"
+                  >
                     {t("dash.stats")}
                   </button>
                 </div>
               </div>
+
+              {/* Writing progress bar for active writes */}
+              {isWriting && (
+                <div className="h-0.5 bg-gradient-to-r from-primary/0 via-primary to-primary/0 animate-pulse" />
+              )}
             </div>
           );
         })}
       </div>
 
+      {/* Live writing progress panel */}
       {writingBooks.size > 0 && logEvents.length > 0 && (
-        <div className={`border rounded-lg p-4 ${c.info}`}>
-          <h2 className="text-sm font-medium mb-3">{t("dash.writingProgress")}</h2>
-          <div className={`space-y-1 text-sm ${c.mono} ${c.subtle}`}>
+        <div className="border border-primary/20 bg-primary/5 rounded-lg p-5">
+          <h3 className="text-xs uppercase tracking-widest text-primary/70 mb-3">{t("dash.writingProgress")}</h3>
+          <div className="space-y-1 text-[13px] font-mono text-muted-foreground">
             {logEvents.map((msg, i) => {
               const d = msg.data as { tag?: string; message?: string };
-              return <div key={i}><span className={c.muted}>[{d.tag}]</span> {d.message}</div>;
+              return (
+                <div key={i} className="leading-relaxed">
+                  <span className="text-primary/50">{d.tag}</span>
+                  <span className="text-border mx-1.5">›</span>
+                  <span>{d.message}</span>
+                </div>
+              );
             })}
             {progressEvent && (
-              <div className="text-blue-400 mt-2">
-                LLM streaming: {Math.round(((progressEvent.data as { elapsedMs?: number })?.elapsedMs ?? 0) / 1000)}s,{" "}
+              <div className="text-primary mt-3 pt-3 border-t border-primary/10">
+                streaming {Math.round(((progressEvent.data as { elapsedMs?: number })?.elapsedMs ?? 0) / 1000)}s
+                <span className="text-border mx-1.5">·</span>
                 {((progressEvent.data as { totalChars?: number })?.totalChars ?? 0).toLocaleString()} chars
               </div>
             )}
           </div>
         </div>
       )}
-
-      {writingBooks.size === 0 && recentEvents.length > 0 && (
-        <div className={`border rounded-lg p-4 ${c.cardStatic}`}>
-          <h2 className={`text-sm font-medium ${c.subtle} mb-3`}>{t("dash.recentEvents")}</h2>
-          <div className={`space-y-1 text-sm ${c.mono} ${c.muted}`}>
-            {recentEvents.slice(-5).map((msg, i) => (
-              <div key={i}>
-                <span className={c.muted}>{new Date(msg.timestamp).toLocaleTimeString()}</span>{" "}
-                <span>{msg.event}</span> {JSON.stringify(msg.data)}
-              </div>
-            ))}
-          </div>
-        </div>
-      )}
     </div>
   );
 }