This document describes the architecture of the sophia-editor package, which provides content authoring UI for Sophia assessment content.
sophia-editor is the content authoring counterpart to the sophia rendering package. It provides:
┌─────────────────────────────────────────────────────────────────┐
│ EditorPage │
│ │
│ Top-level container for the full editing experience │
│ Includes preview, device framer, and diff views │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────┐ ┌───────────────────────┐ │
│ │ ArticleEditor │ │ ContentPreview │ │
│ │ │ │ │ │
│ │ Multi-section │ │ Live preview of │ │
│ │ article editing │ │ rendered content │ │
│ └───────────────────────┘ └───────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Editor │ │
│ │ │ │
│ │ Core content editor with markdown + widget support │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ WidgetEditor │ │ │
│ │ │ │ │ │
│ │ │ Per-widget editing container │ │ │
│ │ │ - Widget-specific editor (RadioEditor, etc.) │ │ │
│ │ │ - Mode-aware extensions (subscale mappings) │ │ │
│ │ │ - Static/alignment controls │ │ │
│ │ │ │ │ │
│ │ │ ┌──────────────────────────────────────────────┐ │ │ │
│ │ │ │ Widget-Specific Editor │ │ │ │
│ │ │ │ (e.g., RadioEditor) │ │ │ │
│ │ │ └──────────────────────────────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ │ ┌──────────────────────────────────────────────┐ │ │ │
│ │ │ │ SubscaleMappingsEditor │ │ │ │
│ │ │ │ (discovery mode only) │ │ │ │
│ │ │ └──────────────────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
editor-page.tsx)Top-level container providing the full editing experience:
editor.tsx)Core markdown + widget content editor:
components/widget-editor.tsx)Container for individual widget editing:
item-editor.tsx)Edits a complete Perseus item (question + hints):
article-editor.tsx)Multi-section article editing:
Each widget type has a corresponding editor in widgets/:
widgets/
├── radio/editor.tsx # Radio button editor
├── dropdown-editor.tsx # Dropdown editor
├── expression-editor.tsx # Math expression editor
├── input-number-editor.tsx # Numeric input editor
├── image-editor/ # Image widget (complex, multi-file)
├── interactive-graph-editor/ # Interactive graph (complex)
└── ...
Each widget editor must implement:
interface WidgetEditorExports {
// Widget type this editor handles
widgetName: string;
// Editor component
default: React.ComponentType<{
// Widget options (varies by type)
...options: WidgetOptions;
// Update handler
onChange: (newOptions: Partial<WidgetOptions>) => void;
// API configuration
apiOptions: APIOptions;
// Whether widget is static (non-interactive)
static?: boolean;
}>;
}
// widgets/my-widget-editor.tsx
import * as React from "react";
type MyWidgetOptions = {
// Define your widget's options
value: string;
showFeedback: boolean;
};
type Props = MyWidgetOptions & {
onChange: (options: Partial<MyWidgetOptions>) => void;
apiOptions: unknown;
static?: boolean;
};
const MyWidgetEditor: React.FC<Props> = (props) => {
const { value, showFeedback, onChange, static: isStatic } = props;
return (
<div className="my-widget-editor">
<label>
Value:
<input
value={value}
onChange={(e) => onChange({ value: e.target.value })}
disabled={isStatic}
/>
</label>
<label>
<input
type="checkbox"
checked={showFeedback}
onChange={(e) => onChange({ showFeedback: e.target.checked })}
/>
Show feedback
</label>
</div>
);
};
export default {
widgetName: "my-widget",
default: MyWidgetEditor,
};
all-editors.ts:import MyWidgetEditor from "./widgets/my-widget-editor";
export default [
// ... existing editors
MyWidgetEditor,
];
Sophia extends Perseus editing with mode-aware UI that adapts based on AssessmentPurpose.
interface ModeAwareEditorProps {
purpose?: AssessmentPurpose; // "mastery" | "discovery" | "reflection"
onPurposeChange?: (purpose: AssessmentPurpose) => void;
subscaleMappings?: SubscaleMappings;
onSubscaleMappingsChange?: (mappings: SubscaleMappings) => void;
subscaleNames?: string[];
}
Component for selecting assessment purpose:
import { PurposeSelector } from "@ethosengine/sophia-editor";
<PurposeSelector
value={purpose}
onChange={setPurpose}
/>
Component for mapping widget choices to subscales (discovery mode):
import { SubscaleMappingsEditor } from "@ethosengine/sophia-editor";
<SubscaleMappingsEditor
widgetId="radio 1"
choices={[
{ id: "0", label: "Option A" },
{ id: "1", label: "Option B" },
]}
mappings={subscaleMappings["radio 1"] ?? {}}
onChange={(mappings) => updateMappings("radio 1", mappings)}
subscaleNames={["openness", "conscientiousness"]}
/>
Common psychometric subscale sets are provided:
import { PREDEFINED_SUBSCALE_SETS } from "@ethosengine/sophia-editor";
// Big Five personality traits
PREDEFINED_SUBSCALE_SETS.bigFive
// ["openness", "conscientiousness", "extraversion", "agreeableness", "neuroticism"]
// Holland RIASEC career interests
PREDEFINED_SUBSCALE_SETS.hollandCodes
// ["realistic", "investigative", "artistic", "social", "enterprising", "conventional"]
// Learning styles (VARK)
PREDEFINED_SUBSCALE_SETS.learningStyles
// ["visual", "auditory", "reading", "kinesthetic"]
// Elohim Protocol domains
PREDEFINED_SUBSCALE_SETS.elohimDomains
// ["governance", "care", "economic"]
Most editor state is managed locally via React component state:
Editors use a serialize/deserialize pattern:
// Editor component provides serialize method
class Editor extends React.Component {
serialize() {
return {
content: this.state.content,
widgets: this.serializeWidgets(),
images: this.state.images,
};
}
serializeWidgets() {
const widgets = {};
for (const [id, ref] of Object.entries(this.widgetRefs)) {
widgets[id] = ref.current?.serialize();
}
return widgets;
}
}
Mode-aware state (purpose, subscale mappings) is lifted to parent:
function MyEditorPage() {
const [purpose, setPurpose] = useState<AssessmentPurpose>("mastery");
const [subscaleMappings, setSubscaleMappings] = useState<SubscaleMappings>({});
return (
<EditorPage
purpose={purpose}
onPurposeChange={setPurpose}
subscaleMappings={subscaleMappings}
onSubscaleMappingsChange={setSubscaleMappings}
/>
);
}
Styles are organized in styles/:
styles/
├── perseus-editor.css # Main editor styles
└── ...
Styles are imported via the main index.ts and bundled with the package.
// Main components
export { ArticleEditor, Editor, EditorPage, ItemEditor };
// Preview/diff components
export { ContentPreview, DeviceFramer, ViewportResizer };
export { ArticleDiff, ItemDiff };
// Mode-aware components (Sophia extensions)
export { PurposeSelector, SubscaleMappingsEditor };
export type { ModeAwareEditorProps, PredefinedSubscaleSet };
export { PREDEFINED_SUBSCALE_SETS };
// Widget editors (auto-registered)
export { AllEditors, widgets };
import {
EditorPage,
PurposeSelector,
PREDEFINED_SUBSCALE_SETS,
} from "@ethosengine/sophia-editor";
import type { AssessmentPurpose, SubscaleMappings } from "@ethosengine/sophia-core";
function ContentEditor() {
const [purpose, setPurpose] = useState<AssessmentPurpose>("mastery");
const [subscaleMappings, setSubscaleMappings] = useState<SubscaleMappings>({});
const [content, setContent] = useState(initialContent);
return (
<div>
<PurposeSelector value={purpose} onChange={setPurpose} />
<EditorPage
content={content}
onChange={setContent}
purpose={purpose}
subscaleMappings={subscaleMappings}
onSubscaleMappingsChange={setSubscaleMappings}
subscaleNames={
purpose === "discovery"
? PREDEFINED_SUBSCALE_SETS.elohimDomains
: undefined
}
/>
</div>
);
}
sophia (widgets, rendering)
│
└── sophia-editor (this package)
│
├── sophia-core (types)
└── perseus-core (widget types)
any types)