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
+39 -12
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,9 +760,8 @@ 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 }}>
@@ -873,7 +898,10 @@ export function Settings() {
</div> </div>
</div> </div>
</div> </div>
)}
{/* TAB 4: AI AGENTS */}
{activeTab === "agents" && (
<div className="ps-settings-group"> <div className="ps-settings-group">
<h3>AI Agents &amp; Role Prompts</h3> <h3>AI Agents &amp; Role Prompts</h3>
<p className="ps-settings-desc" style={{ marginBottom: 12 }}> <p className="ps-settings-desc" style={{ marginBottom: 12 }}>
@@ -1018,7 +1046,6 @@ export function Settings() {
</div> </div>
</div> </div>
</div> </div>
</>
)} )}
{/* TAB 4: SERVER & SYSTEM */} {/* TAB 4: SERVER & SYSTEM */}
@@ -127,7 +127,8 @@ export function EntitiesView() {
}; };
return ( return (
<div className="ps-entities-view" style={{ padding: "20px 24px", overflowY: "auto" }}> <div className="ps-entities-view">
<div className="ps-entities-pinned-header">
<div className="ps-entity-toolbar"> <div className="ps-entity-toolbar">
<div className="ps-entity-tabs"> <div className="ps-entity-tabs">
{TABS.map((t) => ( {TABS.map((t) => (
@@ -202,7 +203,10 @@ export function EntitiesView() {
onChange={(ev) => setQuery(ev.target.value)} onChange={(ev) => setQuery(ev.target.value)}
/> />
</div> </div>
</div>
<div className="ps-entities-body">
<div className="ps-entities-main">
{tab === "consistency" ? ( {tab === "consistency" ? (
<ConsistencyPanel <ConsistencyPanel
issues={issues} issues={issues}
@@ -229,7 +233,7 @@ export function EntitiesView() {
<button <button
key={e.id} key={e.id}
type="button" type="button"
className="ps-entity-card" className={`ps-entity-card${open && draft?.id === e.id ? " is-selected" : ""}`}
onClick={() => openEntity(e)} onClick={() => openEntity(e)}
> >
<div className="ps-entity-card-top"> <div className="ps-entity-card-top">
@@ -250,6 +254,7 @@ export function EntitiesView() {
))} ))}
</div> </div>
)} )}
</div>
<EntityEditor <EntityEditor
entity={draft} entity={draft}
@@ -260,6 +265,7 @@ export function EntitiesView() {
onDelete={remove} 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")
+51 -36
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">
{label}
<textarea <textarea
className="ps-textarea ps-input" className="ps-textarea ps-input"
rows={3}
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 : ""}
options={(field.options ?? []).map((o) => ({ value: o, label: o }))}
onChange={(ev) => onChange(ev.target.value)} onChange={(ev) => onChange(ev.target.value)}
/> >
</div> {(field.options ?? []).map((o) => (
<option key={o} value={o}>
{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 : ""}
options={[{ value: "", label: "— none —" }, ...opts]}
onChange={(ev) => onChange(ev.target.value)} onChange={(ev) => onChange(ev.target.value)}
/> >
</div> <option value="">— None —</option>
{opts.map((o) => (
<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,6 +139,7 @@ function TagField({
return ( return (
<div className="ps-tag-field"> <div className="ps-tag-field">
{value.length > 0 && (
<div className="ps-tag-list"> <div className="ps-tag-list">
{value.map((t, i) => ( {value.map((t, i) => (
<span key={`${t}-${i}`} className="ps-tag-chip"> <span key={`${t}-${i}`} className="ps-tag-chip">
@@ -154,10 +155,11 @@ function TagField({
</span> </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 (
<div
key={field.key}
className={`ps-field${isCompact ? " ps-field-half" : " ps-field-full"}`}
>
<label className="ps-field-label">{field.label}</label>
{renderFieldControl(
field, field,
getFieldValue(entity, field.key), getFieldValue(entity, field.key),
(v) => onChange({ [field.key]: v } as Partial<Entity>), (v) => handleFieldChange(field.key, v),
entity.id,
entities, entities,
)} )}
</div> </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); }