diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..420e9b2
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,36 @@
+name: CI
+
+on:
+ push:
+ branches: [main, 'copilot/**']
+ pull_request:
+ branches: [main]
+
+jobs:
+ ci:
+ name: Lint, Build, Test
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Lint
+ run: npm run lint
+
+ - name: Build
+ run: npm run build
+
+ - name: Test
+ run: npm test
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8ec85b9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,38 @@
+# Dependencies
+node_modules/
+
+# Build output
+dist/
+build/
+
+# Environment files
+.env
+.env.local
+.env.*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea/
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+# OS files
+.DS_Store
+Thumbs.db
+
+# Logs
+logs/
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# TypeScript build info
+*.tsbuildinfo
+
+# Coverage
+coverage/
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..f03fb6e
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,6 @@
+{
+ "singleQuote": true,
+ "semi": true,
+ "printWidth": 100,
+ "trailingComma": "es5"
+}
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..efd28f3
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,33 @@
+import tseslint from 'typescript-eslint';
+import reactHooks from 'eslint-plugin-react-hooks';
+import globals from 'globals';
+
+export default tseslint.config(
+ // Ignore build output and deps
+ { ignores: ['dist/**', 'node_modules/**'] },
+
+ // Apply typescript-eslint recommended to all TS/TSX files
+ ...tseslint.configs.recommended,
+
+ // Project-specific overrides for TS/TSX source
+ {
+ files: ['**/*.{ts,tsx}'],
+ languageOptions: {
+ globals: {
+ ...globals.browser,
+ ...globals.es2022,
+ },
+ },
+ plugins: {
+ 'react-hooks': reactHooks,
+ },
+ rules: {
+ // React hooks correctness
+ 'react-hooks/rules-of-hooks': 'error',
+ 'react-hooks/exhaustive-deps': 'warn',
+
+ // TypeScript handles prop-types; disable the JS rule
+ '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
+ },
+ },
+);
diff --git a/index.html b/index.html
index d15d3a0..c39d360 100644
--- a/index.html
+++ b/index.html
@@ -1,506 +1,17 @@
-
-
+
.'
+ );
+}
+
+createRoot(rootElement).render(
+
+
+
+);
+`
+);
+
+// ─── src/editor-core/schema/index.ts ─────────────────────────────────────────
+write(
+ 'src/editor-core/schema/index.ts',
+ // NOTE: the heading toDOM uses a JS template literal — the \` and \${} below
+ // are escape sequences so this setup script's own template literal is valid.
+ `import { Schema } from 'prosemirror-model';
+
+/**
+ * Carbon Type document schema.
+ *
+ * Node hierarchy:
+ * doc
+ * ├─ title (exactly one, at the top — the document title)
+ * └─ block+ (paragraphs, headings, blockquotes, code blocks, hr)
+ *
+ * Inline content: text, hard_break
+ * Marks: em, strong, code, link, underline, strikethrough
+ */
+const schema = new Schema({
+ nodes: {
+ doc: {
+ content: 'title block+',
+ },
+
+ title: {
+ content: 'inline*',
+ marks: '',
+ defining: true,
+ parseDOM: [{ tag: 'h1.doc-title' }],
+ toDOM() {
+ return ['h1', { class: 'doc-title' }, 0];
+ },
+ },
+
+ paragraph: {
+ content: 'inline*',
+ marks: 'em strong code link underline strikethrough',
+ group: 'block',
+ parseDOM: [{ tag: 'p' }],
+ toDOM() {
+ return ['p', 0];
+ },
+ },
+
+ heading: {
+ content: 'inline*',
+ marks: 'em strong code link underline strikethrough',
+ group: 'block',
+ defining: true,
+ attrs: { level: { default: 1 } },
+ parseDOM: [
+ { tag: 'h1', getAttrs: () => ({ level: 1 }) },
+ { tag: 'h2', getAttrs: () => ({ level: 2 }) },
+ { tag: 'h3', getAttrs: () => ({ level: 3 }) },
+ { tag: 'h4', getAttrs: () => ({ level: 4 }) },
+ { tag: 'h5', getAttrs: () => ({ level: 5 }) },
+ { tag: 'h6', getAttrs: () => ({ level: 6 }) },
+ ],
+ toDOM(node) {
+ return [\`h\${node.attrs['level'] as number}\`, 0];
+ },
+ },
+
+ blockquote: {
+ content: 'block+',
+ group: 'block',
+ defining: true,
+ parseDOM: [{ tag: 'blockquote' }],
+ toDOM() {
+ return ['blockquote', 0];
+ },
+ },
+
+ code_block: {
+ content: 'text*',
+ marks: '',
+ group: 'block',
+ code: true,
+ defining: true,
+ parseDOM: [{ tag: 'pre', preserveWhitespace: 'full' }],
+ toDOM() {
+ return ['pre', ['code', 0]];
+ },
+ },
+
+ horizontal_rule: {
+ group: 'block',
+ parseDOM: [{ tag: 'hr' }],
+ toDOM() {
+ return ['hr'];
+ },
+ },
+
+ hard_break: {
+ inline: true,
+ group: 'inline',
+ selectable: false,
+ parseDOM: [{ tag: 'br' }],
+ toDOM() {
+ return ['br'];
+ },
+ },
+
+ text: {
+ group: 'inline',
+ },
+ },
+
+ marks: {
+ em: {
+ parseDOM: [{ tag: 'i' }, { tag: 'em' }, { style: 'font-style=italic' }],
+ toDOM() {
+ return ['em', 0];
+ },
+ },
+
+ strong: {
+ parseDOM: [
+ { tag: 'strong' },
+ {
+ tag: 'b',
+ getAttrs: (node) => {
+ const el = node as HTMLElement;
+ return el.style?.fontWeight !== 'normal' ? null : false;
+ },
+ },
+ { style: 'font-weight=bold' },
+ ],
+ toDOM() {
+ return ['strong', 0];
+ },
+ },
+
+ code: {
+ parseDOM: [{ tag: 'code' }],
+ toDOM() {
+ return ['code', 0];
+ },
+ },
+
+ link: {
+ attrs: {
+ href: {},
+ title: { default: null },
+ },
+ inclusive: false,
+ parseDOM: [
+ {
+ tag: 'a[href]',
+ getAttrs(node) {
+ const el = node as HTMLElement;
+ return { href: el.getAttribute('href'), title: el.getAttribute('title') };
+ },
+ },
+ ],
+ toDOM(node) {
+ const { href, title } = node.attrs as { href: string; title: string | null };
+ const domAttrs: Record
= { href };
+ if (title) domAttrs['title'] = title;
+ return ['a', domAttrs, 0];
+ },
+ },
+
+ underline: {
+ parseDOM: [{ tag: 'u' }, { style: 'text-decoration=underline' }],
+ toDOM() {
+ return ['u', 0];
+ },
+ },
+
+ strikethrough: {
+ parseDOM: [{ tag: 's' }, { tag: 'del' }, { style: 'text-decoration=line-through' }],
+ toDOM() {
+ return ['s', 0];
+ },
+ },
+ },
+});
+
+export default schema;
+`
+);
+
+// ─── src/editor-core/plugins/index.ts ────────────────────────────────────────
+write(
+ 'src/editor-core/plugins/index.ts',
+ `import { Plugin } from 'prosemirror-state';
+import { history, undo, redo } from 'prosemirror-history';
+import { keymap } from 'prosemirror-keymap';
+import { baseKeymap, toggleMark } from 'prosemirror-commands';
+import { gapCursor } from 'prosemirror-gapcursor';
+import { inputRules, InputRule } from 'prosemirror-inputrules';
+import schema from '../schema';
+
+/**
+ * Input rules for common markdown-like shorthand:
+ * *text* → em
+ * **text** → strong (checked first — longer pattern takes priority)
+ * \`text\` → code
+ */
+function buildInputRules(): Plugin {
+ const rules: InputRule[] = [
+ new InputRule(/\\*\\*([^*\\s][^*]*)\\*\\*$/, (state, match, start, end) => {
+ const content = match[1] ?? '';
+ if (!content) return null;
+ return state.tr.replaceWith(
+ start,
+ end,
+ schema.text(content, [schema.marks.strong.create()])
+ );
+ }),
+
+ new InputRule(/\\*([^*\\s][^*]*)\\*$/, (state, match, start, end) => {
+ const content = match[1] ?? '';
+ if (!content) return null;
+ return state.tr.replaceWith(
+ start,
+ end,
+ schema.text(content, [schema.marks.em.create()])
+ );
+ }),
+
+ new InputRule(/\`([^\`]+)\`$/, (state, match, start, end) => {
+ const content = match[1] ?? '';
+ if (!content) return null;
+ return state.tr.replaceWith(
+ start,
+ end,
+ schema.text(content, [schema.marks.code.create()])
+ );
+ }),
+ ];
+
+ return inputRules({ rules });
+}
+
+/**
+ * Build the complete plugin array for the editor.
+ * Plugin order matters: earlier plugins have higher key-binding priority.
+ */
+export function buildPlugins(): Plugin[] {
+ return [
+ history(),
+
+ keymap({
+ 'Mod-b': toggleMark(schema.marks.strong),
+ 'Mod-i': toggleMark(schema.marks.em),
+ 'Mod-z': undo,
+ 'Mod-Shift-z': redo,
+ Enter: (state, dispatch) => {
+ if (!dispatch) return false;
+ dispatch(
+ state.tr
+ .replaceSelectionWith(schema.nodes.hard_break.create())
+ .scrollIntoView()
+ );
+ return true;
+ },
+ }),
+
+ keymap(baseKeymap),
+
+ gapCursor(),
+
+ buildInputRules(),
+ ];
+}
+`
+);
+
+// ─── src/editor-core/commands/index.ts ───────────────────────────────────────
+write(
+ 'src/editor-core/commands/index.ts',
+ `import { Command } from 'prosemirror-state';
+import { toggleMark, setBlockType } from 'prosemirror-commands';
+import schema from '../schema';
+
+/** Toggle the em (italic) mark on the current selection. */
+export const toggleItalic: Command = toggleMark(schema.marks.em);
+
+/** Toggle the strong (bold) mark on the current selection. */
+export const toggleBold: Command = toggleMark(schema.marks.strong);
+
+/** Toggle the inline code mark on the current selection. */
+export const toggleCode: Command = toggleMark(schema.marks.code);
+
+/** Toggle the underline mark on the current selection. */
+export const toggleUnderline: Command = toggleMark(schema.marks.underline);
+
+/**
+ * Set the current block to a heading of the given level (1–6).
+ * Returns a Command so callers can bind it to a key or toolbar button.
+ */
+export function setHeading(level: 1 | 2 | 3 | 4 | 5 | 6): Command {
+ return setBlockType(schema.nodes.heading, { level });
+}
+
+/**
+ * Insert a hard line break (\\
) at the cursor position.
+ * This breaks the line within the same block without creating a new paragraph.
+ */
+export const insertHardBreak: Command = (state, dispatch) => {
+ if (!dispatch) return false;
+ dispatch(
+ state.tr.replaceSelectionWith(schema.nodes.hard_break.create()).scrollIntoView()
+ );
+ return true;
+};
+`
+);
+
+// ─── src/editor-core/EditorCore.tsx ──────────────────────────────────────────
+write(
+ 'src/editor-core/EditorCore.tsx',
+ `import { useEffect, useRef } from 'react';
+import { EditorState } from 'prosemirror-state';
+import { EditorView } from 'prosemirror-view';
+import schema from './schema';
+import { buildPlugins } from './plugins';
+import styles from './EditorCore.module.css';
+
+interface EditorCoreProps {
+ /** Optional extra class name applied to the editor container. */
+ className?: string;
+}
+
+/**
+ * EditorCore — the ProseMirror editing surface.
+ *
+ * Accessibility contract:
+ * - The outer container div carries role="textbox", aria-multiline, and
+ * aria-label so that assistive technology identifies the editing region.
+ * - The inner ProseMirror contenteditable div does not repeat those
+ * attributes (controlled via the EditorView \`attributes\` option) to
+ * prevent duplicate ARIA exposure.
+ *
+ * Keyboard behaviour (via plugins):
+ * - Mod-b → bold, Mod-i → italic
+ * - Mod-z → undo, Mod-Shift-z → redo
+ * - Enter → insert hard break (inline line break within a block)
+ * - All standard ProseMirror navigation keys
+ */
+export function EditorCore({ className }: EditorCoreProps) {
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ if (!containerRef.current) return;
+
+ // Minimal valid initial document: one empty title + one empty paragraph.
+ const initialDoc = schema.node('doc', null, [
+ schema.node('title', null, []),
+ schema.node('paragraph', null, []),
+ ]);
+
+ const state = EditorState.create({
+ schema,
+ plugins: buildPlugins(),
+ doc: initialDoc,
+ });
+
+ const view = new EditorView(containerRef.current, {
+ state,
+ // ARIA is handled on the outer container div.
+ // This option prevents accidental duplication on the inner
+ // ProseMirror contenteditable element.
+ attributes: {
+ 'data-editor-content': '',
+ },
+ });
+
+ return () => {
+ view.destroy();
+ };
+ }, []);
+
+ return (
+
+ );
+}
+`
+);
+
+// ─── src/editor-core/EditorCore.module.css ────────────────────────────────────
+write(
+ 'src/editor-core/EditorCore.module.css',
+ `.editor {
+ width: 100%;
+ min-height: 400px;
+ font-family: var(--font-editor);
+ font-size: var(--font-size-editor);
+ line-height: var(--line-height-editor);
+ color: var(--color-text-primary);
+ background-color: var(--color-bg-surface);
+ padding: var(--spacing-xl) var(--spacing-2xl);
+ border-radius: var(--radius-md);
+ border: 1px solid var(--color-border);
+}
+
+/* ProseMirror inner contenteditable div */
+.editor :global(.ProseMirror) {
+ outline: none;
+ min-height: 360px;
+ white-space: pre-wrap;
+}
+
+.editor :global(.ProseMirror-focused) {
+ outline: none;
+}
+
+/* Document title (h1.doc-title) */
+.editor :global(.doc-title) {
+ font-size: 1.75rem;
+ font-weight: 700;
+ line-height: 1.3;
+ color: var(--color-text-primary);
+ margin-bottom: var(--spacing-lg);
+ border-bottom: 1px solid var(--color-border);
+ padding-bottom: var(--spacing-sm);
+}
+
+/* Headings */
+.editor :global(h2) { font-size: 1.5rem; margin-top: var(--spacing-lg); }
+.editor :global(h3) { font-size: 1.25rem; margin-top: var(--spacing-lg); }
+.editor :global(h4) { font-size: 1.125rem; margin-top: var(--spacing-md); }
+
+/* Paragraphs */
+.editor :global(p) {
+ margin-top: var(--spacing-sm);
+}
+
+/* Blockquote */
+.editor :global(blockquote) {
+ border-left: 3px solid var(--color-accent-primary);
+ padding-left: var(--spacing-md);
+ color: var(--color-text-secondary);
+ margin: var(--spacing-md) 0;
+}
+
+/* Code block */
+.editor :global(pre) {
+ background-color: var(--color-bg-secondary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ padding: var(--spacing-md);
+ overflow-x: auto;
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 0.875rem;
+}
+
+/* Inline code */
+.editor :global(code) {
+ background-color: var(--color-bg-secondary);
+ border-radius: var(--radius-sm);
+ padding: 0.1em 0.3em;
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 0.875em;
+}
+
+/* Horizontal rule */
+.editor :global(hr) {
+ border: none;
+ border-top: 1px solid var(--color-border);
+ margin: var(--spacing-lg) 0;
+}
+`
+);
+
+// ─── src/components/Toolbar/Toolbar.tsx ───────────────────────────────────────
+write(
+ 'src/components/Toolbar/Toolbar.tsx',
+ `import styles from './Toolbar.module.css';
+
+interface ToolbarProps {
+ onBold?: () => void;
+ onItalic?: () => void;
+ onUnderline?: () => void;
+ onHeading?: (level: 1 | 2 | 3) => void;
+}
+
+/**
+ * Formatting toolbar.
+ *
+ * Accessibility contract:
+ * - role="toolbar" with aria-label groups buttons as a toolbar landmark.
+ * - Every button has a visible label AND an aria-label for screen readers.
+ * - All buttons are focusable native