Phase UI7 — completes the note detail surface, the Palace knowledge
exploration page + its panels, the knowledge-gaps page, the prompts
page empty states, and the broadcast banner. Brings the ratchet down
to 14 raw controls / 21 legacy class matches — both genuine remaining
intentional items (NoteEditor toolbar, hidden file input, audit false
positives matching Tailwind arbitrary values).
notes/[noteId]/page.tsx:
- 'Loading' badge → Badge variant=neutral.
- Loading/error sections → Card.
- Review-state link → Link wrapping Badge.
palace/page.tsx:
- Wing <select> → Select with options=[{value,label}].
palace components:
- PalacePanel.tsx — search input → Input, hall chip → Badge.
- MemoryTimeline.tsx — hall chip → Badge.
- KnowledgeGraphView.tsx — entity query input → Input.
workspaces/[id]/gaps/page.tsx:
- Topic Coverage section → Card, chip → Badge.
- Empty-state + per-gap items → Card.
prompts/page.tsx:
- Loading + empty-state divs → Card.
landing page (/):
- section.surface-card → Card.
- 'Backend-backed web surface' badge → Badge.
- 'Open dashboard'/'Browse workspaces' links → utility classes.
share/[token]/page.tsx:
- Read-only public share badge → Badge.
- Main content surface-card + input-shell body wrapper → Card with
bordered body container.
BroadcastBanner.tsx:
- CTA + Dismiss raw <button> → Button (ghost variant, size sm).
Cumulative ratchet impact since session start:
raw interactive controls 38 → 14 (-24)
legacy global surface classes 92 → 21 (-71)
hardcoded color literals 0 (clean)
direct @bytelyst/ui imports 0 (clean)
Verified: pnpm typecheck, test (96/96), ratchet at new baseline.
90 lines
3.0 KiB
TypeScript
90 lines
3.0 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, useCallback, useRef } from "react";
|
||
import { Button } from "@/components/ui/Primitives";
|
||
import { getBroadcastClient } from "@/lib/broadcast-client";
|
||
import type { InAppMessage } from "@bytelyst/broadcast-client";
|
||
|
||
export function BroadcastBanner() {
|
||
const [messages, setMessages] = useState<InAppMessage[]>([]);
|
||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
|
||
const fetchMessages = useCallback(async () => {
|
||
try {
|
||
const { messages: list } = await getBroadcastClient().listMessages();
|
||
setMessages(list.filter((m) => m.status !== "dismissed"));
|
||
} catch {
|
||
// non-critical
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetchMessages();
|
||
intervalRef.current = setInterval(fetchMessages, 5 * 60_000);
|
||
return () => {
|
||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||
};
|
||
}, [fetchMessages]);
|
||
|
||
async function dismiss(id: string) {
|
||
try {
|
||
await getBroadcastClient().markDismissed(id);
|
||
} catch {
|
||
// best-effort
|
||
}
|
||
setMessages((prev) => prev.filter((m) => m.id !== id));
|
||
}
|
||
|
||
async function handleClick(msg: InAppMessage) {
|
||
try {
|
||
await getBroadcastClient().trackClick(msg.id);
|
||
} catch {
|
||
// best-effort
|
||
}
|
||
if (msg.ctaUrl) window.open(msg.ctaUrl, "_blank", "noopener");
|
||
}
|
||
|
||
if (messages.length === 0) return null;
|
||
|
||
return (
|
||
<div style={{ display: "grid", gap: "var(--nl-space-2)", padding: "var(--nl-space-3) var(--nl-space-4)" }}>
|
||
{messages.map((msg) => (
|
||
<div
|
||
key={msg.id}
|
||
role="status"
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
padding: "var(--nl-space-3) var(--nl-space-4)",
|
||
background: msg.priority === "high" || msg.priority === "urgent" ? "var(--nl-warning-muted)" : "var(--nl-info-muted)",
|
||
borderRadius: "var(--nl-radius-sm)",
|
||
fontSize: "var(--nl-fs-sm)",
|
||
}}
|
||
>
|
||
<div style={{ display: "flex", alignItems: "center", gap: "var(--nl-space-3)", flex: 1, minWidth: 0 }}>
|
||
<strong style={{ whiteSpace: "nowrap" }}>{msg.title}</strong>
|
||
{msg.body && <span style={{ color: "var(--nl-text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{msg.body}</span>}
|
||
{msg.ctaUrl && (
|
||
<Button variant="ghost" size="sm" onClick={() => handleClick(msg)} className="whitespace-nowrap font-semibold text-[color:var(--nl-accent-primary)]">
|
||
{msg.ctaText ?? "Learn more"}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
{msg.dismissible !== false && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => dismiss(msg.id)}
|
||
aria-label="Dismiss"
|
||
className="text-[color:var(--nl-text-secondary)]"
|
||
>
|
||
×
|
||
</Button>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|