From daf9f51e98d4b790375d831ac349c5a6f9f76862 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 5 Apr 2026 02:00:55 +0000
Subject: [PATCH 1/2] =?UTF-8?q?feat(m0):=20scaffold=20foundation=20?=
=?UTF-8?q?=E2=80=94=20React=2019,=20ProseMirror,=20Vite,=20Vitest,=20CI?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Agent-Logs-Url: https://github.com/codewriter3000/carbon-type/sessions/4d90e1b5-74b4-49d1-b243-42464184f942
Co-authored-by: codewriter3000 <58712923+codewriter3000@users.noreply.github.com>
---
.github/workflows/ci.yml | 34 +
.gitignore | 38 +
.prettierrc | 6 +
eslint.config.js | 33 +
index.html | 17 +
package-lock.json | 5824 +++++++++++++++++++++
package.json | 46 +
setup-workspace.mjs | 993 ++++
src/components/App.module.css | 39 +
src/components/App.tsx | 28 +
src/components/Toolbar/Toolbar.module.css | 54 +
src/components/Toolbar/Toolbar.tsx | 91 +
src/editor-core/EditorCore.module.css | 88 +
src/editor-core/EditorCore.tsx | 71 +
src/editor-core/commands/index.ts | 35 +
src/editor-core/plugins/index.ts | 81 +
src/editor-core/schema/index.ts | 175 +
src/main.tsx | 16 +
src/styles/global.css | 79 +
src/styles/tokens.css | 65 +
src/vite-env.d.ts | 1 +
tests/editor-core/schema.test.ts | 64 +
tests/setup.ts | 1 +
tsconfig.app.json | 24 +
tsconfig.json | 7 +
tsconfig.node.json | 16 +
vite.config.ts | 18 +
vitest.config.ts | 16 +
28 files changed, 7960 insertions(+)
create mode 100644 .github/workflows/ci.yml
create mode 100644 .gitignore
create mode 100644 .prettierrc
create mode 100644 eslint.config.js
create mode 100644 index.html
create mode 100644 package-lock.json
create mode 100644 package.json
create mode 100644 setup-workspace.mjs
create mode 100644 src/components/App.module.css
create mode 100644 src/components/App.tsx
create mode 100644 src/components/Toolbar/Toolbar.module.css
create mode 100644 src/components/Toolbar/Toolbar.tsx
create mode 100644 src/editor-core/EditorCore.module.css
create mode 100644 src/editor-core/EditorCore.tsx
create mode 100644 src/editor-core/commands/index.ts
create mode 100644 src/editor-core/plugins/index.ts
create mode 100644 src/editor-core/schema/index.ts
create mode 100644 src/main.tsx
create mode 100644 src/styles/global.css
create mode 100644 src/styles/tokens.css
create mode 100644 src/vite-env.d.ts
create mode 100644 tests/editor-core/schema.test.ts
create mode 100644 tests/setup.ts
create mode 100644 tsconfig.app.json
create mode 100644 tsconfig.json
create mode 100644 tsconfig.node.json
create mode 100644 vite.config.ts
create mode 100644 vitest.config.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..e7da241
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,34 @@
+name: CI
+
+on:
+ push:
+ branches: [main, 'copilot/**']
+ pull_request:
+ branches: [main]
+
+jobs:
+ ci:
+ name: Lint, Build, Test
+ runs-on: ubuntu-latest
+
+ 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
new file mode 100644
index 0000000..c39d360
--- /dev/null
+++ b/index.html
@@ -0,0 +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