Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
9ca7695
docs: add flat schema v2 spec
mbj36 Aug 4, 2026
5b6a379
feat: schema version guard for forward compatibility (flat schema M0)
mbj36 Aug 4, 2026
285d466
feat: flat schema v2 skeleton behind per-doc marker (M1)
mbj36 Aug 4, 2026
aeeaed5
docs: add editor chrome design spec and implementation plan (TEC-2515)
bhaveshxrawat Aug 4, 2026
eb2b9f7
test: restore make-editor helper deleted in f20d313
bhaveshxrawat Aug 4, 2026
0ef517e
chore: add drag-handle deps pinned at 3.11.0 with supply-chain guards
bhaveshxrawat Aug 4, 2026
348a0d9
feat: add floating DBlockDragHandle cluster (coexists with gutter)
bhaveshxrawat Aug 4, 2026
a59af9f
fix: stabilize DragHandle plugin registration in DBlockDragHandle
bhaveshxrawat Aug 4, 2026
f06a8df
feat: move editor chrome to container padding + floating controls
bhaveshxrawat Aug 4, 2026
8e5983e
fix: merge editorProps in setOptions instead of replacing them
bhaveshxrawat Aug 4, 2026
cb7c136
fix: stop construction-time attributes literal from clobbering DdocEd…
bhaveshxrawat Aug 4, 2026
2207020
feat: portal template overlay outside the editable DOM
bhaveshxrawat Aug 5, 2026
44eaf92
chore: bump version to 4.4.0
bhaveshxrawat Aug 5, 2026
f574671
fix: presentation padding scope, table centering, dead chrome code
bhaveshxrawat Aug 5, 2026
63ca1a6
feat: schema-aware block insertion across v1/v2 (M2 sweep)
mbj36 Aug 5, 2026
a4789c7
feat: heading collapse works on both schemas (M2)
mbj36 Aug 5, 2026
38e7462
feat: schema badge in demo DevBar
mbj36 Aug 5, 2026
bea3fac
feat: live doc JSON panel in demo DevBar
mbj36 Aug 5, 2026
fa7f6f3
feat: persistent block ids in the flat v2 schema (M2)
mbj36 Aug 5, 2026
509de74
feat: stock trailing node for the flat v2 schema (M2)
mbj36 Aug 5, 2026
e509e05
feat: AI writer space trigger for the flat v2 schema (M2)
mbj36 Aug 5, 2026
cdf8467
feat: dBlock unwrap transform for v1-shaped JSON (M2 exit)
mbj36 Aug 5, 2026
a4af33c
fix: columns caret set in setColumns' own transaction (position audit)
mbj36 Aug 5, 2026
618c050
refactor: use IconButton for block control buttons
bhaveshxrawat Aug 5, 2026
72521d8
fix: drop legacy editor spacing superseded by container padding
bhaveshxrawat Aug 5, 2026
58e315d
chore: sync package-lock version to 4.4.0
bhaveshxrawat Aug 5, 2026
3405935
fix: paste empty-paragraph collapse + v2 spacing parity
mbj36 Aug 5, 2026
3d3dbf4
fix: drag handle cluster geometry (stable width, alignment, line-heig…
bhaveshxrawat Aug 5, 2026
813678c
refactor: compute drag handle line offset synchronously in onNodeChange
bhaveshxrawat Aug 5, 2026
2e72409
fix: write drag handle line offset imperatively to avoid split-frame …
bhaveshxrawat Aug 5, 2026
91b5cc2
fix: insert uploaded image at mapped placeholder position (Fix 1)
bhaveshxrawat Aug 5, 2026
4a760ff
fix: stop cursor jumps from action-button embeds and delayed media co…
bhaveshxrawat Aug 5, 2026
a8c9f0a
fix: top-align to-do checkboxes with the first line (TEC-2644)
bhaveshxrawat Aug 5, 2026
7b0c485
fix: position template overlay statically and align its breakpoint wi…
bhaveshxrawat Aug 5, 2026
0eb032f
fix: retry deferred media conversion until the caret leaves the block
bhaveshxrawat Aug 5, 2026
6d54ab6
fix: let block controls act on columns and page breaks
bhaveshxrawat Aug 5, 2026
d568788
fix: restore word-break on editor blocks
bhaveshxrawat Aug 5, 2026
53842a0
fix: restore heading chrome (collapse + copy link) in read-only preview
bhaveshxrawat Aug 5, 2026
06dfc20
Merge remote-tracking branch 'origin/main' into TEC-2515
bhaveshxrawat Aug 5, 2026
d502a01
Merge remote-tracking branch 'origin/TEC-2515' into integration/tec25…
mbj36 Aug 6, 2026
692aa58
perf: keep up to 4 recent tab editors warm in the cache
mbj36 Aug 6, 2026
fc49640
Merge branch 'feat/flat-schema-v2' into integration/tec2515-x-v2
mbj36 Aug 6, 2026
f7d7dc5
fix: make heading render meta schema-agnostic
mbj36 Aug 6, 2026
e52d3a9
Merge branch 'feat/flat-schema-v2' into integration/tec2515-x-v2
mbj36 Aug 6, 2026
413770e
fix: make the floating block chrome work in the flat schema
mbj36 Aug 6, 2026
86f3221
fix: keep the floating block handle above the editor surface
mbj36 Aug 6, 2026
cf6d2da
docs: track what remains on TEC-2515 across both tracks
mbj36 Aug 6, 2026
497c460
feat: close the remaining flat-schema parity gaps
mbj36 Aug 6, 2026
79d8a0b
docs: mark the flat-schema package items done
mbj36 Aug 6, 2026
65f3138
fix: stop the collapse toggle from yanking the viewport
mbj36 Aug 6, 2026
fbed9ec
fix: do not paint a stale tab before IndexedDB has synced
mbj36 Aug 6, 2026
72ed0e9
docs: record the two v1 bugs for Bhavesh and split out the tab-switch…
mbj36 Aug 6, 2026
0128a3b
fix: extract document titles from flat-schema content
mbj36 Aug 6, 2026
ea34bc3
test: add the v1/v2 parity sweep as a repo script
mbj36 Aug 6, 2026
b816a5a
docs: correct where the schema marker is written
mbj36 Aug 6, 2026
999d5a2
docs: reconcile the flat-schema spec with what was actually built
mbj36 Aug 6, 2026
7a400a5
Merge remote-tracking branch 'origin/main' into integration/tec2515-x-v2
mbj36 Aug 10, 2026
c7f0c18
test: add the block-chrome parity probe as a repo script
mbj36 Aug 10, 2026
09f1f35
fix(undo): stop block ids from erasing the user's edit from the undo …
mbj36 Aug 10, 2026
010a11d
fix(undo): select what the undo actually changed, not the previous edit
mbj36 Aug 10, 2026
45748a0
fix: drop the gap cursor from the flat v2 schema (TEC-2679)
bhaveshxrawat Aug 10, 2026
228f0e8
fix: drag and drop for childless blocks — captionless media, embeds, …
bhaveshxrawat Aug 10, 2026
f7793bc
fix(undo): keep redo alive when an undo empties the document
mbj36 Aug 10, 2026
2536420
feat: join same-type lists when the block between them is backspaced …
bhaveshxrawat Aug 11, 2026
01e0360
style: give top-level lists the 24px block rhythm
bhaveshxrawat Aug 11, 2026
46e30d0
docs: ddocs.new integration handoff for M3 (creation fork, journeys, …
mbj36 Aug 11, 2026
a0f52b0
fix: keep block chrome alive when the document ends in a collapsed se…
mbj36 Aug 11, 2026
ab8d1fa
feat: v2-aware headless conversion for M3 template creation
bhaveshxrawat Aug 12, 2026
462c257
fix: keep headings when pasting flat blocks into a v1 doc
bhaveshxrawat Aug 12, 2026
c8978f6
docs: version-history diff needs no flat branch (verified)
bhaveshxrawat Aug 12, 2026
7d24882
chore: v4.4.0-rc.0
bhaveshxrawat Aug 12, 2026
9adfd8a
css fix
bhaveshxrawat Aug 12, 2026
555622e
skeleton ui fix
bhaveshxrawat Aug 12, 2026
7766b78
chore: semver tagged bump
bhaveshxrawat Aug 12, 2026
06102e0
skeleton-ui-fix
bhaveshxrawat Aug 12, 2026
4d135fa
semver tagged bump
bhaveshxrawat Aug 12, 2026
330a5a2
ux fixes
bhaveshxrawat Aug 13, 2026
d4707f9
chore: ui package and semver bump
bhaveshxrawat Aug 13, 2026
d22b434
katex overflow.
bhaveshxrawat Aug 13, 2026
22cc124
fix: never mount block chrome in preview editors
bhaveshxrawat Aug 13, 2026
eb48e19
chore: v4.4.0-rc.7
bhaveshxrawat Aug 13, 2026
aa479bd
chore: v4.5.0 semver bump
bhaveshxrawat Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ dist-ssr
# graphify (generated knowledge graph — local only)
graphify-out/

.superpowers/
12 changes: 12 additions & 0 deletions demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,12 @@ function App() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const editorRef = useRef<any>(null);

// Dev affordance: expose the editor handle for automated smoke tests.
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).__ddoc = editorRef;
}, []);

// Poll tab state from Y.Doc for DevBar
useEffect(() => {
const interval = setInterval(() => {
Expand Down Expand Up @@ -832,6 +838,12 @@ function App() {
initialContent={initialContent}
enableIndexeddbSync={true}
ddocId={docId}
// Only consulted at doc creation; existing docs follow their marker.
preferredSchemaVersion={
new URLSearchParams(window.location.search).get('v2') === '1'
? 2
: undefined
}
tabConfig={tabConfig}
onError={(error) => {
toast({
Expand Down
90 changes: 89 additions & 1 deletion demo/src/components/DevBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ export function DevBar({
}: DevBarProps) {
const [visible, setVisible] = useState(false);
const [contentSize, setContentSize] = useState(0);
const [schemaInfo, setSchemaInfo] = useState('...');
const [showJson, setShowJson] = useState(false);
const [docJson, setDocJson] = useState('');

useEffect(() => {
const handler = (e: KeyboardEvent) => {
Expand All @@ -63,6 +66,54 @@ export function DevBar({
return () => clearInterval(interval);
}, [visible, docId]);

// Doc schema: what the marker says vs what the editor actually loaded.
// A disagreement means the extension fork picked the wrong set.
useEffect(() => {
if (!visible) return;
const update = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handle = (window as any).__ddoc?.current;
const editor = handle?.getEditor?.();
const ydoc = handle?.getYdoc?.();
if (!editor || !ydoc) {
setSchemaInfo('...');
return;
}
const marker = ydoc.getMap('ddocMeta').get('schemaVersion');
const markerVersion = typeof marker === 'number' ? marker : 1;
const loadedVersion = editor.schema.nodes.dBlock ? 1 : 2;
setSchemaInfo(
markerVersion === loadedVersion
? `v${loadedVersion} ${loadedVersion >= 2 ? '(flat)' : '(dblock)'}`
: `MISMATCH marker=v${markerVersion} loaded=v${loadedVersion}`,
);
};
update();
const interval = setInterval(update, 2000);
return () => clearInterval(interval);
}, [visible]);

// Live document JSON while the panel is open.
useEffect(() => {
if (!visible || !showJson) return;
const update = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const editor = (window as any).__ddoc?.current?.getEditor?.();
if (!editor) {
setDocJson('editor not ready');
return;
}
setDocJson(JSON.stringify(editor.getJSON(), null, 2));
};
update();
const interval = setInterval(update, 1000);
return () => clearInterval(interval);
}, [visible, showJson]);

const handleCopyJson = () => {
navigator.clipboard?.writeText(docJson);
};

const handleClearData = () => {
const confirmed = window.confirm(
'Clear all data for this document? (localStorage + IndexedDB)',
Expand All @@ -84,11 +135,40 @@ export function DevBar({
);

return (
<div className="fixed bottom-0 left-0 right-0 z-[9999] h-8 color-bg-default border-t color-border-default flex items-center gap-3 px-4 text-[11px] font-mono color-text-secondary select-none">
<>
{showJson && (
<div className="fixed bottom-8 right-0 z-[9999] w-[480px] max-w-[90vw] h-[60vh] color-bg-default border color-border-default rounded-tl-md shadow-lg flex flex-col">
<div className="flex items-center justify-between px-3 py-1 border-b color-border-default">
<span className="text-[11px] font-mono color-text-secondary">
editor.getJSON() (live)
</span>
<Button
variant="ghost"
className="!h-5 !text-[10px] !px-2"
onClick={handleCopyJson}
>
Copy
</Button>
</div>
<pre className="flex-1 overflow-auto p-3 text-[10px] font-mono color-text-secondary whitespace-pre">
{docJson}
</pre>
</div>
)}
<div className="fixed bottom-0 left-0 right-0 z-[9999] h-8 color-bg-default border-t color-border-default flex items-center gap-3 px-4 text-[11px] font-mono color-text-secondary select-none">
<span title="Document ID">
<strong>doc:</strong> {docId.slice(0, 8)}
</span>
<Separator />
<span
title="Doc schema: marker in ddocMeta vs extensions the editor loaded"
className={
schemaInfo.startsWith('MISMATCH') ? 'text-red-500 font-bold' : ''
}
>
<strong>schema:</strong> {schemaInfo}
</span>
<Separator />
<span title="Active Tab ID">
<strong>tab:</strong> {activeTabId}
</span>
Expand Down Expand Up @@ -139,6 +219,13 @@ export function DevBar({
</span>

<div className="ml-auto flex items-center gap-2">
<Button
variant="ghost"
className="!h-5 !text-[10px] !px-2"
onClick={() => setShowJson((v) => !v)}
>
{showJson ? 'Hide JSON' : 'JSON'}
</Button>
<Button
variant="ghost"
className="!h-5 !text-[10px] !px-2 text-red-500"
Expand All @@ -149,5 +236,6 @@ export function DevBar({
<span className="opacity-50">Ctrl+Shift+D to hide</span>
</div>
</div>
</>
);
}
16 changes: 13 additions & 3 deletions demo/src/components/DocSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ interface DocSwitcherProps {
export function DocSwitcher({ currentDocId, currentTitle }: DocSwitcherProps) {
const [docs] = useState(() => docStore.getDocList());

const handleNewDoc = () => {
const handleNewDoc = (schemaVersion?: 2) => {
const newId = generateDocId();
docStore.addDoc({
id: newId,
Expand All @@ -25,7 +25,9 @@ export function DocSwitcher({ currentDocId, currentTitle }: DocSwitcherProps) {
lastModifiedAt: Date.now(),
});
docStore.setCurrentDocId(newId);
window.location.href = `${window.location.pathname}?doc=${newId}`;
// v2 only matters at creation; once the doc is stamped, the marker rules.
const v2Param = schemaVersion === 2 ? '&v2=1' : '';
window.location.href = `${window.location.pathname}?doc=${newId}${v2Param}`;
};

const handleSwitchDoc = (docId: string) => {
Expand Down Expand Up @@ -123,11 +125,19 @@ export function DocSwitcher({ currentDocId, currentTitle }: DocSwitcherProps) {
<Button
variant="ghost"
className="flex items-center gap-2 justify-start w-full"
onClick={handleNewDoc}
onClick={() => handleNewDoc()}
>
<LucideIcon name="Plus" size="sm" />
New Document
</Button>
<Button
variant="ghost"
className="flex items-center gap-2 justify-start w-full"
onClick={() => handleNewDoc(2)}
>
<LucideIcon name="FlaskConical" size="sm" />
New Document (flat schema v2)
</Button>
</div>
}
/>
Expand Down
146 changes: 146 additions & 0 deletions docs/DDOCS_NEW_INTEGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# ddocs.new integration handoff (M3)

For the person doing the ddocs.new side of the flat-schema rollout. The
file-level inventory lives in `TEC2515_REMAINING.md` section 3 and the
`FLAT_SCHEMA_V2.md` spec; this document explains how the pieces behave
together, per user journey, and in what order things must happen. Nothing
here supersedes those two, it narrates them.

## The mental model: one question, asked once

Every document answers one question: **which schema does it use?** The answer
is decided once, at the moment the document is born, and stamped inside the
document itself (`ydoc.getMap('ddocMeta').get('schemaVersion')`). Every later
open just reads the stamp.

On editor mount the package checks the Y.Doc:

- **Stamp present, or doc has content** → that document's schema is settled.
No stamp + content = pre-marker legacy doc = v1. The prop below is ignored
entirely. Nothing the app does can change an existing document's schema.
- **No stamp and the doc is empty** → the document is being born right now.
This is the only moment `preferredSchemaVersion` is consulted. If it says 2,
the package writes the stamp (origin `'self'`, so no spurious save) and the
doc is flat forever.

"Creation fork" means exactly that: a single if, evaluated once per document,
at birth. The road is one-way.

Because the stamp travels inside the content blob, everything downstream is
automatic: duplication copies the stamp, sharing sends it, IPFS restore keeps
it, a collaborator's client reads it and builds the matching editor. Dexie
needs no schema change; `IDdoc.version` (crypto/contract) is unrelated.

## The flag

```
flags.ts: flatSchemaEnabled prod: false | staging, preview: true
<DdocEditor preferredSchemaVersion={flatSchemaEnabled ? 2 : 1} ... />
↓ consulted only for a brand-new empty doc
stamp written into the Y.Doc
↓ every open, forever
stamp → extension set (dBlock or flat)
```

The flag never decides any existing document's schema; it only decides what
the next newborn is stamped with. Consequences worth internalizing:

- Flipping prod on: new docs are born v2 from that second; every existing doc,
including one created a minute earlier, is untouched.
- Flipping prod back off (emergency): new docs go back to v1. The v2 docs
created in between keep working, their stamp governs them. Rollback is a
config change, not a deploy, and it never strands data.
- Staging and prod can disagree indefinitely. Mixed v1/v2 libraries are the
normal state, not a transition artifact.
- Package default is 1 so no other consumer gets v2 by accident on a version
bump. The flag is the same protection one layer up, for ddocs.new itself.

## The app changes (details in TEC2515_REMAINING.md §3)

1. **Package upgrade** to the release cut from #552. The app pins the exact
version; keep the `yjs` / `y-indexeddb` / `y-protocols` overrides in
lockstep with the package's peers.
2. **The prop**, gated by the flag, at the `<DdocEditor>` mount.
3. **Title extraction** (`utils/ddoc-title-manager.ts:120-180`) — the app's
own copy of the wrapper-shape assumption. On v2 it fails *silently* and
every doc stays "Untitled" (doc list, export filenames). The package fixed
its identical copy in `package/utils/extract-title-from-content.tsx`;
reuse that shape. **Must land before any v2 doc exists.**
4. **Version-history diff** — verified to need NO code change. The pipeline
is schema-agnostic end to end: `buildVersionDiffSnapshot` decodes blobs
via `yDocToProsemirrorJSON` (no schema involved), the LCS differ compares
whatever block types it is given, and the renderer's dBlock branch at
`utils/diff/node-diff-renderer.ts:272` is a v1-only refinement that flat
content simply never enters. v2 diffs actually align better than v1:
persistent blockId attrs give the LCS block identity, where v1's
attr-less dBlock wrappers are interchangeable. Characterization tests
lock the flat path (`utils/diff/__tests__/node-diff-flat.test.ts` in the
app repo). Cross-schema diff still cannot occur (a doc never changes
schema).
5. **Templates** (`use-create-page.tsx`) — the app converts its template
JSON to a Yjs blob headlessly, before any editor mounts. Pass
`{ schemaVersion: 2 }` to the package's `getYjsConvertor()` when the flag
says v2; the package builds the flat editor, unwraps the dBlock wrappers,
and stamps the marker inside the blob itself. The stamp must be born
there: a headless blob already has content at first real mount, so the
mount-time stamping refuses it, and an unstamped blob is legacy v1
forever. Never hand-rewrite `template-utils.ts` (144 dBlock nodes); the
v1-shaped JSON stays the source of truth. Note the in-editor template
overlay needs nothing — it already unwraps at insert time against the
live schema. The `.md`/`.docx` import paths (`getYjsContentFromMarkdown`
/ `getYjsContentFromDocx`) still build v1 blobs — safe (imported docs
simply stay v1), thread the same option through them when imports should
produce v2 docs.
6. **E2E selectors** (`tests/utils/selectors.ts:13`) couple to v1 node-view
DOM; add v2 variants.

Everything else is verified opaque: Dexie, IPFS publish, collab transport,
comments, search, AI, key rotation all pass the blob through byte-level.

## User journeys after the flip

| Journey | What happens |
|---|---|
| Old user opens a legacy doc | No stamp → v1 extensions → identical behavior, indefinitely. No migration. |
| Same user creates a new doc | Born v2. Their library is mixed v1/v2; every surface picks per-doc by stamp. |
| New user | Only ever sees v2, templates included. |
| Viewer / public page | Blob → stamp → right extension set. v2 read-only collapse/copy-link chrome exists (widget decorations). |
| Collaborator joins | Transport opaque; their client reads the stamp. See the hazard below. |
| Blog user (.md) | Markdown serialization is package-side, parity-verified (20/20 sweep). |
| Split View | Right pane is the real editor; parity-verified. |
| Version history | Per-doc single schema; works once the diff renderer has its flat branch. |
| Duplicate a doc | Stamp travels in the blob; schema preserved for free. |

## The journey that can destroy data

A stale browser tab running an old bundle has no concept of schema versions.
If it opens a v2 doc, it parses flat content with dBlock rules and writes that
structure back through Yjs: corruption, synced to everyone, no undo. This
cannot be fixed retroactively for code already running in someone's browser.

Hence the one hard ordering rule, and the reason it is rigid:

1. **Ship the guard release** (any release cut from #552; the guard is
dormant) and deploy ddocs.new with it. Let it **soak for weeks** so the
stale-tab population turns over. Clients with the guard refuse
newer-than-supported docs and show "refresh to update".
2. **Test collaboration against v2** — the one unverified area in the package
work. Nothing today can create a v2 collab doc, so this needs deliberate
setup on staging with the flag on.
3. **Only then flip the prod flag.**

The soak time, not the code, is the schedule's long pole. That is the
argument for merging #552 and cutting the guard release early, while
everything else proceeds in parallel.

## Sequence checklist

- [ ] Merge #552, cut a package release (guard now exists in a published version)
- [ ] ddocs.new: upgrade package + land fixes 3-6 above, flag OFF everywhere
- [ ] Deploy to prod (all dormant), start the soak clock
- [ ] Flag ON in staging/preview; run the journey table above, especially collab
- [ ] Title check: create v2 doc with an H1, confirm doc list + export filename
- [ ] Version history on a v2 doc with several versions
- [ ] Soak elapsed + collab verified → flip prod flag
- [ ] Later, once irreversible in practice: delete the flag, hardcode 2
Loading