Skip to content

Commit 955b2a3

Browse files
committed
refactor(demo): single-use clause library + brand-blue fields
Make the clause library a single-use inclusion checklist instead of a duplicate stamp tool. A clause is either "In contract" or available to "Add clause": a clause already placed can't be inserted again - clicking its card reveals the existing section, while an available card adds it (click or drag) and then flips to "In contract". Drops the "used N times" surface. This teaches the right model: fields are reusable variables, clauses are governed sections included once. - Add a library-only "Return of Materials" clause carrying a nested Receiving party slot, so insert-with-nested-fields stays demonstrable now that the seeded Permitted Use is "In contract" and no longer insertable. - Recolor fields and clauses to the SuperDoc brand blue (--sd-color-blue-500/600, per brand.md) instead of amber. They render as tinted/outlined pills, so they stay distinct from the solid-blue primary buttons. - Update tests (single-use status badges, add-once-no-duplicate, nested-field on add), the README, and code comments to the single-use + blue model.
1 parent 989f060 commit 955b2a3

4 files changed

Lines changed: 172 additions & 76 deletions

File tree

demos/__tests__/contract-templates-smart-tags.spec.ts

Lines changed: 47 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ test('a smart-field pill does not shift its box on hover or click (no jitter)',
132132
}
133133
});
134134

135-
test('a block clause keeps its amber left rail and box across hover/select (no jitter)', async ({ page }) => {
135+
test('a block clause keeps its left rail and box across hover/select (no jitter)', async ({ page }) => {
136136
test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only');
137137

138138
await page.route('**/ingest.superdoc.dev/**', (r) =>
@@ -148,7 +148,7 @@ test('a block clause keeps its amber left rail and box across hover/select (no j
148148
await page.waitForSelector(sel);
149149

150150
// Block SDTs strip border + fill on .sdt-group-hover / .ProseMirror-selectednode;
151-
// the demo overrides them. Guard the 4px amber left rail and box stay constant.
151+
// the demo overrides them. Guard the 4px left rail and box stay constant.
152152
const box = () =>
153153
page.evaluate((s) => {
154154
const el = document.querySelector(s) as HTMLElement;
@@ -289,7 +289,7 @@ test('a field value broadcasts to every occurrence, including one nested in a lo
289289
.toBe(2);
290290
});
291291

292-
test('clicking a clause card inserts a locked block clause at the cursor', async ({ page }) => {
292+
test('the clause library is single-use: seeded clauses are In contract, others Add clause', async ({ page }) => {
293293
test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only');
294294

295295
await page.route('**/ingest.superdoc.dev/**', (r) =>
@@ -303,37 +303,60 @@ test('clicking a clause card inserts a locked block clause at the cursor', async
303303
);
304304
await page.waitForSelector('.clause[data-clause-id]');
305305

306-
// Caret in the (unlocked) title so the clause inserts at a clean block boundary.
306+
// A seeded clause is already in the contract; a library-only one is available.
307+
await expect(page.locator('.clause[data-clause-id="permittedUse"] .clause-status')).toHaveText('In contract');
308+
await expect(page.locator('.clause[data-clause-id="permittedUse"]')).toHaveClass(/is-present/);
309+
await expect(page.locator('.clause[data-clause-id="indemnification"] .clause-status')).toHaveText('Add clause');
310+
await expect(page.locator('.clause[data-clause-id="indemnification"]')).toHaveClass(/is-available/);
311+
});
312+
313+
test('clicking an available clause adds it once (single-use, then In contract)', async ({ page }) => {
314+
test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only');
315+
316+
await page.route('**/ingest.superdoc.dev/**', (r) =>
317+
r.fulfill({ status: 204, contentType: 'application/json', body: '{}' }),
318+
);
319+
await page.goto('/');
320+
await page.waitForFunction(
321+
() => (window as any).__demo?.state?.ui?.contentControls?.getSnapshot()?.items?.length > 0,
322+
null,
323+
{ timeout: 30_000 },
324+
);
325+
await page.waitForSelector('.clause[data-clause-id="indemnification"]');
326+
327+
// Caret in the (unlocked) title so the clause adds at a clean block boundary.
307328
await page.evaluate(() => {
308329
(window as any).__demo.superdoc.activeEditor.commands?.setTextSelection?.({ from: 6, to: 6 });
309330
});
310331

311-
const sectionId = await page.getAttribute('.clause[data-clause-id]', 'data-clause-id');
312-
expect(sectionId).toBeTruthy();
313-
314-
// Count controls for this clause + confirm they're all locked.
315-
const clauseInfo = () =>
316-
page.evaluate((sid) => {
332+
const indemnificationInfo = () =>
333+
page.evaluate(() => {
317334
const doc = (window as any).__demo.doc();
318335
const items = doc.contentControls.list({}).items.filter((c: any) => {
319336
try {
320-
return JSON.parse(c.properties?.tag ?? '{}').sectionId === sid;
337+
return JSON.parse(c.properties?.tag ?? '{}').sectionId === 'indemnification';
321338
} catch {
322339
return false;
323340
}
324341
});
325342
return { count: items.length, allLocked: items.every((c: any) => c.lockMode === 'contentLocked') };
326-
}, sectionId);
343+
});
344+
345+
expect((await indemnificationInfo()).count).toBe(0);
346+
await page.click('.clause[data-clause-id="indemnification"]');
327347

328-
const before = await clauseInfo();
329-
await page.click(`.clause[data-clause-id="${sectionId}"]`);
348+
// It's added once, locked, and the card flips to In contract.
349+
await expect.poll(async () => (await indemnificationInfo()).count, { timeout: 6_000 }).toBe(1);
350+
expect((await indemnificationInfo()).allLocked).toBe(true);
351+
await expect(page.locator('.clause[data-clause-id="indemnification"] .clause-status')).toHaveText('In contract');
330352

331-
// A new block clause for this section appears, and every occurrence is locked.
332-
await expect.poll(async () => (await clauseInfo()).count, { timeout: 6_000 }).toBe(before.count + 1);
333-
expect((await clauseInfo()).allLocked).toBe(true);
353+
// Clicking again does NOT duplicate it (single-use; reveals the existing one).
354+
await page.click('.clause[data-clause-id="indemnification"]');
355+
await page.waitForTimeout(500);
356+
expect((await indemnificationInfo()).count).toBe(1);
334357
});
335358

336-
test('inserting Permitted Use nests real smart fields that fill from the form', async ({ page }) => {
359+
test('adding the Return of Materials clause nests a real smart field that fills from the form', async ({ page }) => {
337360
test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only');
338361

339362
await page.route('**/ingest.superdoc.dev/**', (r) =>
@@ -345,15 +368,14 @@ test('inserting Permitted Use nests real smart fields that fill from the form',
345368
null,
346369
{ timeout: 30_000 },
347370
);
348-
await page.waitForSelector('.clause[data-clause-id="permittedUse"]');
371+
await page.waitForSelector('.clause[data-clause-id="returnOfMaterials"]');
349372

350-
// Caret in the (unlocked) title so the clause inserts at a clean block boundary.
373+
// Caret in the (unlocked) title so the clause adds at a clean block boundary.
351374
await page.evaluate(() => {
352375
(window as any).__demo.superdoc.activeEditor.commands?.setTextSelection?.({ from: 6, to: 6 });
353376
});
354377

355-
// Count Receiving party smart fields in the document (an inline structuredContent
356-
// whose tag carries that key) - the Permitted Use clause carries one as a slot.
378+
// Receiving party smart fields in the document (Return of Materials carries one).
357379
const receivingPartyControls = () =>
358380
page.evaluate(() => {
359381
const doc = (window as any).__demo.doc();
@@ -362,13 +384,13 @@ test('inserting Permitted Use nests real smart fields that fill from the form',
362384
});
363385

364386
const before = (await receivingPartyControls()).length; // 2 seeded
365-
await page.click('.clause[data-clause-id="permittedUse"]');
387+
await page.click('.clause[data-clause-id="returnOfMaterials"]');
366388

367-
// Inserting the clause adds a real nested Receiving party SDT (not plain text).
389+
// Adding the clause creates a real nested Receiving party SDT (not plain text).
368390
await expect.poll(async () => (await receivingPartyControls()).length, { timeout: 6_000 }).toBe(before + 1);
369391

370392
// Filling Receiving party in the Values form reaches every occurrence,
371-
// including the one just nested inside the inserted clause.
393+
// including the one just nested inside the added clause.
372394
await page.click('.tab[data-tab="values"]');
373395
await page.fill('input[data-field="receivingParty"]', 'Beacon Bio');
374396
await expect

demos/contract-templates/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ The starting document is `public/nda-template.docx`: inline plain-text fields an
1212

1313
**Template tab, the building-block library.** Two catalogs, fields and clauses, each styled to match what it inserts:
1414

15-
- Smart-field chips wear the same amber token look as the in-document field (CSS on `.superdoc-structured-content-inline[data-sdt-tag*='smartField']`). Drag a chip onto the document, or click to insert it at the cursor. An unfilled field shows its field-name token (e.g. `DISCLOSING_PARTY`) as a stand-in placeholder. That token is literal text content, not a native SDT placeholder.
16-
- Clause cards wear the same amber block look as the in-document clause and carry metadata: category, jurisdiction, version, and how many times the clause is placed ("Used 2 times"). The catalog includes clauses that aren't in the document yet. Drag a card onto the document, or click to insert it at the cursor.
15+
- Smart-field chips wear the same blue token look as the in-document field (CSS on `.superdoc-structured-content-inline[data-sdt-tag*='smartField']`). Drag a chip onto the document, or click to insert it at the cursor. An unfilled field shows its field-name token (e.g. `DISCLOSING_PARTY`) as a stand-in placeholder. That token is literal text content, not a native SDT placeholder.
16+
- Clause cards wear the same blue block look as the in-document clause and carry metadata (category, jurisdiction, version) and a status. A clause is single-use, like an inclusion checklist: a card already in the contract reads **In contract** and clicking it reveals the existing clause; an available card reads **Add clause** and drags or clicks in. The catalog includes clauses that aren't in the document yet (e.g. Indemnification, Return of Materials).
1717

1818
Inserts resolve the drop point with `ui.viewport.positionAt({ x, y })` and create the control with `editor.doc.create.contentControl({ kind, at, content, tag, lockMode })`. A field inserts inline at the exact caret; a clause snaps to a block boundary so it lands as a clean section instead of splitting a paragraph. Clicking a control in the document highlights its chip or card (`content-control:click`).
1919

demos/contract-templates/src/main.ts

Lines changed: 75 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@
1818
* - Every control is `contentLocked`, so it can't be edited by typing in the
1919
* document. This is a locked template surface; the custom UI drives changes.
2020
* - Template tab = the building-block library. Two catalogs: smart-field chips
21-
* and clause cards (each with category / jurisdiction / version and a "used
22-
* N times" count). Drag or click a chip to insert an inline field, or a card
23-
* to insert a block clause. An unfilled field shows its field-name token
24-
* (e.g. DISCLOSING_PARTY) as a stand-in placeholder - literal text content,
25-
* not a native SDT placeholder.
21+
* (reusable variables) and clause cards (governed sections, single-use). A
22+
* chip drags/clicks in as an inline field. A clause card shows category /
23+
* jurisdiction / version and a status: "Add clause" when available (drag or
24+
* click to add) or "In contract" once placed (click reveals the existing one
25+
* - a clause appears once, like an inclusion checklist). An unfilled field
26+
* shows its field-name token (e.g. DISCLOSING_PARTY) as a stand-in
27+
* placeholder - literal text content, not a native SDT placeholder.
2628
* - A clause is assembled from structured `parts`: prose plus `{ field }`
2729
* slots. Inserting it creates the block and wraps each slot as a nested,
2830
* locked inline smart field - so an inserted "Permitted Use" carries real
@@ -113,7 +115,8 @@ type ClauseId =
113115
| 'termination'
114116
| 'governingLaw'
115117
| 'limitationOfLiability'
116-
| 'indemnification';
118+
| 'indemnification'
119+
| 'returnOfMaterials';
117120

118121
type ClauseCategory = 'Core' | 'Confidentiality' | 'Termination' | 'Risk Allocation';
119122

@@ -202,7 +205,7 @@ const CLAUSE_LIBRARY: LibraryClause[] = [
202205
parts: ['Each party’s aggregate liability under this Agreement is limited to fees paid in the twelve (12) months preceding the claim.'],
203206
},
204207
{
205-
// A library-only clause: not in the seeded document, so it starts "Used 0".
208+
// A library-only clause: not in the seeded document, so it starts "Add clause".
206209
// Insert it to add a new governed section to the contract.
207210
id: 'indemnification',
208211
label: 'Indemnification',
@@ -211,6 +214,20 @@ const CLAUSE_LIBRARY: LibraryClause[] = [
211214
version: 'v1',
212215
parts: ['Each party will indemnify and hold the other harmless from third-party claims arising out of its breach of this Agreement.'],
213216
},
217+
{
218+
// Library-only and carries a nested field slot: adding it shows that an
219+
// inserted clause's embedded variables become real, broadcast-linked SDTs.
220+
id: 'returnOfMaterials',
221+
label: 'Return of Materials',
222+
category: 'Confidentiality',
223+
jurisdiction: 'General',
224+
version: 'v1',
225+
parts: [
226+
'Upon termination or at the disclosing party’s request, ',
227+
{ field: 'receivingParty' },
228+
' will promptly return or destroy all Confidential Information in its possession.',
229+
],
230+
},
214231
];
215232

216233
// ---------------------------------------------------------------------------
@@ -592,7 +609,7 @@ async function insertClause(clauseId: ClauseId, blockId: string): Promise<void>
592609
}
593610

594611
// Lock the clause now that its slots are wrapped, then refresh the cards
595-
// ("Used N") and scroll the new clause into view.
612+
// (the card flips to "In contract") and scroll the new clause into view.
596613
reportMutation(doc.contentControls.setLockMode({ target: clauseTarget, lockMode: 'contentLocked' }), 'Lock clause');
597614
renderClausesPanel();
598615
void ui.contentControls.scrollIntoView({ id: clauseTarget.nodeId, block: 'center' });
@@ -602,9 +619,14 @@ async function insertClause(clauseId: ClauseId, blockId: string): Promise<void>
602619
function insertClauseAtCursor(clauseId: ClauseId): void {
603620
const ui = state.ui;
604621
if (!ui || !state.editor?.doc) return;
622+
// Single-use: if it's already in the contract, reveal it instead of duplicating.
623+
if (isClauseInDocument(clauseId)) {
624+
revealClause(clauseId);
625+
return;
626+
}
605627
const seg = ui.selection.capture()?.target?.segments?.[0];
606628
if (!seg) {
607-
setStatus('Place the cursor in the document (or drag the clause in), then click a clause to insert it.');
629+
setStatus('Place the cursor in the document, then click a clause to add it.');
608630
return;
609631
}
610632
void insertClause(clauseId, seg.blockId);
@@ -651,7 +673,10 @@ function setupPaletteDragDrop(): () => void {
651673
insertField(field.key, field.label, { kind: 'selection', start: point, end: point });
652674
} else if (clauseId) {
653675
const clause = CLAUSE_LIBRARY.find((c) => c.id === clauseId);
654-
if (clause) void insertClause(clause.id, hit.point.blockId); // offset 0 (block boundary)
676+
if (!clause) return;
677+
// Single-use: a clause already in the contract reveals instead of duplicating.
678+
if (isClauseInDocument(clause.id)) revealClause(clause.id);
679+
else void insertClause(clause.id, hit.point.blockId); // offset 0 (block boundary)
655680
}
656681
};
657682

@@ -785,8 +810,10 @@ function highlightActiveClause(): void {
785810

786811
/**
787812
* Template tab: the contract's building blocks. Two catalogs - inline Smart tags
788-
* (variable chips) and block Clauses (cards with metadata + usage count). Both
789-
* drag or click to insert; values are filled on the Values tab.
813+
* (reusable variable chips, drag or click to insert) and block Clauses (governed,
814+
* single-use cards with metadata + a status; an available clause adds by drag or
815+
* click, one already in the contract reveals it). Values are filled on the
816+
* Values tab.
790817
*/
791818
function renderFieldsPanel(): void {
792819
fieldsPanelEl.innerHTML = '';
@@ -797,7 +824,7 @@ function renderFieldsPanel(): void {
797824
/**
798825
* Clauses section of the Template tab: a search + the clause cards. Mirrors the
799826
* Smart-tags section's style (group header, search) but the clauses render as
800-
* compact amber cards, not pills, since they're block controls. Creates the
827+
* compact blue cards, not pills, since they're block controls. Creates the
801828
* list container (clausesListEl) that renderClausesPanel re-renders into.
802829
*/
803830
function renderClausesSection(): void {
@@ -873,11 +900,11 @@ function renderValuesPanel(): void {
873900

874901
/**
875902
* Render the clause cards: one card per clause, styled like the in-document
876-
* block clause (amber left rail). Like the smart-tag chips, a card is draggable
903+
* block clause (blue left rail). Like the smart-tag chips, a card is draggable
877904
* into the document or click-to-insert at the cursor (insertClause snaps to a
878905
* block boundary). A card highlights when its clause is clicked in the document.
879906
*/
880-
/** Every control in the document for a given clause (a clause can be placed more than once). */
907+
/** Every control in the document for a given clause (used internally for counts). */
881908
function clauseControls(clauseId: ClauseId): ContentControlInfo[] {
882909
const doc = state.editor?.doc;
883910
if (!doc) return [];
@@ -887,32 +914,51 @@ function clauseControls(clauseId: ClauseId): ContentControlInfo[] {
887914
});
888915
}
889916

917+
/** A clause is single-use: it's either in the contract or available to add. */
918+
function isClauseInDocument(clauseId: ClauseId): boolean {
919+
return clauseControls(clauseId).length > 0;
920+
}
921+
922+
/** Scroll the clause's placement into view and highlight its card. */
923+
function revealClause(clauseId: ClauseId): void {
924+
const ctrl = clauseControls(clauseId)[0];
925+
if (!state.ui || !ctrl) return;
926+
state.activeClauseId = clauseId;
927+
highlightActiveClause();
928+
void state.ui.contentControls.scrollIntoView({ id: ctrl.target.nodeId, block: 'center' });
929+
}
930+
890931
/**
891-
* Render the clause library catalog: a card per available clause with its
892-
* category / jurisdiction / version and how many times it's placed in the
893-
* document. A card wears the in-document block clause's amber look. Drag a card
894-
* in, or click to insert it at the cursor; the card highlights when its clause
895-
* is clicked in the document.
932+
* Render the clause library as a single-use inclusion checklist. Each card shows
933+
* the clause's category / jurisdiction / version and whether it's "In contract"
934+
* or available to "Add clause". A clause is governed and appears once: a card
935+
* that's already in the contract can't be inserted again - clicking it reveals
936+
* the existing clause instead; an available card inserts (click) or drags in.
896937
*/
897938
function renderClausesPanel(): void {
898939
const list = clausesListEl;
899940
if (!list) return;
900941
list.innerHTML = '';
901942
for (const clause of CLAUSE_LIBRARY) {
902-
const used = clauseControls(clause.id).length;
903-
const usedText = used === 0 ? 'Not used' : `Used ${used} time${used === 1 ? '' : 's'}`;
943+
const inDoc = isClauseInDocument(clause.id);
904944
const card = document.createElement('article');
905-
card.className = 'clause' + (clause.id === state.activeClauseId ? ' is-active' : '');
945+
card.className =
946+
'clause ' + (inDoc ? 'is-present' : 'is-available') + (clause.id === state.activeClauseId ? ' is-active' : '');
906947
card.dataset.clauseId = clause.id;
907-
card.draggable = true;
908-
card.title = `Drag into the document, or click to insert the ${clause.label} clause at the cursor`;
948+
card.draggable = !inDoc; // single-use: can't drag a clause that's already in
949+
card.title = inDoc
950+
? `${clause.label} is in the contract — click to reveal it`
951+
: `Drag into the document, or click to add the ${clause.label} clause at the cursor`;
909952
card.innerHTML = `
910-
<h3 class="clause-label">${escapeHtml(clause.label)}</h3>
911-
<p class="clause-meta">${escapeHtml(clause.category)} · ${escapeHtml(clause.jurisdiction)} · ${escapeHtml(clause.version)} · ${escapeHtml(usedText)}</p>
953+
<div class="clause-head">
954+
<h3 class="clause-label">${escapeHtml(clause.label)}</h3>
955+
<span class="clause-status">${inDoc ? 'In contract' : 'Add clause'}</span>
956+
</div>
957+
<p class="clause-meta">${escapeHtml(clause.category)} · ${escapeHtml(clause.jurisdiction)} · ${escapeHtml(clause.version)}</p>
912958
`;
913-
card.addEventListener('click', () => insertClauseAtCursor(clause.id));
959+
card.addEventListener('click', () => (isClauseInDocument(clause.id) ? revealClause(clause.id) : insertClauseAtCursor(clause.id)));
914960
card.addEventListener('dragstart', (event) => {
915-
if (!event.dataTransfer) return;
961+
if (!event.dataTransfer || isClauseInDocument(clause.id)) return;
916962
event.dataTransfer.setData(CLAUSE_MIME, clause.id);
917963
event.dataTransfer.effectAllowed = 'copy';
918964
});

0 commit comments

Comments
 (0)