fix: pin Writing toolbar and Entities header, fix Entities form alignment and red hint text, and make Settings category bar horizontal

This commit is contained in:
Schwitzz
2026-10-30 15:49:24 -05:00
parent 723fafdef8
commit 65d06e1681
8 changed files with 621 additions and 513 deletions
+306 -279
View File
@@ -20,7 +20,7 @@ import {
} from "@pubscript/ui"; } from "@pubscript/ui";
import type { ProviderKind, ThinkingEffort } from "@pubscript/ai"; import type { ProviderKind, ThinkingEffort } from "@pubscript/ai";
type SettingsTab = "appearance" | "writing" | "ai" | "server"; type SettingsTab = "appearance" | "writing" | "providers" | "agents" | "server";
const ACCENT_PRESETS = [ const ACCENT_PRESETS = [
"#7c8bf5", "#7c8bf5",
@@ -290,14 +290,36 @@ export function Settings() {
return ( return (
<section className="ps-content ps-page-container" aria-label="Settings"> <section className="ps-content ps-page-container" aria-label="Settings">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14, flexWrap: "wrap", gap: 10 }}> <div
<h2 style={{ margin: 0 }}>Settings</h2> style={{
<div className="ps-tabs" role="tablist" aria-label="Settings Categories" style={{ display: "flex", gap: 6, flexWrap: "wrap" }}> position: "sticky",
top: 0,
zIndex: 20,
background: "var(--ps-bg)",
paddingBottom: 12,
marginBottom: 20,
borderBottom: "1px solid var(--ps-border)",
}}
>
<h2 style={{ margin: "0 0 12px 0" }}>Settings</h2>
<div
role="tablist"
aria-label="Settings Categories"
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: 8,
flexWrap: "wrap",
width: "100%",
}}
>
{( {(
[ [
{ id: "appearance", label: "🎨 Appearance & Themes" }, { id: "appearance", label: "🎨 Appearance & Themes" },
{ id: "writing", label: "📁 Projects & Writing" }, { id: "writing", label: "📁 Projects & Writing" },
{ id: "ai", label: "✨ AI Providers & Agents" }, { id: "providers", label: "🔌 AI Providers" },
{ id: "agents", label: "🤖 AI Agents" },
{ id: "server", label: "🖧 Server & System" }, { id: "server", label: "🖧 Server & System" },
] as const ] as const
).map((tab) => { ).map((tab) => {
@@ -310,18 +332,22 @@ export function Settings() {
aria-selected={isActive} aria-selected={isActive}
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
style={{ style={{
padding: "7px 14px", display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "8px 16px",
borderRadius: 8, borderRadius: 8,
border: isActive border: isActive
? "1.5px solid var(--ps-accent)" ? "1.5px solid var(--ps-accent)"
: "1px solid var(--ps-border)", : "1px solid var(--ps-border)",
background: isActive background: isActive
? "color-mix(in srgb, var(--ps-accent) 16%, var(--ps-surface-2))" ? "color-mix(in srgb, var(--ps-accent) 18%, var(--ps-surface-2))"
: "var(--ps-surface)", : "var(--ps-surface)",
color: "var(--ps-text)", color: "var(--ps-text)",
fontWeight: isActive ? 600 : 500, fontWeight: isActive ? 600 : 500,
fontSize: 13, fontSize: 13.5,
cursor: "pointer", cursor: "pointer",
whiteSpace: "nowrap",
}} }}
> >
{tab.label} {tab.label}
@@ -734,291 +760,292 @@ export function Settings() {
</div> </div>
)} )}
{/* TAB 3: AI PROVIDERS & AGENTS */} {/* TAB 3: AI PROVIDERS */}
{activeTab === "ai" && ( {activeTab === "providers" && (
<> <div className="ps-settings-group">
<div className="ps-settings-group"> <h3>AI Providers &amp; Connection</h3>
<h3>AI Providers &amp; Connection</h3> <p className="ps-settings-desc" style={{ marginBottom: 12 }}>
<p className="ps-settings-desc" style={{ marginBottom: 12 }}> Configure local AI servers (Ollama, llama.cpp, LM Studio) or cloud endpoints (OpenAI, Anthropic, Google Gemini, OpenRouter).
Configure local AI servers (Ollama, llama.cpp, LM Studio) or cloud endpoints (OpenAI, Anthropic, Google Gemini, OpenRouter). </p>
</p>
<div className="ps-ai-settings-card"> <div className="ps-ai-settings-card">
<div className="ps-ai-settings-card-head"> <div className="ps-ai-settings-card-head">
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
<Select
value={currentProvider.id}
onChange={(e) => {
const id = e.target.value;
setSelectedProviderId(id);
const found = s.aiProviders.find((p) => p.id === id);
if (found) {
set({
activeProviderId: id,
activeModel: found.defaultModel || found.models[0] || "",
});
}
}}
options={s.aiProviders.map((p) => ({
value: p.id,
label: `${p.name} (${p.kind})`,
}))}
/>
{s.activeProviderId === currentProvider.id ? (
<Badge variant="success">Active Provider</Badge>
) : (
<Button
variant="secondary"
size="sm"
onClick={() =>
set({
activeProviderId: currentProvider.id,
activeModel: currentProvider.defaultModel || currentProvider.models[0] || "",
})
}
>
Set Active
</Button>
)}
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<Button
variant="secondary"
size="sm"
onClick={() => void handleProbeModels()}
disabled={probing}
>
{probing ? "Detecting…" : "↻ Load / Detect Models"}
</Button>
<Button variant="secondary" size="sm" onClick={handleAddProvider}>
+ Add Provider
</Button>
{s.aiProviders.length > 1 && (
<Button
variant="danger"
size="sm"
onClick={() => {
deleteProvider(currentProvider.id);
toast.info("Provider removed");
}}
>
Delete
</Button>
)}
</div>
</div>
<div className="ps-ai-settings-grid">
<Input
label="Provider Name"
value={currentProvider.name}
onChange={(e) => updateProvider(currentProvider.id, { name: e.target.value })}
/>
<Select <Select
label="Provider API Type" value={currentProvider.id}
value={currentProvider.kind}
onChange={(e) =>
updateProvider(currentProvider.id, { kind: e.target.value as ProviderKind })
}
options={[
{ value: "openai-compatible", label: "OpenAI-Compatible (Ollama / llama.cpp / LM Studio / OpenAI)" },
{ value: "anthropic", label: "Anthropic (Claude)" },
{ value: "google", label: "Google (Gemini)" },
{ value: "openrouter", label: "OpenRouter" },
]}
/>
<Input
label="Base URL / Endpoint"
value={currentProvider.baseUrl}
placeholder="http://localhost:11434/v1"
onChange={(e) => updateProvider(currentProvider.id, { baseUrl: e.target.value })}
/>
<Input
label="API Key (leave blank for local Ollama / llama.cpp)"
type="password"
value={currentProvider.apiKey}
placeholder="sk-..."
onChange={(e) => updateProvider(currentProvider.id, { apiKey: e.target.value })}
/>
<Select
label="Default Model"
value={currentProvider.defaultModel}
onChange={(e) => { onChange={(e) => {
const model = e.target.value; const id = e.target.value;
updateProvider(currentProvider.id, { defaultModel: model }); setSelectedProviderId(id);
if (s.activeProviderId === currentProvider.id) { const found = s.aiProviders.find((p) => p.id === id);
set({ activeModel: model }); if (found) {
} set({
}} activeProviderId: id,
options={currentProvider.models.map((m) => ({ value: m, label: m }))} activeModel: found.defaultModel || found.models[0] || "",
/>
<Input
label="Custom Model ID (comma-separated list)"
value={currentProvider.models.join(", ")}
onChange={(e) => {
const list = e.target.value
.split(",")
.map((x) => x.trim())
.filter(Boolean);
if (list.length > 0) {
updateProvider(currentProvider.id, {
models: list,
defaultModel: list.includes(currentProvider.defaultModel)
? currentProvider.defaultModel
: list[0]!,
}); });
} }
}} }}
options={s.aiProviders.map((p) => ({
value: p.id,
label: `${p.name} (${p.kind})`,
}))}
/> />
</div> {s.activeProviderId === currentProvider.id ? (
</div> <Badge variant="success">Active Provider</Badge>
</div> ) : (
<Button
<div className="ps-settings-group"> variant="secondary"
<h3>AI Agents &amp; Role Prompts</h3> size="sm"
<p className="ps-settings-desc" style={{ marginBottom: 12 }}> onClick={() =>
Customize temperature, context window (ctx), max output tokens, reasoning effort, and system prompts for each AI co-writing agent. set({
</p> activeProviderId: currentProvider.id,
activeModel: currentProvider.defaultModel || currentProvider.models[0] || "",
<div className="ps-ai-settings-card">
<div className="ps-ai-settings-card-head">
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
<Select
value={currentAgent.id}
onChange={(e) => {
const id = e.target.value;
setSelectedAgentId(id);
set({ activeAgentId: id });
}}
options={s.aiAgents.map((a) => ({
value: a.id,
label: `${a.name} (${a.role})`,
}))}
/>
<Badge variant="accent">{`Temp: ${currentAgent.temperature}`}</Badge>
<Badge variant="neutral">{`Ctx: ${currentAgent.contextWindow.toLocaleString()} tok`}</Badge>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{DEFAULT_AI_AGENTS.some((a) => a.id === currentAgent.id) && (
<Button variant="secondary" size="sm" onClick={handleResetAgentPrompt}>
Reset Defaults
</Button>
)}
<Button variant="secondary" size="sm" onClick={handleAddAgent}>
+ New Agent
</Button>
{!DEFAULT_AI_AGENTS.some((a) => a.id === currentAgent.id) && (
<Button
variant="danger"
size="sm"
onClick={() => {
deleteAgent(currentAgent.id);
toast.info("Agent deleted");
}}
>
Delete Agent
</Button>
)}
</div>
</div>
<div className="ps-ai-settings-grid">
<Input
label="Agent Name"
value={currentAgent.name}
onChange={(e) => updateAgent(currentAgent.id, { name: e.target.value })}
/>
<div className="ps-field">
<label className="ps-field-label">
Temperature ({currentAgent.temperature.toFixed(2)})
</label>
<input
type="range"
min="0"
max="2"
step="0.05"
value={currentAgent.temperature}
onChange={(e) =>
updateAgent(currentAgent.id, { temperature: Number(e.target.value) })
}
/>
<span className="ps-field-hint">
Lower (0.2) = precise editing · Higher (0.8+) = creative brainstorming
</span>
</div>
<Select
label="Context Window (ctx tokens)"
value={String(currentAgent.contextWindow)}
onChange={(e) =>
updateAgent(currentAgent.id, { contextWindow: Number(e.target.value) })
}
options={[
{ value: "4096", label: "4,096 tokens (4K)" },
{ value: "8192", label: "8,192 tokens (8K)" },
{ value: "16384", label: "16,384 tokens (16K)" },
{ value: "32768", label: "32,768 tokens (32K)" },
{ value: "65536", label: "65,536 tokens (64K)" },
{ value: "131072", label: "131,072 tokens (128K)" },
]}
/>
<Select
label="Max Output Tokens"
value={String(currentAgent.maxTokens)}
onChange={(e) =>
updateAgent(currentAgent.id, { maxTokens: Number(e.target.value) })
}
options={[
{ value: "512", label: "512 tokens" },
{ value: "1024", label: "1,024 tokens" },
{ value: "2048", label: "2,048 tokens" },
{ value: "4096", label: "4,096 tokens" },
{ value: "8192", label: "8,192 tokens" },
]}
/>
<div className="ps-field">
<label className="ps-field-label">Extended Thinking / Reasoning</label>
<label style={{ display: "flex", alignItems: "center", gap: 8, paddingTop: 6 }}>
<input
type="checkbox"
checked={currentAgent.thinkingEnabled}
onChange={(e) =>
updateAgent(currentAgent.id, { thinkingEnabled: e.target.checked })
}
/>
<span>Enable reasoning tokens for supported models</span>
</label>
</div>
{currentAgent.thinkingEnabled && (
<Select
label="Reasoning Effort"
value={currentAgent.thinkingEffort}
onChange={(e) =>
updateAgent(currentAgent.id, {
thinkingEffort: e.target.value as ThinkingEffort,
}) })
} }
options={[ >
{ value: "low", label: "Low" }, Set Active
{ value: "medium", label: "Medium" }, </Button>
{ value: "high", label: "High" },
]}
/>
)} )}
</div> </div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<div className="ps-field" style={{ marginTop: 12, marginBottom: 0 }}> <Button
<label className="ps-field-label">System Prompt &amp; Instructions</label> variant="secondary"
<textarea size="sm"
className="ps-input" onClick={() => void handleProbeModels()}
rows={5} disabled={probing}
value={currentAgent.systemPrompt} >
onChange={(e) => updateAgent(currentAgent.id, { systemPrompt: e.target.value })} {probing ? "Detecting…" : "↻ Load / Detect Models"}
placeholder="System instructions for this agent role…" </Button>
/> <Button variant="secondary" size="sm" onClick={handleAddProvider}>
+ Add Provider
</Button>
{s.aiProviders.length > 1 && (
<Button
variant="danger"
size="sm"
onClick={() => {
deleteProvider(currentProvider.id);
toast.info("Provider removed");
}}
>
Delete
</Button>
)}
</div> </div>
</div> </div>
<div className="ps-ai-settings-grid">
<Input
label="Provider Name"
value={currentProvider.name}
onChange={(e) => updateProvider(currentProvider.id, { name: e.target.value })}
/>
<Select
label="Provider API Type"
value={currentProvider.kind}
onChange={(e) =>
updateProvider(currentProvider.id, { kind: e.target.value as ProviderKind })
}
options={[
{ value: "openai-compatible", label: "OpenAI-Compatible (Ollama / llama.cpp / LM Studio / OpenAI)" },
{ value: "anthropic", label: "Anthropic (Claude)" },
{ value: "google", label: "Google (Gemini)" },
{ value: "openrouter", label: "OpenRouter" },
]}
/>
<Input
label="Base URL / Endpoint"
value={currentProvider.baseUrl}
placeholder="http://localhost:11434/v1"
onChange={(e) => updateProvider(currentProvider.id, { baseUrl: e.target.value })}
/>
<Input
label="API Key (leave blank for local Ollama / llama.cpp)"
type="password"
value={currentProvider.apiKey}
placeholder="sk-..."
onChange={(e) => updateProvider(currentProvider.id, { apiKey: e.target.value })}
/>
<Select
label="Default Model"
value={currentProvider.defaultModel}
onChange={(e) => {
const model = e.target.value;
updateProvider(currentProvider.id, { defaultModel: model });
if (s.activeProviderId === currentProvider.id) {
set({ activeModel: model });
}
}}
options={currentProvider.models.map((m) => ({ value: m, label: m }))}
/>
<Input
label="Custom Model ID (comma-separated list)"
value={currentProvider.models.join(", ")}
onChange={(e) => {
const list = e.target.value
.split(",")
.map((x) => x.trim())
.filter(Boolean);
if (list.length > 0) {
updateProvider(currentProvider.id, {
models: list,
defaultModel: list.includes(currentProvider.defaultModel)
? currentProvider.defaultModel
: list[0]!,
});
}
}}
/>
</div>
</div> </div>
</> </div>
)}
{/* TAB 4: AI AGENTS */}
{activeTab === "agents" && (
<div className="ps-settings-group">
<h3>AI Agents &amp; Role Prompts</h3>
<p className="ps-settings-desc" style={{ marginBottom: 12 }}>
Customize temperature, context window (ctx), max output tokens, reasoning effort, and system prompts for each AI co-writing agent.
</p>
<div className="ps-ai-settings-card">
<div className="ps-ai-settings-card-head">
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
<Select
value={currentAgent.id}
onChange={(e) => {
const id = e.target.value;
setSelectedAgentId(id);
set({ activeAgentId: id });
}}
options={s.aiAgents.map((a) => ({
value: a.id,
label: `${a.name} (${a.role})`,
}))}
/>
<Badge variant="accent">{`Temp: ${currentAgent.temperature}`}</Badge>
<Badge variant="neutral">{`Ctx: ${currentAgent.contextWindow.toLocaleString()} tok`}</Badge>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{DEFAULT_AI_AGENTS.some((a) => a.id === currentAgent.id) && (
<Button variant="secondary" size="sm" onClick={handleResetAgentPrompt}>
Reset Defaults
</Button>
)}
<Button variant="secondary" size="sm" onClick={handleAddAgent}>
+ New Agent
</Button>
{!DEFAULT_AI_AGENTS.some((a) => a.id === currentAgent.id) && (
<Button
variant="danger"
size="sm"
onClick={() => {
deleteAgent(currentAgent.id);
toast.info("Agent deleted");
}}
>
Delete Agent
</Button>
)}
</div>
</div>
<div className="ps-ai-settings-grid">
<Input
label="Agent Name"
value={currentAgent.name}
onChange={(e) => updateAgent(currentAgent.id, { name: e.target.value })}
/>
<div className="ps-field">
<label className="ps-field-label">
Temperature ({currentAgent.temperature.toFixed(2)})
</label>
<input
type="range"
min="0"
max="2"
step="0.05"
value={currentAgent.temperature}
onChange={(e) =>
updateAgent(currentAgent.id, { temperature: Number(e.target.value) })
}
/>
<span className="ps-field-hint">
Lower (0.2) = precise editing · Higher (0.8+) = creative brainstorming
</span>
</div>
<Select
label="Context Window (ctx tokens)"
value={String(currentAgent.contextWindow)}
onChange={(e) =>
updateAgent(currentAgent.id, { contextWindow: Number(e.target.value) })
}
options={[
{ value: "4096", label: "4,096 tokens (4K)" },
{ value: "8192", label: "8,192 tokens (8K)" },
{ value: "16384", label: "16,384 tokens (16K)" },
{ value: "32768", label: "32,768 tokens (32K)" },
{ value: "65536", label: "65,536 tokens (64K)" },
{ value: "131072", label: "131,072 tokens (128K)" },
]}
/>
<Select
label="Max Output Tokens"
value={String(currentAgent.maxTokens)}
onChange={(e) =>
updateAgent(currentAgent.id, { maxTokens: Number(e.target.value) })
}
options={[
{ value: "512", label: "512 tokens" },
{ value: "1024", label: "1,024 tokens" },
{ value: "2048", label: "2,048 tokens" },
{ value: "4096", label: "4,096 tokens" },
{ value: "8192", label: "8,192 tokens" },
]}
/>
<div className="ps-field">
<label className="ps-field-label">Extended Thinking / Reasoning</label>
<label style={{ display: "flex", alignItems: "center", gap: 8, paddingTop: 6 }}>
<input
type="checkbox"
checked={currentAgent.thinkingEnabled}
onChange={(e) =>
updateAgent(currentAgent.id, { thinkingEnabled: e.target.checked })
}
/>
<span>Enable reasoning tokens for supported models</span>
</label>
</div>
{currentAgent.thinkingEnabled && (
<Select
label="Reasoning Effort"
value={currentAgent.thinkingEffort}
onChange={(e) =>
updateAgent(currentAgent.id, {
thinkingEffort: e.target.value as ThinkingEffort,
})
}
options={[
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
]}
/>
)}
</div>
<div className="ps-field" style={{ marginTop: 12, marginBottom: 0 }}>
<label className="ps-field-label">System Prompt &amp; Instructions</label>
<textarea
className="ps-input"
rows={5}
value={currentAgent.systemPrompt}
onChange={(e) => updateAgent(currentAgent.id, { systemPrompt: e.target.value })}
placeholder="System instructions for this agent role…"
/>
</div>
</div>
</div>
)} )}
{/* TAB 4: SERVER & SYSTEM */} {/* TAB 4: SERVER & SYSTEM */}
+132 -126
View File
@@ -127,138 +127,144 @@ export function EntitiesView() {
}; };
return ( return (
<div className="ps-entities-view" style={{ padding: "20px 24px", overflowY: "auto" }}> <div className="ps-entities-view">
<div className="ps-entity-toolbar"> <div className="ps-entities-pinned-header">
<div className="ps-entity-tabs"> <div className="ps-entity-toolbar">
{TABS.map((t) => ( <div className="ps-entity-tabs">
<div key={t.id} className={"ps-entity-tab-wrap" + (t.id === tab ? " active" : "")}> {TABS.map((t) => (
<button <div key={t.id} className={"ps-entity-tab-wrap" + (t.id === tab ? " active" : "")}>
type="button"
className={"ps-entity-tab" + (t.id === tab ? " active" : "")}
onClick={() => setTab(t.id)}
>
{t.label}
</button>
{t.createKind && (
<button <button
type="button" type="button"
className="ps-entity-tab-add" className={"ps-entity-tab" + (t.id === tab ? " active" : "")}
title={t.createLabel ?? `Add to ${t.label}`} onClick={() => setTab(t.id)}
aria-label={t.createLabel ?? `Add to ${t.label}`}
onClick={(e) => {
e.stopPropagation();
void handleCreateKind(t.createKind!, t.id);
}}
> >
+ {t.label}
</button> </button>
)} {t.createKind && (
</div> <button
))} type="button"
</div> className="ps-entity-tab-add"
title={t.createLabel ?? `Add to ${t.label}`}
<div className="ps-entity-new-group"> aria-label={t.createLabel ?? `Add to ${t.label}`}
{activeTabDef.createKind ? ( onClick={(e) => {
<Button e.stopPropagation();
variant="primary" void handleCreateKind(t.createKind!, t.id);
size="sm" }}
onClick={() => void handleCreateKind(activeTabDef.createKind!)} >
> +
{activeTabDef.createLabel} </button>
</Button> )}
) : tab === "all" ? (
<>
<Button
variant="secondary"
size="sm"
onClick={() => void handleCreateKind("character", "people")}
>
+ Person
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => void handleCreateKind("world", "places")}
>
+ World Item
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => void handleCreateKind("note", "notes")}
>
+ Note
</Button>
</>
) : null}
</div>
</div>
<div className="ps-entity-search">
<Input
className="ps-entity-search-input"
placeholder="Search people, places, world items, and notes…"
value={query}
onChange={(ev) => setQuery(ev.target.value)}
/>
</div>
{tab === "consistency" ? (
<ConsistencyPanel
issues={issues}
onOpenEntity={(id) => {
const e = entities.find((x) => x.id === id);
if (e) {
setDraft(e);
setOpen(true);
}
}}
/>
) : visible.length === 0 ? (
<div className="ps-emptystate">
<div className="ps-emptystate-title">No entities in this view yet</div>
<p className="ps-muted">
{activeTabDef.createKind
? `Click "${activeTabDef.createLabel}" or the + button on the tab to create one.`
: "Use the + buttons on the People, Places & World, or Notes tabs to add story entities."}
</p>
</div>
) : (
<div className="ps-entity-grid">
{visible.map((e) => (
<button
key={e.id}
type="button"
className="ps-entity-card"
onClick={() => openEntity(e)}
>
<div className="ps-entity-card-top">
<span className="ps-entity-type-badge">{badgeFor(e)}</span>
</div> </div>
<div className="ps-entity-card-name">{entityLabel(e)}</div> ))}
<div className="ps-entity-card-sub">{subFor(e)}</div> </div>
{tagsFor(e).length > 0 && (
<div className="ps-entity-card-tags">
{tagsFor(e).slice(0, 4).map((t) => (
<span key={t} className="ps-entity-tag">
{t}
</span>
))}
</div>
)}
</button>
))}
</div>
)}
<EntityEditor <div className="ps-entity-new-group">
entity={draft} {activeTabDef.createKind ? (
entities={entities} <Button
open={open} variant="primary"
onClose={() => setOpen(false)} size="sm"
onSave={save} onClick={() => void handleCreateKind(activeTabDef.createKind!)}
onDelete={remove} >
/> {activeTabDef.createLabel}
</Button>
) : tab === "all" ? (
<>
<Button
variant="secondary"
size="sm"
onClick={() => void handleCreateKind("character", "people")}
>
+ Person
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => void handleCreateKind("world", "places")}
>
+ World Item
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => void handleCreateKind("note", "notes")}
>
+ Note
</Button>
</>
) : null}
</div>
</div>
<div className="ps-entity-search">
<Input
className="ps-entity-search-input"
placeholder="Search people, places, world items, and notes…"
value={query}
onChange={(ev) => setQuery(ev.target.value)}
/>
</div>
</div>
<div className="ps-entities-body">
<div className="ps-entities-main">
{tab === "consistency" ? (
<ConsistencyPanel
issues={issues}
onOpenEntity={(id) => {
const e = entities.find((x) => x.id === id);
if (e) {
setDraft(e);
setOpen(true);
}
}}
/>
) : visible.length === 0 ? (
<div className="ps-emptystate">
<div className="ps-emptystate-title">No entities in this view yet</div>
<p className="ps-muted">
{activeTabDef.createKind
? `Click "${activeTabDef.createLabel}" or the + button on the tab to create one.`
: "Use the + buttons on the People, Places & World, or Notes tabs to add story entities."}
</p>
</div>
) : (
<div className="ps-entity-grid">
{visible.map((e) => (
<button
key={e.id}
type="button"
className={`ps-entity-card${open && draft?.id === e.id ? " is-selected" : ""}`}
onClick={() => openEntity(e)}
>
<div className="ps-entity-card-top">
<span className="ps-entity-type-badge">{badgeFor(e)}</span>
</div>
<div className="ps-entity-card-name">{entityLabel(e)}</div>
<div className="ps-entity-card-sub">{subFor(e)}</div>
{tagsFor(e).length > 0 && (
<div className="ps-entity-card-tags">
{tagsFor(e).slice(0, 4).map((t) => (
<span key={t} className="ps-entity-tag">
{t}
</span>
))}
</div>
)}
</button>
))}
</div>
)}
</div>
<EntityEditor
entity={draft}
entities={entities}
open={open}
onClose={() => setOpen(false)}
onSave={save}
onDelete={remove}
/>
</div>
</div> </div>
); );
} }
@@ -42,7 +42,7 @@ export function EntityEditor({
const isExisting = entities.some((e) => e.id === draft.id); const isExisting = entities.some((e) => e.id === draft.id);
const isCharacterDraft = isCharacter(draft); const isCharacterDraft = isCharacter(draft);
const canDelete = isExisting && isCharacterDraft; const canDelete = isExisting;
const title = isCharacterDraft const title = isCharacterDraft
? (draft.name || "Edit character") ? (draft.name || "Edit character")
+84 -69
View File
@@ -9,8 +9,6 @@
import React from "react"; import React from "react";
import type { Entity, FieldDef } from "@pubscript/core"; import type { Entity, FieldDef } from "@pubscript/core";
import { ENTITY_FIELDS } from "@pubscript/core"; import { ENTITY_FIELDS } from "@pubscript/core";
import { Input } from "@pubscript/ui";
import { Select } from "@pubscript/ui";
/** A short display label for an entity, regardless of kind. */ /** A short display label for an entity, regardless of kind. */
export function entityLabel(e: Entity): string { export function entityLabel(e: Entity): string {
@@ -23,7 +21,9 @@ export function entityLabel(e: Entity): string {
/** Read a field value from an entity, regardless of where it is stored. */ /** Read a field value from an entity, regardless of where it is stored. */
export function getFieldValue(e: Entity, key: string): string | string[] { export function getFieldValue(e: Entity, key: string): string | string[] {
if ("fields" in e) { if ("fields" in e) {
if (key === "name") return e.name;
const v = e.fields[key]; const v = e.fields[key];
if (Array.isArray(v)) return v;
return typeof v === "string" ? v : ""; return typeof v === "string" ? v : "";
} }
const v = (e as unknown as Record<string, unknown>)[key]; const v = (e as unknown as Record<string, unknown>)[key];
@@ -34,6 +34,9 @@ export function getFieldValue(e: Entity, key: string): string | string[] {
/** Return a copy of `e` with `key` set to `value`. */ /** Return a copy of `e` with `key` set to `value`. */
export function setFieldValue(e: Entity, key: string, value: string | string[]): Entity { export function setFieldValue(e: Entity, key: string, value: string | string[]): Entity {
if ("fields" in e) { if ("fields" in e) {
if (key === "name" && typeof value === "string") {
return { ...e, name: value };
}
return { ...e, fields: { ...e.fields, [key]: value } }; return { ...e, fields: { ...e.fields, [key]: value } };
} }
return { ...e, [key]: value }; return { ...e, [key]: value };
@@ -52,69 +55,66 @@ interface EntityFieldsProps {
entities?: Entity[]; entities?: Entity[];
} }
function renderField( function renderFieldControl(
field: FieldDef, field: FieldDef,
value: string | string[], value: string | string[],
onChange: (v: string | string[]) => void, onChange: (v: string | string[]) => void,
currentEntityId: string,
entities?: Entity[], entities?: Entity[],
) { ) {
const label = (
<label className="ps-field-label">
{field.label}
{field.hint ? <span className="ps-field-hint">{field.hint}</span> : null}
</label>
);
switch (field.type) { switch (field.type) {
case "textarea": case "textarea":
return ( return (
<div className="ps-field-span-2"> <textarea
{label} className="ps-textarea ps-input"
<textarea rows={3}
className="ps-textarea ps-input" value={typeof value === "string" ? value : ""}
value={typeof value === "string" ? value : ""} onChange={(ev) => onChange(ev.target.value)}
onChange={(ev) => onChange(ev.target.value)} />
/>
</div>
); );
case "select": case "select":
return ( return (
<div> <select
{label} className="ps-select"
<Select value={typeof value === "string" ? value : ""}
value={typeof value === "string" ? value : ""} onChange={(ev) => onChange(ev.target.value)}
options={(field.options ?? []).map((o) => ({ value: o, label: o }))} >
onChange={(ev) => onChange(ev.target.value)} {(field.options ?? []).map((o) => (
/> <option key={o} value={o}>
</div> {o}
</option>
))}
</select>
); );
case "list": case "list":
return <TagField value={Array.isArray(value) ? value : []} onChange={onChange} />; return <TagField value={Array.isArray(value) ? value : []} onChange={onChange} />;
case "link": { case "link": {
const opts = (entities ?? []) const opts = (entities ?? [])
.filter((e) => e.id !== value) .filter((e) => e.id !== currentEntityId && !("body" in e))
.map((e) => ({ value: e.id, label: entityLabel(e) })); .map((e) => ({ value: e.id, label: entityLabel(e) }));
return ( return (
<div> <select
{label} className="ps-select"
<Select value={typeof value === "string" ? value : ""}
value={typeof value === "string" ? value : ""} onChange={(ev) => onChange(ev.target.value)}
options={[{ value: "", label: "— none —" }, ...opts]} >
onChange={(ev) => onChange(ev.target.value)} <option value="">— None —</option>
/> {opts.map((o) => (
</div> <option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
); );
} }
case "text": case "text":
return ( return (
<div className="ps-field-span-2"> <input
{label} type="text"
<Input className="ps-input"
className="ps-input" value={typeof value === "string" ? value : ""}
value={typeof value === "string" ? value : ""} onChange={(ev) => onChange(ev.target.value)}
onChange={(ev) => onChange(ev.target.value)} />
/>
</div>
); );
} }
} }
@@ -139,25 +139,27 @@ function TagField({
return ( return (
<div className="ps-tag-field"> <div className="ps-tag-field">
<div className="ps-tag-list"> {value.length > 0 && (
{value.map((t, i) => ( <div className="ps-tag-list">
<span key={`${t}-${i}`} className="ps-tag-chip"> {value.map((t, i) => (
{t} <span key={`${t}-${i}`} className="ps-tag-chip">
<button {t}
type="button" <button
className="ps-tag-remove" type="button"
aria-label={`Remove ${t}`} className="ps-tag-remove"
onClick={() => onChange(value.filter((_, idx) => idx !== i))} aria-label={`Remove ${t}`}
> onClick={() => onChange(value.filter((_, idx) => idx !== i))}
× >
</button> ×
</span> </button>
))} </span>
</div> ))}
</div>
)}
<input <input
className="ps-input" className="ps-input"
value={draft} value={draft}
placeholder="Add tag + Enter" placeholder="Add item + press Enter"
onChange={(ev) => setDraft(ev.target.value)} onChange={(ev) => setDraft(ev.target.value)}
onKeyDown={(ev) => { onKeyDown={(ev) => {
if (ev.key === "Enter") { if (ev.key === "Enter") {
@@ -173,18 +175,31 @@ function TagField({
export function EntityFields({ entity, onChange, entities }: EntityFieldsProps) { export function EntityFields({ entity, onChange, entities }: EntityFieldsProps) {
const fields = fieldsFor(entity); const fields = fieldsFor(entity);
const handleFieldChange = (key: string, val: string | string[]) => {
const updated = setFieldValue(entity, key, val);
onChange(updated);
};
return ( return (
<div className="ps-entity-fields"> <div className="ps-entity-fields">
{fields.map((field) => ( {fields.map((field) => {
<div key={field.key} className="ps-field"> const isCompact = field.type === "select" || field.type === "link";
{renderField( return (
field, <div
getFieldValue(entity, field.key), key={field.key}
(v) => onChange({ [field.key]: v } as Partial<Entity>), className={`ps-field${isCompact ? " ps-field-half" : " ps-field-full"}`}
entities, >
)} <label className="ps-field-label">{field.label}</label>
</div> {renderFieldControl(
))} field,
getFieldValue(entity, field.key),
(v) => handleFieldChange(field.key, v),
entity.id,
entities,
)}
</div>
);
})}
</div> </div>
); );
} }
+82 -34
View File
@@ -8,19 +8,37 @@
* (`--ps-*`) and field/button/drawer/tabs/badge classes. * (`--ps-*`) and field/button/drawer/tabs/badge classes.
*/ */
/* Full-width container for the entity engine (overrides .ps-content narrowness). */ /* Full-width container for the entity engine (pinned header + scrollable body). */
.ps-entities-view { .ps-entities-view {
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
background: var(--ps-bg);
} }
.ps-entities-view > .ps-tabs { .ps-entities-pinned-header {
position: sticky;
top: 0;
z-index: 20;
flex-shrink: 0;
padding: 16px 24px 12px;
background: var(--ps-bg);
border-bottom: 1px solid var(--ps-border);
}
.ps-entities-body {
flex: 1; flex: 1;
overflow: hidden; min-height: 0;
display: flex; display: flex;
flex-direction: column; overflow: hidden;
}
.ps-entities-main {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 18px 24px 64px;
} }
/* ---- Search bar + new-entity actions ---- */ /* ---- Search bar + new-entity actions ---- */
@@ -28,9 +46,13 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
margin-bottom: 20px; margin-bottom: 0;
flex-wrap: wrap; flex-wrap: wrap;
} }
.ps-entity-search .ps-field {
flex: 1;
margin-bottom: 0;
}
.ps-entity-search-input { .ps-entity-search-input {
flex: 1; flex: 1;
@@ -64,7 +86,8 @@
font-family: var(--ps-font-ui); font-family: var(--ps-font-ui);
} }
.ps-entity-card:hover { .ps-entity-card:hover,
.ps-entity-card.is-selected {
border-color: var(--ps-accent); border-color: var(--ps-accent);
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
@@ -161,20 +184,46 @@
font-family: var(--ps-font-ui); font-family: var(--ps-font-ui);
} }
/* ---- Entity editor drawer ---- */ /* ---- Entity editor drawer (docked right rail) ---- */
.ps-entity-editor { .ps-entity-editor {
width: min(460px, 45vw);
flex-shrink: 0;
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 0;
background: var(--ps-surface);
border-left: 1px solid var(--ps-border);
overflow: hidden;
}
.ps-entity-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--ps-border);
flex-shrink: 0;
}
.ps-entity-editor-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 14px;
overflow-y: auto;
padding: 16px 18px;
} }
.ps-entity-editor-actions { .ps-entity-editor-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-top: 12px; padding: 12px 18px;
padding-top: 16px;
border-top: 1px solid var(--ps-border); border-top: 1px solid var(--ps-border);
background: var(--ps-surface);
flex-shrink: 0;
} }
.ps-spacer { .ps-spacer {
@@ -185,14 +234,24 @@
margin-right: auto; margin-right: auto;
} }
/* ---- Generic entity fields ---- */ /* ---- Generic entity fields (clean 2-column grid for selects, full-width for text/textarea) ---- */
.ps-entity-fields { .ps-entity-fields {
display: flex; display: grid;
flex-direction: column; grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
} }
.ps-entity-fields .ps-field {
margin-bottom: 0;
}
.ps-field-full,
.ps-field-span-2 { .ps-field-span-2 {
grid-column: span 2; grid-column: 1 / -1;
}
.ps-field-half {
grid-column: span 1;
} }
.ps-textarea { .ps-textarea {
@@ -312,15 +371,19 @@
align-items: center; align-items: center;
} }
.ps-relationship-add > .ps-field {
margin-bottom: 0;
flex: 1;
min-width: 110px;
}
.ps-relationship-target-select, .ps-relationship-target-select,
.ps-relationship-kind-select { .ps-relationship-kind-select {
flex: 1; width: 100%;
min-width: 120px;
} }
.ps-relationship-note { .ps-relationship-note {
flex: 2; width: 100%;
min-width: 120px;
} }
/* ---- Misc ---- */ /* ---- Misc ---- */
@@ -406,14 +469,7 @@
min-width: 120px; min-width: 120px;
} }
/* ---- Entity editor drawer header/body ---- */ .ps-entity-Editor-title,
.ps-entity-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.ps-entity-editor-header h2 { .ps-entity-editor-header h2 {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
@@ -421,14 +477,6 @@
color: var(--ps-text); color: var(--ps-text);
} }
.ps-entity-editor-body {
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
padding: 4px 2px;
}
.ps-icon-btn { .ps-icon-btn {
border: none; border: none;
background: none; background: none;
+14 -2
View File
@@ -5,15 +5,26 @@
* line-number gutter, focus mode, and status bar. * line-number gutter, focus mode, and status bar.
*/ */
.ps-writing-slot {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.ps-writing { .ps-writing {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1;
min-height: 0;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
} }
/* OnlyOffice / Word-inspired icon formatting ribbon */ /* OnlyOffice / Word-inspired icon formatting ribbon (pinned at top) */
.ps-writing-toolbar { .ps-writing-toolbar {
position: sticky;
top: 0;
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
@@ -23,7 +34,8 @@
border-bottom: 1px solid var(--ps-border); border-bottom: 1px solid var(--ps-border);
font-family: var(--ps-font-ui); font-family: var(--ps-font-ui);
flex: none; flex: none;
z-index: 5; flex-shrink: 0;
z-index: 30;
} }
:root[data-density="compact"] .ps-writing-toolbar { :root[data-density="compact"] .ps-writing-toolbar {
padding: 4px 10px; padding: 4px 10px;
+1 -1
View File
@@ -32,7 +32,7 @@ export const ENTITY_FIELDS: Record<string, EntityKindDef> = {
{ key: "name", label: "Name", type: "text" }, { key: "name", label: "Name", type: "text" },
{ key: "role", label: "Role", type: "select", options: ["protagonist", "antagonist", "supporting", "minor", "narrator"] }, { key: "role", label: "Role", type: "select", options: ["protagonist", "antagonist", "supporting", "minor", "narrator"] },
{ key: "status", label: "Status", type: "select", options: ["active", "inactive", "dead", "unseen"] }, { key: "status", label: "Status", type: "select", options: ["active", "inactive", "dead", "unseen"] },
{ key: "home", label: "Home", type: "link", hint: "linked location" }, { key: "home", label: "Home Location", type: "link" },
{ key: "bio", label: "Bio", type: "textarea" }, { key: "bio", label: "Bio", type: "textarea" },
{ key: "appearance", label: "Appearance", type: "textarea" }, { key: "appearance", label: "Appearance", type: "textarea" },
{ key: "voice", label: "Voice", type: "textarea" }, { key: "voice", label: "Voice", type: "textarea" },
+1 -1
View File
@@ -431,7 +431,7 @@ body {
} }
.ps-input.is-error, .ps-select.is-error { border-color: var(--ps-danger); } .ps-input.is-error, .ps-select.is-error { border-color: var(--ps-danger); }
.ps-field-hint { font-size: 12px; color: var(--ps-text-muted); margin: 0; } .ps-field-hint { font-size: 12px; color: var(--ps-text-muted); margin: 0; }
.ps-field-hint:only-child { color: var(--ps-danger); } .ps-field-hint.is-error { color: var(--ps-danger); }
/* ---- Modal ---- */ /* ---- Modal ---- */
.ps-modal-root { position: fixed; inset: 0; z-index: 1000; font-family: var(--ps-font-ui); } .ps-modal-root { position: fixed; inset: 0; z-index: 1000; font-family: var(--ps-font-ui); }