diff --git a/src/api/labNotesClient.ts b/src/api/labNotesClient.ts index dec484e..1840ab8 100644 --- a/src/api/labNotesClient.ts +++ b/src/api/labNotesClient.ts @@ -25,6 +25,7 @@ export interface LabNote { status?: LabNoteStatus; // NEW (can be derived server-side) type?: LabNoteType; // NEW dept?: string; // NEW (human readable) + card_style?: string; department_id: string; locale?: "en" | "ko"; // NEW diff --git a/src/components/labnotes/LabNoteCard.tsx b/src/components/labnotes/LabNoteCard.tsx index 73a331f..e157ff1 100644 --- a/src/components/labnotes/LabNoteCard.tsx +++ b/src/components/labnotes/LabNoteCard.tsx @@ -10,227 +10,257 @@ staggered reveal, and hover-driven excerpt reveal. =========================================================== */ -/** - * @file LabNoteCard.tsx - * @author Ada - * @assistant Lyric - * @lab-unit SCMS — Systems & Code Management Suite - * @since 2025-12-25 - * @description Presentational UI component for the Lab Notes index. - * Displays one Lab Note in a stylized card frame with: - * - Department-aware border/text/glow/shadow styling - * - Staggered reveal animation via per-card delay - * - Title ↔ excerpt "whisper" swap on hover - * - Tag pill, reading-time metadata, and link portal - * Data loading is handled by LabNotesPage; this component - * is intentionally stateless and render-only. - */ - // src/components/labnotes/LabNoteCard.tsx import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import type { LabNote } from "@/lib/labNotes"; +import { + cx, + DEPT_STYLES, + getDeptKey, + GUEST_STYLES, + getGuestKey, +} from "@/components/labnotes/labNoteCard.styles"; + +type Props = { + note: LabNote; + index: number; +}; -interface DeptStyle { - border: string; - text: string; - glow: string; - shadow: string; +function normalizeBlurb(s?: string | null) { + return String(s ?? "") + .replace(/\s+/g, " ") + .replace(/[—–-]\s*$/, "") + .trim(); } -const allStyles: Record = { - coda: { - border: "hover:!border-coda", - text: "!text-coda", - glow: "from-coda/20", - shadow: "hover:!shadow-[0_0_20px_rgba(255,184,0,0.4)]", - }, - vesper: { - border: "hover:!border-vesper", - text: "!text-vesper", - glow: "from-vesper/20", - shadow: "hover:!shadow-[0_0_20px_rgba(110,0,255,0.3)]", - }, - lyric: { - border: "hover:!border-lyric", - text: "!text-lyric", - glow: "from-lyric/20", - shadow: "hover:!shadow-[0_0_20px_rgba(0,255,133,0.3)]", - }, - scms: { - border: "hover:!border-slate-500", - text: "!text-slate-400", - glow: "from-slate-500/10", - shadow: "hover:!shadow-none", - }, -}; +function looksDuplicate(a?: string | null, b?: string | null) { + const A = normalizeBlurb(a).toLowerCase(); + const B = normalizeBlurb(b).toLowerCase(); + if (!A || !B) return false; + + if (A === B) return true; + if (A.includes(B) && B.length > 40) return true; + if (B.includes(A) && A.length > 40) return true; -// 🧬 Explicitly map potential YAML names to our style keys -function getDeptKey(id?: string): keyof typeof allStyles { - const normalized = (id || "scms").toLowerCase(); - if (normalized === "alignment" || normalized === "coda") return "coda"; - if (normalized === "shadow" || normalized === "vesper") return "vesper"; - if (normalized === "structure" || normalized === "lyric") return "lyric"; - if (normalized === "systems" || normalized === "scms") return "scms"; - return "scms"; + return false; } -type Props = { - note: LabNote; - index: number; -}; +function getPrimaryBlurb(note: any) { + return ( + normalizeBlurb(note.subtitle) || + normalizeBlurb(note.summary) || + normalizeBlurb(note.excerpt) || + null + ); +} + +// Hover whisper: only show if it adds value (no duplication) +function getHoverBlurb(note: { subtitle?: string | null; summary?: string | null; excerpt?: string | null }) { + const subtitle = normalizeBlurb(note.subtitle); + const summary = normalizeBlurb(note.summary); + const excerpt = normalizeBlurb(note.excerpt); + + const candidate = subtitle || summary || excerpt; + if (!candidate) return null; + + // If it's basically the same as the primary visible blurb, skip it + const primary = subtitle || summary || excerpt; + if (looksDuplicate(candidate, primary)) return null; + + // Extra guard: avoid “subtitle mirrors summary” cases + if (!subtitle && looksDuplicate(candidate, summary)) return null; + + return candidate; +} export function LabNoteCard({ note, index }: Props) { const { t, i18n } = useTranslation("labNotesPage"); const locale = i18n.language || "en"; - // Prefer canonical department_id; dept is optional label - const deptKey = getDeptKey(note.dept ?? note.department_id); - const styles = allStyles[deptKey] ?? allStyles.scms; + const hoverBlurb = getHoverBlurb(note as any); + const primaryBlurb = getPrimaryBlurb(note as any); - // Better label fallback now that type exists - const tag = note.tags?.[0] || (note.type ? note.type.toUpperCase() : "NOTE"); + const guestKey = getGuestKey(note as any); + const guest = guestKey ? (GUEST_STYLES[guestKey] ?? GUEST_STYLES.copilot) : null; - // Clamp shadow density to 0–10 - const shadow = Math.max(0, Math.min(10, Math.round(note.shadow_density ?? 0))); + const styleKey = note.card_style + ? String(note.card_style).toLowerCase() + : getDeptKey(note.dept ?? note.department_id); - const teaser = note.subtitle ?? note.summary ?? ""; + const styles = DEPT_STYLES[styleKey] ?? DEPT_STYLES.scms; + + + const tag = note.tags?.[0] || (note.type ? String(note.type).toUpperCase() : "NOTE"); + const shadow = Math.max(0, Math.min(10, Math.round(note.shadow_density ?? 0))); return (
{/* Glow layer */}
+ {/* Guest “visiting signal” rail (does not replace dept identity) */} + {guest ? ( +
+ ) : null} +
-
+ {/* Pills */} +
{tag} + + {guestKey && guest ? ( + + Guest · {guestKey} + + ) : null}
- {/* Title ↔ Excerpt swap */} + {/* Title ↔ hover whisper */}

{note.title}

- {teaser && ( + {hoverBlurb ? (

- {teaser} + {hoverBlurb}

- )} + ) : null}
- {note.summary && ( -
- {/* Preview ↔ Hover Insight Swap */} -
- {/* Default: excerpt */} -

- {note.summary} -

+ {/* Body: always show best available blurb; hover shows concept panel */} + {primaryBlurb ? ( +
+

+ {primaryBlurb} +

+
{/* Hover: concept signals */} -
-
-
- - Concept Load +
+
+ + Concept Load + + + {note.safer_landing ? ( + + Safer ✓ + ) : null} +
+ + {guestKey && guest ? ( +

+ In conversation with: {guestKey} +

+ ) : null} - {note.safer_landing && ( - - Safer ✓ - - )} -
- - {/* Shadow density bars */} -
- {Array.from({ length: 10 }).map((_, i) => { - const active = i < shadow; - - return ( - - ); - })} -
- - {/* Tag pills */} -
- {(note.tags ?? []).slice(0, 3).map((tagItem) => ( + {/* Shadow density bars */} +
+ {Array.from({ length: 10 }).map((_, i) => { + const active = i < shadow; + return ( - {tagItem} - - ))} - - {/* fallback if tags empty */} - {(!note.tags || note.tags.length === 0) && ( - - No tags registered - - )} -
+ key={i} + className={cx( + "w-1 rounded-sm transition-all duration-500", + active ? styles.accentBarActive : styles.accentBarInactive, + active ? styles.accentBarShadow : undefined + )} + style={{ height: `${6 + i}px` }} + /> + ); + })} +
+ + {/* Tags */} +
+ {(note.tags ?? []).slice(0, 3).map((tagItem) => ( + + {tagItem} + + ))}
+ ) : ( +
+

+
)}
@@ -242,7 +272,10 @@ export function LabNoteCard({ note, index }: Props) { {t("readMore", { defaultValue: "Open Note" })} → diff --git a/src/components/labnotes/LabNotesGridSkeleton.tsx b/src/components/labnotes/LabNotesGridSkeleton.tsx index d107e34..2d60715 100644 --- a/src/components/labnotes/LabNotesGridSkeleton.tsx +++ b/src/components/labnotes/LabNotesGridSkeleton.tsx @@ -1,8 +1,8 @@ // src/components/labnotes/LabNotesGridSkeleton.tsx import React from "react"; import { LabNoteCardSkeleton } from "@/components/labnotes/LabNoteCardSkeleton"; -import {LabNote} from "@/lib/labNotes"; -import {LabNoteCard} from "@/components/labnotes/LabNoteCard"; +import type { LabNote } from "@/lib/labNotes"; +import { LabNoteCard } from "@/components/labnotes/LabNoteCard"; export function LabNotesGridSkeleton({ count = 6 }: { count?: number }) { return ( @@ -13,12 +13,13 @@ export function LabNotesGridSkeleton({ count = 6 }: { count?: number }) {
); } + export function LabNotesGrid({ notes }: { notes: LabNote[] }) { return (
- {notes.map((n) => ( - + {notes.map((n, i) => ( + ))}
); -} \ No newline at end of file +} diff --git a/src/components/labnotes/labNoteCard.styles.ts b/src/components/labnotes/labNoteCard.styles.ts new file mode 100644 index 0000000..aed4041 --- /dev/null +++ b/src/components/labnotes/labNoteCard.styles.ts @@ -0,0 +1,134 @@ +// src/components/labnotes/labNoteCard.styles.ts + +export interface DeptStyle { + border: string; + text: string; + glow: string; + shadow: string; + + // Optional: used for hover bars / accents (lets Vesper not “own” everything) + accentBarActive?: string; + accentBarInactive?: string; + accentBarShadow?: string; +} + +// Central “design tokens” per department. +// Keep these subtle: designer > neon arcade. +export const DEPT_STYLES: Record = { + coda: { + border: "hover:!border-coda", + text: "!text-coda", + glow: "from-coda/20", + shadow: "hover:!shadow-[0_0_20px_rgba(255,184,0,0.35)]", + accentBarActive: "bg-coda", + accentBarInactive: "bg-slate-800", + accentBarShadow: "shadow-[0_0_8px_rgba(255,184,0,0.45)]", + }, + vesper: { + border: "hover:!border-vesper", + text: "!text-vesper", + glow: "from-vesper/20", + shadow: "hover:!shadow-[0_0_20px_rgba(110,0,255,0.25)]", + accentBarActive: "bg-vesper", + accentBarInactive: "bg-slate-800", + accentBarShadow: "shadow-[0_0_8px_rgba(110,0,255,0.45)]", + }, + lyric: { + border: "hover:!border-lyric", + text: "!text-lyric", + glow: "from-lyric/20", + shadow: "hover:!shadow-[0_0_20px_rgba(0,255,133,0.22)]", + accentBarActive: "bg-lyric", + accentBarInactive: "bg-slate-800", + accentBarShadow: "shadow-[0_0_8px_rgba(0,255,133,0.35)]", + }, + + // Default / SCMS stays quiet and structured + scms: { + border: "hover:!border-slate-500", + text: "!text-slate-300", + glow: "from-slate-500/10", + shadow: "hover:!shadow-none", + accentBarActive: "bg-slate-500", + accentBarInactive: "bg-slate-800", + accentBarShadow: "shadow-none", + }, + + // 🧠 Sage: calm authority, not loud — emerald/graphite vibe + sage: { + border: "hover:!border-emerald-500/40", + text: "!text-emerald-200", + glow: "from-emerald-400/14", + shadow: "hover:!shadow-[0_0_22px_rgba(52,211,153,0.18)]", + accentBarActive: "bg-emerald-400", + accentBarInactive: "bg-slate-800", + accentBarShadow: "shadow-[0_0_8px_rgba(52,211,153,0.32)]", + }, +}; + +export interface GuestStyle { + badge: string; + rail: string; + railMask?: string; + wash?: string; + text?: string; +} + +export const GUEST_STYLES: Record = { + copilot: { + badge: "bg-slate-900/40 text-slate-300 border-slate-700/70", + rail: "bg-gradient-to-b from-slate-400/35 via-slate-400/15 to-transparent", + railMask: "[mask-image:linear-gradient(to_bottom,black_70%,transparent)]", + wash: "from-slate-400/10 via-transparent to-transparent", + text: "text-slate-400", + }, + // future-proof examples + gemini: { + badge: "bg-slate-900/40 text-amber-200 border-amber-300/25", + rail: "bg-gradient-to-b from-amber-300/25 via-amber-300/10 to-transparent", + railMask: "[mask-image:linear-gradient(to_bottom,black_70%,transparent)]", + wash: "from-amber-300/10 via-transparent to-transparent", + text: "text-amber-200/70", + }, + grok: { + badge: "bg-slate-900/40 text-fuchsia-200 border-fuchsia-300/25", + rail: "bg-gradient-to-b from-fuchsia-300/25 via-fuchsia-300/10 to-transparent", + railMask: "[mask-image:linear-gradient(to_bottom,black_70%,transparent)]", + wash: "from-fuchsia-300/10 via-transparent to-transparent", + text: "text-fuchsia-200/70", + }, + claude: { + badge: "bg-slate-900/40 text-emerald-200 border-emerald-300/25", + rail: "bg-gradient-to-b from-emerald-300/25 via-emerald-300/10 to-transparent", + railMask: "[mask-image:linear-gradient(to_bottom,black_70%,transparent)]", + wash: "from-emerald-300/10 via-transparent to-transparent", + text: "text-emerald-200/70", + }, +}; + +export function getGuestKey(note: any): string | null { + const raw = (note?.guest ?? note?.voice ?? "").toString().trim().toLowerCase(); + return raw || null; +} + +// 🧬 Explicitly map potential YAML names to our style keys +export function getDeptKey(id?: string): keyof typeof DEPT_STYLES { + const normalized = (id || "scms").toLowerCase(); + + // Your existing aliases + if (normalized === "alignment" || normalized === "coda") return "coda"; + if (normalized === "shadow" || normalized === "vesper") return "vesper"; + if (normalized === "structure" || normalized === "lyric") return "lyric"; + if (normalized === "systems" || normalized === "scms") return "scms"; + + // New: Sage aliases + if (normalized === "sage") return "sage"; + if (normalized === "analysis" || normalized === "philosophy") return "sage"; + + return "scms"; +} + +// Tiny class joiner (avoids importing clsx just for this) +export function cx(...parts: Array) { + return parts.filter(Boolean).join(" "); +} diff --git a/src/data/labNote.ts b/src/data/labNote.ts index c61d383..56747af 100644 --- a/src/data/labNote.ts +++ b/src/data/labNote.ts @@ -34,4 +34,5 @@ type ApiLabNote = { safer_landing?: boolean; created?: string; updated?: string; + card_style?: string; }; diff --git a/src/data/labNotes.ts b/src/data/labNotes.ts index 65b9c98..c9415e2 100644 --- a/src/data/labNotes.ts +++ b/src/data/labNotes.ts @@ -39,6 +39,7 @@ export interface LabNote { publishedAt: string; // ISO date created?: string; updated?: string; + cardStyle?: string; } export const labNotes: LabNote[] = [ diff --git a/src/lib/labNotes.ts b/src/lib/labNotes.ts index 39ec13d..8f44fcd 100644 --- a/src/lib/labNotes.ts +++ b/src/lib/labNotes.ts @@ -19,6 +19,7 @@ export type LabNoteAttributes = { status?: "published" | "draft" | "archived"; dept?: string; + card_style?: string; department_id: string; shadow_density?: number; @@ -73,6 +74,7 @@ type ApiLabNoteIndexItem = { created_at?: string; updated_at?: string; + card_style?: string; }; type ApiLabNoteDetail = ApiLabNoteIndexItem & { @@ -157,6 +159,7 @@ function normalizeIndex(apiNotes: ApiLabNoteIndexItem[], requestedLocale: string created_at: n.created_at, updated_at: n.updated_at, + card_style: n.card_style, }; }) .filter((n) => n.status !== "archived") diff --git a/src/pages/admin/pages/AdminNotesPage.tsx b/src/pages/admin/pages/AdminNotesPage.tsx index d8d0a3d..6e2d447 100644 --- a/src/pages/admin/pages/AdminNotesPage.tsx +++ b/src/pages/admin/pages/AdminNotesPage.tsx @@ -12,6 +12,7 @@ export function AdminNotesPage() { const [form, setForm] = useState({ id: "", title: "", + subtitle: "", slug: "", locale: "en", type: "labnote", @@ -19,6 +20,7 @@ export function AdminNotesPage() { department_id: "SCMS", dept: "", + card_style: "", category: "", excerpt: "", @@ -79,6 +81,7 @@ export function AdminNotesPage() { setForm({ id: "", title: "", + subtitle: "", slug: "", locale: "en", type: "labnote", @@ -86,6 +89,7 @@ export function AdminNotesPage() { department_id: "SCMS", dept: "", + card_style: "", category: "", excerpt: "", @@ -102,6 +106,15 @@ export function AdminNotesPage() { setEditingId(null); }; + const CARD_STYLE_OPTIONS = [ + { value: "", label: "Auto (from department)" }, + { value: "scms", label: "SCMS (neutral)" }, + { value: "vesper", label: "Vesper (purple)" }, + { value: "sage", label: "Sage (emerald)" }, + { value: "lyric", label: "Lyric (neon green)" }, + { value: "coda", label: "Coda (amber)" }, + ]; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -141,6 +154,7 @@ export function AdminNotesPage() { setForm({ id: full.id ?? "", title: full.title ?? "", + subtitle: full.subtitle ?? "", slug: full.slug ?? "", locale: full.locale ?? "en", type: full.type ?? "labnote", @@ -148,6 +162,7 @@ export function AdminNotesPage() { department_id: full.department_id ?? "SCMS", dept: full.dept ?? "", + card_style: full.card_style ?? "", category: full.category ?? "", excerpt: full.excerpt ?? "", @@ -310,6 +325,19 @@ export function AdminNotesPage() { required />
+ {/* SUBTITLE*/} + {/* SLUG*/}
- {/* CATEGORY */} -
-
{/* EXCERPT */}