From af05f1c7fb20a762df0d928b5657826e8065811a Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 29 May 2026 15:13:21 -0300 Subject: [PATCH 1/5] feat(demo): smart-tags palette that inserts custom-styled SDT fields A searchable "Smart tags" palette in the contract-templates sidebar. Clicking a tag inserts it as an inline content control at the caret, and the inserted field paints with the SAME token look (--tag-* / .smart-tag) as the palette chip - so the sidebar tag and the in-editor field read as one object. This is the core custom-SDT story: turn off built-in chrome, style the painted wrapper, author fields from your own UI. Insert path (verified): ui.selection.capture() -> bridge the TextTarget to a collapsed SelectionTarget -> editor.doc.create.contentControl({ at, content, tag }) -> ui.contentControls.focus(). Adds a behavior test proving collapsed- caret insertion works (no API gap) and a demo acceptance test for chip -> field. --- .../contract-templates-smart-tags.spec.ts | 57 +++++++++++++ demos/contract-templates/src/main.ts | 83 +++++++++++++++++++ demos/contract-templates/src/style.css | 59 +++++++++++-- .../content-control-insert-at-cursor.spec.ts | 57 +++++++++++++ 4 files changed, 251 insertions(+), 5 deletions(-) create mode 100644 demos/__tests__/contract-templates-smart-tags.spec.ts create mode 100644 tests/behavior/tests/sdt/content-control-insert-at-cursor.spec.ts diff --git a/demos/__tests__/contract-templates-smart-tags.spec.ts b/demos/__tests__/contract-templates-smart-tags.spec.ts new file mode 100644 index 0000000000..d8b68335bf --- /dev/null +++ b/demos/__tests__/contract-templates-smart-tags.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from '@playwright/test'; + +/** + * Smart-tags authoring: clicking a tag chip in the sidebar inserts a matching + * inline SDT at the caret (dogfoods ui.selection.capture + create.contentControl + * + ui.contentControls.focus). The inserted field carries the field's tag and + * the token text, and paints with the same .superdoc-structured-content-inline + * wrapper the chips are styled to match. + * + * Runs only for the contract-templates demo (the shared suite runs once per DEMO). + */ + +test('clicking a Smart-tags chip inserts a matching inline SDT at the caret', async ({ page }) => { + test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only'); + + await page.route('**/ingest.superdoc.dev/**', (r) => + r.fulfill({ status: 204, contentType: 'application/json', body: '{}' }), + ); + await page.goto('/'); + await page.waitForFunction( + () => (window as any).__demo?.state?.ui?.contentControls?.getSnapshot()?.items?.length > 0, + null, + { timeout: 30_000 }, + ); + await page.waitForSelector('[data-tag-key]'); + + // Place a caret in the document body so capture() has an insertion point. + await page.evaluate(() => { + (window as any).__demo.superdoc.activeEditor.commands?.setTextSelection?.({ from: 6, to: 6 }); + }); + + const key = await page.getAttribute('[data-tag-key]', 'data-tag-key'); + expect(key).toBeTruthy(); + + // Count existing controls with this tag, then click the chip and expect one more. + const tag = JSON.stringify({ kind: 'smartField', key }); + const token = key!.replace(/([A-Z])/g, '_$1').toUpperCase(); + + const textsForTag = () => + page.evaluate((t) => { + const ed = (window as any).__demo.superdoc.activeEditor; + const out: string[] = []; + ed.state.doc.descendants((node: any) => { + if (node.type.name === 'structuredContent' && node.attrs?.tag === t) out.push(node.textContent); + return true; + }); + return out; + }, tag); + + const before = await textsForTag(); + await page.click(`[data-tag-key="${key}"]`); + + // A new inline SDT carrying this tag + token text should appear. + await expect + .poll(async () => (await textsForTag()).filter((x) => x === token).length, { timeout: 6_000 }) + .toBeGreaterThan(before.filter((x) => x === token).length); +}); diff --git a/demos/contract-templates/src/main.ts b/demos/contract-templates/src/main.ts index e104ff8987..f6a785ec0d 100644 --- a/demos/contract-templates/src/main.ts +++ b/demos/contract-templates/src/main.ts @@ -46,6 +46,9 @@ import { attachFieldChip } from './field-chip.js'; type NodeKind = 'block' | 'inline'; type LockMode = 'unlocked' | 'sdtLocked' | 'contentLocked' | 'sdtContentLocked'; type ContentControlTarget = { kind: NodeKind; nodeType: 'sdt'; nodeId: string }; +// Minimal shapes for inserting at the caret (see `editor.doc.create.contentControl`). +type SelectionPoint = { kind: 'text'; blockId: string; offset: number }; +type SelectionTarget = { kind: 'selection'; start: SelectionPoint; end: SelectionPoint }; type ContentControlInfo = { target: ContentControlTarget; @@ -60,6 +63,16 @@ type MutationResult = | { success: false; failure: { code: string; message: string } }; type DocumentApi = { + create: { + contentControl(input: { + kind: NodeKind; + controlType?: 'text' | 'richText'; + at?: SelectionTarget; + content?: string; + tag?: string; + alias?: string; + }): MutationResult; + }; contentControls: { list(input?: Record): { items: ContentControlInfo[]; total: number }; selectByTag(input: { tag: string }): { items: ContentControlInfo[]; total: number }; @@ -318,6 +331,37 @@ function applyField(key: FieldKey, value: string): void { } } +/** The token text shown inside an unfilled field (e.g. `disclosingParty` -> `DISCLOSING_PARTY`). */ +const tokenFor = (key: FieldKey): string => key.replace(/([A-Z])/g, '_$1').toUpperCase(); + +/** + * Insert a smart-tag field at the caret (authoring). Dogfoods the verified + * recipe: capture the caret as a TextTarget, bridge it to a collapsed + * SelectionTarget, then `create.contentControl` with the token as initial + * content. The new inline SDT paints with the same `.superdoc-structured-content-inline` + * wrapper the palette chips are styled to match. Then focus it so the user can type. + */ +function insertTagAtCursor(key: FieldKey, label: string): void { + const ui = state.ui; + const editor = state.editor; + if (!ui || !editor?.doc) return; + const seg = ui.selection.capture()?.target?.segments?.[0]; + if (!seg) return; // no caret in the document + const point: SelectionPoint = { kind: 'text', blockId: seg.blockId, offset: seg.range.start }; + const result = editor.doc.create.contentControl({ + kind: 'inline', + controlType: 'text', + at: { kind: 'selection', start: point, end: point }, + content: tokenFor(key), + tag: fieldTag(key), + alias: label, + }); + if (result.success) { + state.values[key] = state.values[key] ?? ''; + void ui.contentControls.focus({ id: result.contentControl.nodeId }); + } +} + async function applyClauseVersion(clauseId: ClauseId, toVersion: string, body: string): Promise { const doc = getDoc(); const clause = CLAUSE_LIBRARY.find((c) => c.id === clauseId); @@ -396,8 +440,47 @@ function renderPanels(): void { renderClausesPanel(); } +/** + * Smart-tags palette: a searchable list of variable chips. Clicking a chip + * inserts that field as an inline SDT at the caret (authoring). The chips use + * the same token look (.smart-tag / --tag-*) as the painted in-editor field, so + * the sidebar tag and the inserted field are visually identical. + */ +function renderSmartTagsPalette(): void { + const section = document.createElement('div'); + section.className = 'smart-tags'; + section.innerHTML = ` +

Click a tag to insert it at the cursor.

+ +
Offer
+
+ ${FIELDS.map( + (f) => + ``, + ).join('')} +
+ `; + fieldsPanelEl.appendChild(section); + + section.querySelectorAll('.smart-tag').forEach((btn) => { + btn.addEventListener('click', () => { + const field = FIELDS.find((f) => f.key === (btn.dataset.tagKey as FieldKey)); + if (field) insertTagAtCursor(field.key, field.label); + }); + }); + + const search = section.querySelector('.smart-tags-search'); + search?.addEventListener('input', () => { + const q = search.value.trim().toUpperCase(); + section.querySelectorAll('.smart-tag').forEach((btn) => { + btn.style.display = !q || (btn.textContent ?? '').includes(q) ? '' : 'none'; + }); + }); +} + function renderFieldsPanel(): void { fieldsPanelEl.innerHTML = ''; + renderSmartTagsPalette(); for (const field of FIELDS) { // A
wrapper (not
+
diff --git a/demos/contract-templates/src/main.ts b/demos/contract-templates/src/main.ts index c6220fa1c9..8dc201f35e 100644 --- a/demos/contract-templates/src/main.ts +++ b/demos/contract-templates/src/main.ts @@ -1,40 +1,44 @@ /** - * Contract templates: a runtime workflow on Word content controls. + * Contract templates: build a contract template on Word content controls (SDTs) + * with a fully custom UI. SuperDoc's built-in SDT chrome is off + * (`modules.contentControls.chrome: 'none'`), so the demo paints the field/clause + * look itself (style.css) and drives every interaction through the public + * surface: `editor.doc.*` and `superdoc/ui` (events, viewport.positionAt, + * contentControls.scrollIntoView / focus / setLockMode). * - * The document is a Mutual NDA (`public/nda-template.docx`) - * with content controls already in place: - * - Seven inline plain-text content controls across five field keys - * (disclosing party, receiving party, effective date, purpose, term - * length). Authored via Word's `ContentControls.Add(1, range)`, so their - * `w:sdtPr` carries `` and they resolve as `controlType: 'text'`. - * Receiving party and Purpose each appear twice: once in the header - * sentence and once nested inside the Permitted Use block clause. - * - Six block rich-text content controls (Preamble, Confidentiality, - * Permitted Use, Term and Termination, Governing Law, Limitation of - * Liability). Authored via `ContentControls.Add(0, range)`, which - * produces typeless sdtPr that resolves as `controlType: 'richText'` - * per ECMA-376 §17.5.2.26. Each block carries - * `{ kind: 'reusableSection', sectionId, version }` in its tag. + * The starting document is a Mutual NDA (`public/nda-template.docx`) with + * controls already in place: + * - Inline plain-text fields across five keys (disclosing party, receiving + * party, effective date, purpose, term). Receiving party and Purpose each + * appear twice: in the header sentence and nested inside the Permitted Use + * block clause. + * - Six block rich-text clauses tagged `{ kind: 'reusableSection', sectionId }`. * - * The app: - * 1. Loads the fixture as its starting document. - * 2. Reads each field's text and each clause's version from the parsed SDTs. - * 3. Compares clause versions against the local library and surfaces a - * Review CTA on every stale clause with a one-line summary of the change. - * 4. Field inputs are reactive: typing in a value debounces by ~250ms and - * fans the new text to every occurrence via `selectByTag` + per-occurrence - * `text.setValue` (the typed API path for plain-text controls). - * 5. Review expands a card showing the in-document clause alongside the - * library version. Replace with library clause swaps body via - * `replaceContent` and bumps the tag version via `patch`. - * 6. Export has two paths: raw DOCX keeps content controls for future - * template/library updates; clean DOCX flattens controls to final values. + * The model: + * - Every control is `contentLocked`, so it can't be edited by typing in the + * document. This is a locked template surface; the custom UI drives changes. + * - Template tab = the building-block library. Two catalogs: smart-field chips + * and clause cards (each with category / jurisdiction / version and a "used + * N times" count). Drag or click a chip to insert an inline field, or a card + * to insert a block clause. An unfilled field shows its field-name token + * (e.g. DISCLOSING_PARTY) as a stand-in placeholder - literal text content, + * not a native SDT placeholder. + * - A clause is assembled from structured `parts`: prose plus `{ field }` + * slots. Inserting it creates the block and wraps each slot as a nested, + * locked inline smart field - so an inserted "Permitted Use" carries real + * Receiving party / Purpose fields, like the seeded one. + * - Values tab = fill the fields. Editing a value debounces ~250ms and fans it + * to every occurrence, including ones nested in clauses (the write briefly + * unlocks clauses, since a clause's content lock otherwise vetoes nested + * writes), via `selectByTag` + `text.setValue`. + * - Export: raw DOCX keeps the controls/tags; clean DOCX flattens to values. * - * Every mutation goes through `editor.doc.*`. The same operation set runs - * headless via the Node SDK and CLI. + * Out of scope (deliberately): clause version review / replace. That's a clause + * lifecycle demo; this one proves template assembly. * - * For a packaged React authoring component (`{{` trigger, linked field - * groups, owner/signer types, DOCX export), see `@superdoc-dev/template-builder`. + * Every mutation goes through `editor.doc.*`, so the same operations run headless + * via the Node SDK and CLI. For a packaged React authoring component, see + * `@superdoc-dev/template-builder`. */ import { SuperDoc } from 'superdoc'; @@ -70,12 +74,14 @@ type DocumentApi = { content?: string; tag?: string; alias?: string; + lockMode?: LockMode; }): MutationResult; }; contentControls: { list(input?: Record): { items: ContentControlInfo[]; total: number }; selectByTag(input: { tag: string }): { items: ContentControlInfo[]; total: number }; patch(input: { target: ContentControlTarget; tag?: string; alias?: string }): MutationResult; + setLockMode(input: { target: ContentControlTarget; lockMode: LockMode }): MutationResult; replaceContent(input: { target: ContentControlTarget; content: string; format?: 'text' }): MutationResult; text: { setValue(input: { target: ContentControlTarget; value: string }): MutationResult; @@ -106,76 +112,104 @@ type ClauseId = | 'permittedUse' | 'termination' | 'governingLaw' - | 'limitationOfLiability'; + | 'limitationOfLiability' + | 'indemnification'; -type PreviewSegment = { kind: 'same' | 'insert' | 'delete'; text: string }; +type ClauseCategory = 'Core' | 'Confidentiality' | 'Termination' | 'Risk Allocation'; +/** + * A clause-body part: literal prose, or a `{ field }` slot that becomes a nested + * inline smart-field SDT when the clause is inserted. The slot renders as the + * field's current display (value if filled, otherwise its name token). + */ +type ClausePart = string | { field: FieldKey }; + +/** + * A governed clause in the library catalog: a label + metadata for the sidebar + * card, and the structured `parts` used to assemble the clause when it's + * inserted. The catalog includes clauses that aren't in the document yet. + */ type LibraryClause = { id: ClauseId; label: string; - latestVersion: string; - /** Upgrade prose. Only defined when `latestVersion` differs from v1. */ - upgrade?: { - version: string; - summary: string; - body: string; - /** Hand-authored proposed-change view shown in the review panel. */ - preview: PreviewSegment[]; - }; + category: ClauseCategory; + jurisdiction: string; + version: string; + parts: ClausePart[]; }; const CLAUSE_LIBRARY: LibraryClause[] = [ - { id: 'preamble', label: 'Preamble', latestVersion: 'v1' }, + { + id: 'preamble', + label: 'Preamble', + category: 'Core', + jurisdiction: 'General', + version: 'v1', + parts: [ + 'The parties wish to share Confidential Information for the purposes described above and acknowledge the obligations set out in this Agreement.', + ], + }, { id: 'confidentiality', label: 'Confidentiality Obligations', - latestVersion: 'v2', - upgrade: { - version: 'v2', - summary: 'Extends survival period from 2 years to 5 years.', - body: 'Each party will treat the other party\u2019s Confidential Information as confidential and will protect it with at least the same care it uses for its own confidential information. These obligations survive disclosure for five (5) years.', - preview: [ - { kind: 'same', text: 'Each party will treat the other party\u2019s Confidential Information as confidential and will protect it with at least the same care it uses for its own confidential information. These obligations survive disclosure for ' }, - { kind: 'delete', text: 'two (2) years' }, - { kind: 'insert', text: 'five (5) years' }, - { kind: 'same', text: '.' }, - ], - }, + category: 'Confidentiality', + jurisdiction: 'General', + version: 'v1', + parts: [ + 'Each party will treat the other party’s Confidential Information as confidential and will protect it with at least the same care it uses for its own confidential information. These obligations survive disclosure for two (2) years.', + ], + }, + { + id: 'permittedUse', + label: 'Permitted Use', + category: 'Confidentiality', + jurisdiction: 'General', + version: 'v1', + // Carries nested smart fields: inserting this clause creates real inline + // SDTs for Receiving party and Purpose that fill from the Values form. + parts: [ + 'The ', + { field: 'receivingParty' }, + ' may use Confidential Information solely for ', + { field: 'purpose' }, + ', and for no other purpose, and will limit access to its employees and advisors with a need to know.', + ], + }, + { + id: 'termination', + label: 'Term and Termination', + category: 'Termination', + jurisdiction: 'General', + version: 'v1', + parts: [ + 'Either party may terminate this Agreement upon thirty (30) days’ written notice. Confidentiality obligations survive termination for the period specified above.', + ], }, - { id: 'permittedUse', label: 'Permitted Use', latestVersion: 'v1' }, - { id: 'termination', label: 'Term and Termination', latestVersion: 'v1' }, { id: 'governingLaw', label: 'Governing Law', - latestVersion: 'v2', - upgrade: { - version: 'v2', - summary: 'Changes governing law from California to New York.', - body: 'This Agreement is governed by the laws of the State of New York, without regard to its conflicts of law provisions.', - preview: [ - { kind: 'same', text: 'This Agreement is governed by the laws of the State of ' }, - { kind: 'delete', text: 'California' }, - { kind: 'insert', text: 'New York' }, - { kind: 'same', text: ', without regard to its conflicts of law provisions.' }, - ], - }, + category: 'Core', + jurisdiction: 'US-CA', + version: 'v1', + parts: ['This Agreement is governed by the laws of the State of California, without regard to its conflicts of law provisions.'], }, { id: 'limitationOfLiability', label: 'Limitation of Liability', - latestVersion: 'v2', - upgrade: { - version: 'v2', - summary: 'Extends liability cap from 12 to 24 months and excludes confidentiality and indemnity obligations.', - body: 'Each party\u2019s aggregate liability under this Agreement is limited to fees paid in the twenty-four (24) months preceding the claim. Confidentiality breaches and indemnity obligations are excluded from this cap.', - preview: [ - { kind: 'same', text: 'Each party\u2019s aggregate liability under this Agreement is limited to fees paid in the ' }, - { kind: 'delete', text: 'twelve (12)' }, - { kind: 'insert', text: 'twenty-four (24)' }, - { kind: 'same', text: ' months preceding the claim.' }, - { kind: 'insert', text: ' Confidentiality breaches and indemnity obligations are excluded from this cap.' }, - ], - }, + category: 'Risk Allocation', + jurisdiction: 'General', + version: 'v1', + parts: ['Each party’s aggregate liability under this Agreement is limited to fees paid in the twelve (12) months preceding the claim.'], + }, + { + // A library-only clause: not in the seeded document, so it starts "Used 0". + // Insert it to add a new governed section to the contract. + id: 'indemnification', + label: 'Indemnification', + category: 'Risk Allocation', + jurisdiction: 'General', + version: 'v1', + parts: ['Each party will indemnify and hold the other harmless from third-party claims arising out of its breach of this Agreement.'], }, ]; @@ -188,8 +222,10 @@ type ReusableSectionTag = { kind: 'reusableSection'; sectionId: ClauseId; versio type TagPayload = SmartFieldTag | ReusableSectionTag; const fieldTag = (key: FieldKey) => JSON.stringify({ kind: 'smartField', key } satisfies SmartFieldTag); -const clauseTag = (sectionId: ClauseId, version: string) => - JSON.stringify({ kind: 'reusableSection', sectionId, version } satisfies ReusableSectionTag); +// version is vestigial now (the version lifecycle was removed); inserted clauses +// carry v1 so the tag shape stays valid and parses as a reusableSection. +const clauseTag = (sectionId: ClauseId) => + JSON.stringify({ kind: 'reusableSection', sectionId, version: 'v1' } satisfies ReusableSectionTag); const parseTag = (tag: string | undefined): TagPayload | null => { if (!tag) return null; @@ -209,20 +245,30 @@ const parseTag = (tag: string | undefined): TagPayload | null => { const state = { editor: null as DemoEditor | null, values: {} as Record, - versions: {} as Record, - expandedClause: null as ClauseId | null, /** Smart-tag chip mirrored as active when the caret is in a matching field. */ activeTagKey: null as FieldKey | null, + /** Clause card mirrored as active when the caret is in a matching clause. */ + activeClauseId: null as ClauseId | null, /** UI controller; created in `initialize`, disposed by `teardown`. */ ui: null as ReturnType | null, /** Detaches the document -> palette highlight listeners. */ smartTagSyncTeardown: null as (() => void) | null, + /** Detaches the field drag-and-drop listeners on the editor host. */ + dragDropTeardown: null as (() => void) | null, }; +/** dataTransfer MIME used when dragging a field chip from the palette. */ +const FIELD_MIME = 'application/x-superdoc-field'; +/** dataTransfer MIME used when dragging a clause card from the palette. */ +const CLAUSE_MIME = 'application/x-superdoc-clause'; + const statusEl = qs('#status'); const summaryEl = qs('#summary'); const fieldsPanelEl = qs('#fields-panel'); -const clausesPanelEl = qs('#clauses-panel'); +const valuesPanelEl = qs('#values-panel'); +// The clause cards live in a section inside the Template panel; this container +// is created by renderClausesSection() and re-rendered by renderClausesPanel(). +let clausesListEl: HTMLElement | null = null; setBusy(true); @@ -234,7 +280,13 @@ const superdoc = new SuperDoc({ // highlight). The wrappers and data-sdt-* datasets are preserved, so the demo // paints its own field look in style.css and drives its own UI (Smart-tags // palette, Locate/Focus) through the public surface. - modules: { comments: false, contentControls: { chrome: 'none' } }, + modules: { + comments: false, + contentControls: { chrome: 'none' }, + // responsiveToContainer collapses toolbar items that overflow the editor + // column into an overflow menu, so the toolbar can't spill over the sidebar. + toolbar: { selector: '#superdoc-toolbar', responsiveToContainer: true }, + }, telemetry: { enabled: false }, onReady: ({ superdoc: sd }) => void initialize(sd as DemoSuperDoc), }); @@ -277,7 +329,12 @@ async function initialize(instance: DemoSuperDoc): Promise { return; } state.editor = instance.activeEditor; - readStateFromDocument(); + // Show each field's name as a placeholder and lock it; values are filled only + // through the Values form, which starts empty (see showFieldNamesAndLock). + showFieldNamesAndLock(); + // Lock the seeded clause blocks too, so their prose can't be edited by typing + // in the document. Fields nested in them still fill through the Values form. + lockClauses(); renderPanels(); refreshSummary(); @@ -292,12 +349,16 @@ async function initialize(instance: DemoSuperDoc): Promise { const onTokenClick = ({ target }: { target: { tag?: string } }) => { const parsed = target?.tag ? parseTag(target.tag) : null; state.activeTagKey = parsed?.kind === 'smartField' ? (parsed.key as FieldKey) : null; + state.activeClauseId = parsed?.kind === 'reusableSection' ? (parsed.sectionId as ClauseId) : null; highlightActiveTag(); + highlightActiveClause(); }; const onActiveChange = ({ active }: { active: { tag?: string } | null }) => { if (active) return; state.activeTagKey = null; + state.activeClauseId = null; highlightActiveTag(); + highlightActiveClause(); }; instance.on('content-control:click', onTokenClick); instance.on('content-control:active-change', onActiveChange); @@ -306,35 +367,96 @@ async function initialize(instance: DemoSuperDoc): Promise { instance.off('content-control:active-change', onActiveChange); }; + // Palette -> document: drag a field or clause onto the editor to insert it at + // the drop point (dogfoods ui.viewport.positionAt + create.contentControl). + state.dragDropTeardown = setupPaletteDragDrop(); + setStatus('Ready'); setBusy(false); } -/** Read field values and clause versions from the loaded fixture. */ -function readStateFromDocument(): void { - const doc = getDoc(); - for (const ctrl of doc.contentControls.list({}).items) { - const tag = parseTag(ctrl.properties?.tag); - if (!tag) continue; - if (tag.kind === 'smartField') { - state.values[tag.key] = ctrl.text ?? ''; - } else if (tag.kind === 'reusableSection') { - state.versions[tag.sectionId] = tag.version; - } - } -} // --------------------------------------------------------------------------- // Mutations: smart fields, clause updates, export // --------------------------------------------------------------------------- -/** Push a single field's value to every occurrence in the document. */ +/** + * Push a field's value to every occurrence. The field controls are + * `contentLocked` so a user can't type into them in the document; the Values + * form is the only writer. `text.setValue` is itself blocked on a locked + * control, so briefly unlock, write, then relock. The relock is in `finally` + * so a failed write never strands a field unlocked (editable by the user). + */ function applyField(key: FieldKey, value: string): void { - if (!state.editor?.doc) return; + const doc = state.editor?.doc; + if (!doc) return; state.values[key] = value; - const { items } = state.editor.doc.contentControls.selectByTag({ tag: fieldTag(key) }); - for (const ctrl of items) { - state.editor.doc.contentControls.text.setValue({ target: ctrl.target, value }); + // Filled -> the value; cleared -> back to the field-name placeholder. + const display = fieldDisplay(key); + + // A field can sit inside a clause (Receiving party / Purpose appear inside the + // Permitted Use clause). A clause's content lock SILENTLY vetoes writes to + // anything nested in it - text.setValue even reports success - so the value + // wouldn't broadcast to the nested occurrence. Briefly unlock every clause + // around the write, then relock them in `finally` so they never stay unlocked. + const clauseControls = () => + doc.contentControls.list({}).items.filter((c) => parseTag(c.properties?.tag)?.kind === 'reusableSection'); + for (const c of clauseControls()) { + reportMutation(doc.contentControls.setLockMode({ target: c.target, lockMode: 'unlocked' }), 'Unlock clause'); + } + try { + for (const ctrl of doc.contentControls.selectByTag({ tag: fieldTag(key) }).items) { + // Skip the write if unlock fails - the field stays locked (safe), just stale. + if (!reportMutation(doc.contentControls.setLockMode({ target: ctrl.target, lockMode: 'unlocked' }), `Unlock ${key}`)) { + continue; + } + try { + reportMutation(doc.contentControls.text.setValue({ target: ctrl.target, value: display }), `Update ${key}`); + } finally { + // A failed relock would leave the field editable, so make it loud. + reportMutation(doc.contentControls.setLockMode({ target: ctrl.target, lockMode: 'contentLocked' }), `Relock ${key}`); + } + } + } finally { + for (const c of clauseControls()) { + reportMutation(doc.contentControls.setLockMode({ target: c.target, lockMode: 'contentLocked' }), 'Relock clause'); + } + } +} + +/** + * Put the document into its starting template state. Each smart field's content + * is set to its field-name token (e.g. DISCLOSING_PARTY) as a stand-in + * placeholder - this is literal text content, NOT a native SDT placeholder + * (those are renderer-hardcoded and not settable via the API). Then each field + * is `contentLocked`, so values change only through the Values form, never by + * typing in the document. The form starts empty (every field unfilled). Content + * is written before locking, since a locked control rejects content writes. + */ +function showFieldNamesAndLock(): void { + const doc = state.editor?.doc; + if (!doc) return; + for (const field of FIELDS) { + state.values[field.key] = ''; + for (const ctrl of doc.contentControls.selectByTag({ tag: fieldTag(field.key) }).items) { + reportMutation(doc.contentControls.text.setValue({ target: ctrl.target, value: fieldDisplay(field.key) }), `Reset ${field.key}`); + reportMutation(doc.contentControls.setLockMode({ target: ctrl.target, lockMode: 'contentLocked' }), `Lock ${field.key}`); + } + } +} + +/** + * Lock every clause block as `contentLocked`, like the inline fields, so its + * prose can't be edited by typing in the document. The clauses are a fixed, + * read-only part of the loaded template. + */ +function lockClauses(): void { + const doc = state.editor?.doc; + if (!doc) return; + for (const ctrl of doc.contentControls.list({}).items) { + if (parseTag(ctrl.properties?.tag)?.kind === 'reusableSection') { + reportMutation(doc.contentControls.setLockMode({ target: ctrl.target, lockMode: 'contentLocked' }), 'Lock clause'); + } } } @@ -342,63 +464,205 @@ function applyField(key: FieldKey, value: string): void { const tokenFor = (key: FieldKey): string => key.replace(/([A-Z])/g, '_$1').toUpperCase(); /** - * Insert a smart-tag field at the caret (authoring). Dogfoods the verified - * recipe: capture the caret as a TextTarget, bridge it to a collapsed - * SelectionTarget, then `create.contentControl` with the token as initial - * content. The new inline SDT paints with the same `.superdoc-structured-content-inline` - * wrapper the palette chips are styled to match. Then focus it so the user can type. + * What a field control should display: the entered value if the field is filled, + * otherwise its field-name token (e.g. `DISCLOSING_PARTY`) as a visible + * placeholder. The Values form is the source of truth for filled/unfilled. */ -function insertTagAtCursor(key: FieldKey, label: string): void { +const fieldDisplay = (key: FieldKey): string => { + const value = state.values[key] ?? ''; + return value.trim() ? value : tokenFor(key); +}; + +/** + * Insert a smart-tag field as an inline SDT at `target` (a collapsed + * SelectionTarget). The control shows the field name as its placeholder + * (`fieldDisplay`, e.g. DISCLOSING_PARTY) and is `contentLocked`, so it behaves + * like the seeded fields: filled only through the Values form. It's tagged so it + * paints with the same `.superdoc-structured-content-inline` look as the palette + * chips. Shared by click-to-insert (caret) and drag-and-drop (drop point); only + * how `target` is resolved differs. Then scroll it into view so the user sees it. + */ +function insertField(key: FieldKey, label: string, target: SelectionTarget): void { const ui = state.ui; const editor = state.editor; if (!ui || !editor?.doc) return; - const seg = ui.selection.capture()?.target?.segments?.[0]; - if (!seg) { - // No caret to insert at — tell the user instead of silently no-op'ing. - setStatus('Place the cursor in the document, then click a tag to insert it.'); - return; - } - const point: SelectionPoint = { kind: 'text', blockId: seg.blockId, offset: seg.range.start }; const result = editor.doc.create.contentControl({ kind: 'inline', controlType: 'text', - at: { kind: 'selection', start: point, end: point }, - content: tokenFor(key), + at: target, + content: fieldDisplay(key), tag: fieldTag(key), alias: label, + lockMode: 'contentLocked', }); if (result.success) { state.values[key] = state.values[key] ?? ''; - void ui.contentControls.focus({ id: result.contentControl.nodeId }); + void ui.contentControls.scrollIntoView({ id: result.contentControl.nodeId, block: 'center' }); } } -async function applyClauseVersion(clauseId: ClauseId, toVersion: string, body: string): Promise { - const doc = getDoc(); +/** + * Insert a field at the caret (click-to-insert). Captures the caret as a + * TextTarget and bridges it to a collapsed SelectionTarget (the verified recipe). + */ +function insertFieldAtCursor(key: FieldKey, label: string): void { + const ui = state.ui; + if (!ui || !state.editor?.doc) return; + const seg = ui.selection.capture()?.target?.segments?.[0]; + if (!seg) { + // No caret to insert at — tell the user instead of silently no-op'ing. + setStatus('Place the cursor in the document (or drag the field in), then click a tag to insert it.'); + return; + } + const point: SelectionPoint = { kind: 'text', blockId: seg.blockId, offset: seg.range.start }; + insertField(key, label, { kind: 'selection', start: point, end: point }); +} + +/** The clause's plain text; each field slot renders as its current display. */ +function clauseText(clause: LibraryClause): string { + return clause.parts.map((part) => (typeof part === 'string' ? part : fieldDisplay(part.field))).join(''); +} + +/** Character ranges of each field slot within `clauseText`, for wrapping as SDTs. */ +function clauseFieldRanges(clause: LibraryClause): { field: FieldKey; start: number; end: number }[] { + const ranges: { field: FieldKey; start: number; end: number }[] = []; + let offset = 0; + for (const part of clause.parts) { + const text = typeof part === 'string' ? part : fieldDisplay(part.field); + if (typeof part !== 'string') ranges.push({ field: part.field, start: offset, end: offset + text.length }); + offset += text.length; + } + return ranges; +} + +/** + * Insert a governed clause as a locked block SDT at the START of `blockId` + * (offset 0, a clean block boundary - inserting at the raw drop caret would + * split a paragraph mid-text). The clause is assembled from its parts: the block + * holds the prose, and each `{ field }` slot is wrapped as a nested, locked + * inline smart-field SDT - so an inserted "Permitted Use" carries real Receiving + * party / Purpose fields that fill from the Values form, like the seeded one. + * Inserts unlocked, wraps the slots, then locks the clause. + */ +async function insertClause(clauseId: ClauseId, blockId: string): Promise { + const ui = state.ui; + const editor = state.editor; + if (!ui || !editor?.doc) return; + const doc = editor.doc; const clause = CLAUSE_LIBRARY.find((c) => c.id === clauseId); if (!clause) return; - const ctrl = findClauseControl(clauseId); - if (!ctrl) throw new Error(`Clause ${clauseId} not in document`); + const point: SelectionPoint = { kind: 'text', blockId, offset: 0 }; + const created = doc.create.contentControl({ + kind: 'block', + controlType: 'richText', + at: { kind: 'selection', start: point, end: point }, + content: clauseText(clause), + tag: clauseTag(clauseId), + alias: clause.label, + lockMode: 'unlocked', // unlocked so the field slots can be wrapped, then locked + }); + if (!reportMutation(created, `Insert ${clause.label}`) || !created.success) return; + const clauseTarget = created.contentControl; + + // Wrap each field slot as a nested inline smart-field SDT. Focus the new block + // to resolve its inner text blockId (no coordinates needed), then wrap by + // character range - last slot first, so wrapping one can't shift another's + // offsets. + await ui.contentControls.focus({ id: clauseTarget.nodeId }); + const innerBlockId = ui.selection.capture()?.target?.segments?.[0]?.blockId; + if (innerBlockId) { + for (const range of [...clauseFieldRanges(clause)].reverse()) { + reportMutation( + doc.create.contentControl({ + kind: 'inline', + controlType: 'text', + at: { + kind: 'selection', + start: { kind: 'text', blockId: innerBlockId, offset: range.start }, + end: { kind: 'text', blockId: innerBlockId, offset: range.end }, + }, + tag: fieldTag(range.field), + alias: FIELDS.find((f) => f.key === range.field)?.label ?? range.field, + lockMode: 'contentLocked', + }), + `Nest ${range.field}`, + ); + } + } - assertMutation( - doc.contentControls.replaceContent({ target: ctrl.target, content: body, format: 'text' }), - `Could not update ${clause.label}`, - true, - ); + // Lock the clause now that its slots are wrapped, then refresh the cards + // ("Used N") and scroll the new clause into view. + reportMutation(doc.contentControls.setLockMode({ target: clauseTarget, lockMode: 'contentLocked' }), 'Lock clause'); + renderClausesPanel(); + void ui.contentControls.scrollIntoView({ id: clauseTarget.nodeId, block: 'center' }); +} - const refreshed = findClauseControl(clauseId) ?? ctrl; - assertMutation( - doc.contentControls.patch({ - target: refreshed.target, - tag: clauseTag(clauseId, toVersion), - alias: `${clause.label} (${toVersion})`, - }), - `Could not patch clause tag for ${clause.label}`, - true, - ); +/** Insert a clause at the caret's block boundary (click-to-insert). */ +function insertClauseAtCursor(clauseId: ClauseId): void { + const ui = state.ui; + if (!ui || !state.editor?.doc) return; + const seg = ui.selection.capture()?.target?.segments?.[0]; + if (!seg) { + setStatus('Place the cursor in the document (or drag the clause in), then click a clause to insert it.'); + return; + } + void insertClause(clauseId, seg.blockId); +} - state.versions[clauseId] = toVersion; +/** + * Palette -> document drag-and-drop for both building blocks. Resolves the drop + * point with the public `ui.viewport.positionAt`, then: a field inserts inline + * at the exact caret; a clause inserts as a block at the drop block's boundary + * (see insertClause). Returns a teardown. + */ +function setupPaletteDragDrop(): () => void { + const host = qs('#editor'); + const draggingPaletteItem = (event: DragEvent) => + event.dataTransfer?.types.includes(FIELD_MIME) || event.dataTransfer?.types.includes(CLAUSE_MIME); + + const onDragOver = (event: DragEvent): void => { + if (!draggingPaletteItem(event)) return; + // preventDefault on dragover is what makes an element a valid drop target. + event.preventDefault(); + event.dataTransfer!.dropEffect = 'copy'; + host.classList.add('drop-target'); + }; + const onDragLeave = (event: DragEvent): void => { + // Only clear when leaving the host itself, not when crossing child nodes. + if (event.target === host) host.classList.remove('drop-target'); + }; + const onDrop = (event: DragEvent): void => { + host.classList.remove('drop-target'); + const fieldKey = event.dataTransfer?.getData(FIELD_MIME) as FieldKey | ''; + const clauseId = event.dataTransfer?.getData(CLAUSE_MIME) as ClauseId | ''; + if (!fieldKey && !clauseId) return; + event.preventDefault(); + const hit = state.ui?.viewport.positionAt({ x: event.clientX, y: event.clientY }); + // A text caret is the only droppable target (a node-edge hit has no offset). + if (!hit || hit.point.kind !== 'text') { + setStatus('Drop onto the document text.'); + return; + } + if (fieldKey) { + const field = FIELDS.find((f) => f.key === fieldKey); + if (!field) return; + const point: SelectionPoint = { kind: 'text', blockId: hit.point.blockId, offset: hit.point.offset }; + insertField(field.key, field.label, { kind: 'selection', start: point, end: point }); + } else if (clauseId) { + const clause = CLAUSE_LIBRARY.find((c) => c.id === clauseId); + if (clause) void insertClause(clause.id, hit.point.blockId); // offset 0 (block boundary) + } + }; + + host.addEventListener('dragover', onDragOver); + host.addEventListener('dragleave', onDragLeave); + host.addEventListener('drop', onDrop); + return () => { + host.removeEventListener('dragover', onDragOver); + host.removeEventListener('dragleave', onDragLeave); + host.removeEventListener('drop', onDrop); + }; } async function exportDocument(mode: 'raw' | 'clean'): Promise { @@ -448,7 +712,7 @@ function focusByTag(tag: string): void { function renderPanels(): void { renderFieldsPanel(); - renderClausesPanel(); + renderValuesPanel(); } /** @@ -461,22 +725,27 @@ function renderSmartTagsPalette(): void { const section = document.createElement('div'); section.className = 'smart-tags'; section.innerHTML = ` -

Click a tag to insert it at the cursor.

- -
Offer
+

Drag a field into the document, or click to insert it at the cursor.

+ +
Template fields
${FIELDS.map( (f) => - ``, + ``, ).join('')}
`; fieldsPanelEl.appendChild(section); section.querySelectorAll('.smart-tag').forEach((btn) => { + const field = FIELDS.find((f) => f.key === (btn.dataset.tagKey as FieldKey)); btn.addEventListener('click', () => { - const field = FIELDS.find((f) => f.key === (btn.dataset.tagKey as FieldKey)); - if (field) insertTagAtCursor(field.key, field.label); + if (field) insertFieldAtCursor(field.key, field.label); + }); + btn.addEventListener('dragstart', (event) => { + if (!field || !event.dataTransfer) return; + event.dataTransfer.setData(FIELD_MIME, field.key); + event.dataTransfer.effectAllowed = 'copy'; }); }); @@ -503,9 +772,66 @@ function highlightActiveTag(): void { }); } +/** + * Mirror the active clause: the card whose id matches `state.activeClauseId` + * gets `.is-active`. Driven by `content-control:click` on a clause block (and + * cleared on blur) — the clauses' half of the document -> sidebar loop. + */ +function highlightActiveClause(): void { + clausesListEl?.querySelectorAll('.clause').forEach((card) => { + card.classList.toggle('is-active', card.dataset.clauseId === state.activeClauseId); + }); +} + +/** + * Template tab: the contract's building blocks. Two catalogs - inline Smart tags + * (variable chips) and block Clauses (cards with metadata + usage count). Both + * drag or click to insert; values are filled on the Values tab. + */ function renderFieldsPanel(): void { fieldsPanelEl.innerHTML = ''; renderSmartTagsPalette(); + renderClausesSection(); +} + +/** + * Clauses section of the Template tab: a search + the clause cards. Mirrors the + * Smart-tags section's style (group header, search) but the clauses render as + * compact amber cards, not pills, since they're block controls. Creates the + * list container (clausesListEl) that renderClausesPanel re-renders into. + */ +function renderClausesSection(): void { + const section = document.createElement('div'); + section.className = 'clauses-section'; + section.innerHTML = ` +
Clauses
+

Drag a clause into the document, or click to insert it at the cursor.

+ +
+ `; + fieldsPanelEl.appendChild(section); + clausesListEl = section.querySelector('.clauses-list'); + + const search = section.querySelector('.clauses-search'); + search?.addEventListener('input', () => { + const q = search.value.trim().toLowerCase(); + clausesListEl?.querySelectorAll('.clause').forEach((card) => { + const label = (card.querySelector('.clause-label')?.textContent ?? '').toLowerCase(); + card.style.display = !q || label.includes(q) ? '' : 'none'; + }); + }); + + renderClausesPanel(); +} + +/** + * Values tab: fill the fields that are in the document. Editing a value + * debounces ~250ms and fans it to every occurrence of that field's tag + * (`selectByTag` + per-occurrence `text.setValue`). Locate/Focus jump to the + * first occurrence. + */ +function renderValuesPanel(): void { + valuesPanelEl.innerHTML = ''; for (const field of FIELDS) { // A
wrapper (not
- + `; - fieldsPanelEl.appendChild(row); + valuesPanelEl.appendChild(row); row.querySelector('.locate')?.addEventListener('click', () => { locateByTag(fieldTag(field.key)); }); @@ -545,97 +871,63 @@ function renderFieldsPanel(): void { } } +/** + * Render the clause cards: one card per clause, styled like the in-document + * block clause (amber left rail). Like the smart-tag chips, a card is draggable + * into the document or click-to-insert at the cursor (insertClause snaps to a + * block boundary). A card highlights when its clause is clicked in the document. + */ +/** Every control in the document for a given clause (a clause can be placed more than once). */ +function clauseControls(clauseId: ClauseId): ContentControlInfo[] { + const doc = state.editor?.doc; + if (!doc) return []; + return doc.contentControls.list({}).items.filter((c) => { + const t = parseTag(c.properties?.tag); + return t?.kind === 'reusableSection' && t.sectionId === clauseId; + }); +} + +/** + * Render the clause library catalog: a card per available clause with its + * category / jurisdiction / version and how many times it's placed in the + * document. A card wears the in-document block clause's amber look. Drag a card + * in, or click to insert it at the cursor; the card highlights when its clause + * is clicked in the document. + */ function renderClausesPanel(): void { - clausesPanelEl.innerHTML = ''; + const list = clausesListEl; + if (!list) return; + list.innerHTML = ''; for (const clause of CLAUSE_LIBRARY) { - const inDoc = state.versions[clause.id] ?? clause.latestVersion; - const stale = clause.upgrade != null && inDoc !== clause.latestVersion; - const expanded = stale && state.expandedClause === clause.id; - + const used = clauseControls(clause.id).length; + const usedText = used === 0 ? 'Not used' : `Used ${used} time${used === 1 ? '' : 's'}`; const card = document.createElement('article'); - card.className = 'clause' + (stale ? ' stale' : ' current') + (expanded ? ' expanded' : ''); - - if (stale && clause.upgrade) { - const upgrade = clause.upgrade; - const previewHtml = upgrade.preview.map(renderSegment).join(''); - card.innerHTML = ` -
-

${escapeHtml(clause.label)}

-
- Update available - - -
-
-

${escapeHtml(upgrade.summary)}

-

Document ${escapeHtml(inDoc)} \u00b7 Library ${escapeHtml(upgrade.version)}

- - ${ - expanded - ? ` -
-
Proposed change
-

${previewHtml}

- -
- ` - : '' - } - `; - card.querySelector('.clause-review')?.addEventListener('click', () => { - state.expandedClause = expanded ? null : clause.id; - renderClausesPanel(); - }); - card.querySelector('.clause-replace')?.addEventListener('click', () => { - void run(`${clause.label}: replaced with library clause`, async () => { - await applyClauseVersion(clause.id, upgrade.version, upgrade.body); - state.expandedClause = null; - }); - }); - } else { - card.innerHTML = ` -
-

${escapeHtml(clause.label)}

-
- Current - - -
-
-

Document ${escapeHtml(inDoc)}

- `; - } - - card.querySelector('.locate')?.addEventListener('click', () => { - locateByTag(clauseTag(clause.id, inDoc)); - }); - card.querySelector('.focus')?.addEventListener('click', () => { - focusByTag(clauseTag(clause.id, inDoc)); + card.className = 'clause' + (clause.id === state.activeClauseId ? ' is-active' : ''); + card.dataset.clauseId = clause.id; + card.draggable = true; + card.title = `Drag into the document, or click to insert the ${clause.label} clause at the cursor`; + card.innerHTML = ` +

${escapeHtml(clause.label)}

+

${escapeHtml(clause.category)} · ${escapeHtml(clause.jurisdiction)} · ${escapeHtml(clause.version)} · ${escapeHtml(usedText)}

+ `; + card.addEventListener('click', () => insertClauseAtCursor(clause.id)); + card.addEventListener('dragstart', (event) => { + if (!event.dataTransfer) return; + event.dataTransfer.setData(CLAUSE_MIME, clause.id); + event.dataTransfer.effectAllowed = 'copy'; }); - clausesPanelEl.appendChild(card); + list.appendChild(card); } } function refreshSummary(): void { - const stale = CLAUSE_LIBRARY.filter( - (c) => c.upgrade != null && (state.versions[c.id] ?? c.latestVersion) !== c.latestVersion, - ).length; - const updateText = stale === 0 ? 'all clauses current' : `${stale} update${stale === 1 ? '' : 's'} available`; - summaryEl.textContent = `${FIELDS.length} fields \u00b7 ${CLAUSE_LIBRARY.length} clauses \u00b7 ${updateText}`; + summaryEl.textContent = `${FIELDS.length} fields \u00b7 ${CLAUSE_LIBRARY.length} clauses`; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -function findClauseControl(clauseId: ClauseId): ContentControlInfo | undefined { - const doc = getDoc(); - return doc.contentControls.list({}).items.find((ctrl) => { - const t = parseTag(ctrl.properties?.tag); - return t?.kind === 'reusableSection' && t.sectionId === clauseId; - }); -} - async function run(status: string, action: () => Promise): Promise { setBusy(true); setStatus('Working'); @@ -656,10 +948,18 @@ function getDoc(): DocumentApi { return state.editor.doc; } -function assertMutation(result: MutationResult, message: string, allowNoOp = false): void { - if (result.success) return; - if (allowNoOp && result.failure.code === 'NO_OP') return; - throw new Error(result.failure.message || message); +/** + * Surface a failed mutation instead of swallowing it. Returns whether it + * succeeded so callers can branch (e.g. skip the write if the unlock failed). + * NO_OP (value already matches) is treated as success. Used on the form-only + * write path, where a silent failure would leave a field stale or - worse, on a + * failed relock - editable by the user. + */ +function reportMutation(result: MutationResult, context: string): boolean { + if (result.success || result.failure.code === 'NO_OP') return true; + console.error(`[contract-templates] ${context} failed:`, result.failure); + setStatus(`${context} failed: ${result.failure.message}`); + return false; } function setBusy(busy: boolean): void { @@ -682,13 +982,6 @@ function escapeHtml(s: string): string { return s.replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[ch]!); } -function renderSegment(seg: PreviewSegment): string { - const text = escapeHtml(seg.text); - if (seg.kind === 'insert') return `${text}`; - if (seg.kind === 'delete') return `${text}`; - return text; -} - function escapeAttr(s: string): string { return escapeHtml(s).replace(/'/g, '''); } @@ -709,6 +1002,12 @@ const teardown = () => { /* best-effort teardown */ } state.smartTagSyncTeardown = null; + try { + state.dragDropTeardown?.(); + } catch { + /* best-effort teardown */ + } + state.dragDropTeardown = null; try { state.ui?.destroy(); } catch { diff --git a/demos/contract-templates/src/style.css b/demos/contract-templates/src/style.css index 93dc1a918f..dab6743569 100644 --- a/demos/contract-templates/src/style.css +++ b/demos/contract-templates/src/style.css @@ -34,7 +34,30 @@ button { cursor: pointer; } .app { display: grid; grid-template-columns: 1fr 360px; height: 100vh; } .editor-area { display: flex; flex-direction: column; min-width: 0; min-height: 0; } -.editor-area #editor { flex: 1; overflow: auto; padding: 12px; } +/* SuperDoc's formatting toolbar, rendered into #superdoc-toolbar. Clip to the + editor column so overflowing items can't paint over the sidebar (they collapse + into the responsive overflow menu instead; popovers append to ). */ +#superdoc-toolbar { + border-bottom: 1px solid var(--demo-border); + background: var(--demo-bg); + overflow: hidden; + min-width: 0; +} +/* Center the page horizontally; align to top so multi-page docs scroll. */ +.editor-area #editor { + flex: 1; + overflow: auto; + padding: 12px; + display: flex; + justify-content: center; + align-items: flex-start; +} +/* Drop zone: highlight the editor while a field chip is dragged over it. */ +.editor-area #editor.drop-target { + outline: 2px dashed var(--demo-accent); + outline-offset: -4px; + background: var(--demo-accent-soft); +} .toolbar { display: flex; @@ -136,9 +159,9 @@ input:focus { } .btn:disabled { color: var(--demo-text-muted); cursor: not-allowed; opacity: 0.55; } -/* "Locate" — scroll a control into view (ui.contentControls.scrollIntoView); - "Focus" — scroll AND place the caret inside it (ui.contentControls.focus). */ -.clause-actions { display: flex; align-items: center; gap: 8px; } +/* Field rows (Values tab): "Locate" scrolls a control into view + (ui.contentControls.scrollIntoView); "Focus" scrolls AND places the caret + inside it (ui.contentControls.focus). */ .row-actions { display: flex; align-items: center; gap: 6px; } .locate, .focus { @@ -161,96 +184,41 @@ input:focus { #fields-panel .btn { width: 100%; margin-top: 12px; } /* ----------------------------------------------------------------------- - Clauses panel + Clause library (Template tab) + + A catalog of governed clauses. Each card echoes the in-document block clause + (amber left rail, soft border, faint amber fill) and shows its metadata and + how many times it's placed. Drag a card in, or click to insert at the cursor. + The clauses themselves are locked blocks. ----------------------------------------------------------------------- */ +.clauses-section { margin-top: 16px; } + .clause { - border: 1px solid var(--demo-border); - border-radius: var(--sd-radius-100, 6px); - padding: 10px 12px; - margin-bottom: 8px; - background: var(--demo-bg); -} -.clause.current { - background: transparent; - border-color: var(--demo-border); -} -.clause.stale { - background: var(--demo-stale-soft); - border-color: var(--demo-accent); -} -.clause-header { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; - margin-bottom: 4px; + border: 1px solid var(--tag-block-border); + border-left: 4px solid var(--tag-color); + border-radius: var(--tag-radius); + background-color: var(--tag-block-bg); + padding: 7px 10px; + margin-bottom: 6px; + cursor: grab; } +.clause:hover { background-color: var(--tag-block-bg-hover); } +.clause:active { cursor: grabbing; } +.clause.is-active { box-shadow: 0 0 0 2px var(--tag-color); } .clause-label { margin: 0; font-size: var(--sd-font-size-300, 13px); font-weight: 600; color: var(--demo-text); -} -.clause-status { - font-size: var(--sd-font-size-200, 11px); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--demo-accent); -} -.clause-status.muted { color: var(--demo-text-muted); } -.clause-summary { - margin: 0 0 6px; - font-size: var(--sd-font-size-300, 13px); - color: var(--demo-text); - line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .clause-meta { - margin: 0 0 8px; - font-size: var(--sd-font-size-200, 12px); - color: var(--demo-text-muted); -} -.clause .btn { width: 100%; } -.clause-review-panel { - margin-top: 10px; - padding-top: 10px; - border-top: 1px solid var(--demo-accent); - display: flex; - flex-direction: column; - gap: 10px; -} -.review-label { - font-size: var(--sd-font-size-200, 11px); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--demo-text-muted); -} -.clause-preview { - margin: 0; - padding: 10px 12px; - background: var(--demo-bg); - border: 1px solid var(--demo-border); - border-radius: var(--sd-radius-50, 4px); - font-size: var(--sd-font-size-300, 13px); - line-height: 1.5; - color: var(--demo-text); -} -.clause-preview ins { - background: color-mix(in srgb, #15803d 18%, transparent); - color: var(--demo-text); - text-decoration: none; - padding: 0 2px; - border-radius: 2px; -} -.clause-preview del { + margin: 2px 0 0; + font-size: var(--sd-font-size-100, 11px); color: var(--demo-text-muted); - text-decoration: line-through; - text-decoration-color: #d92e2e; - background: color-mix(in srgb, #d92e2e 12%, transparent); - padding: 0 2px; - border-radius: 2px; } .status { @@ -276,9 +244,12 @@ input:focus { /* Smart-tag token look, shared by the in-editor inline SDT and the Smart-tags palette chips so a sidebar tag and the field it inserts read as one object. */ :root { - /* Smart tags get their own amber identity (distinct from the blue UI accent), - echoing a typical template-variable palette. One token set, applied to both - the palette chip and the painted in-editor field. */ + /* Template fields wear a deliberate amber identity, distinct from the + SuperDoc-blue UI accent (--demo-accent), so a reader can see "this is a + template variable" at a glance. Amber isn't a built-in --sd-* token, so + this is an intentional demo-local accent (the theming the demo is showing + off, per demos/AGENTS.md). One token set, applied to both the palette chip + and the painted in-editor field. */ --tag-color: var(--sd-color-amber-600, #d97706); --tag-border: var(--tag-color); --tag-bg: color-mix(in srgb, var(--tag-color) 12%, transparent); @@ -352,7 +323,7 @@ input:focus { font-size: 14px; font-weight: 500; line-height: 1.2; - cursor: pointer; + cursor: grab; padding: 1px 6px; border: 1px solid var(--tag-border); border-radius: var(--tag-radius); @@ -361,6 +332,7 @@ input:focus { white-space: nowrap; } .smart-tag:hover { background: var(--tag-bg-hover); } +.smart-tag:active { cursor: grabbing; } .smart-tag.is-active { box-shadow: 0 0 0 2px var(--demo-accent); } .smart-tag:focus-visible { outline: 1px solid var(--demo-accent); outline-offset: 1px; } From 955b2a3b28ef9c41dbe0566d741d1796f7e44059 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 29 May 2026 20:08:14 -0300 Subject: [PATCH 5/5] 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. --- .../contract-templates-smart-tags.spec.ts | 72 +++++++----- demos/contract-templates/README.md | 4 +- demos/contract-templates/src/main.ts | 104 +++++++++++++----- demos/contract-templates/src/style.css | 68 ++++++++---- 4 files changed, 172 insertions(+), 76 deletions(-) diff --git a/demos/__tests__/contract-templates-smart-tags.spec.ts b/demos/__tests__/contract-templates-smart-tags.spec.ts index 82008ad27f..face61bf61 100644 --- a/demos/__tests__/contract-templates-smart-tags.spec.ts +++ b/demos/__tests__/contract-templates-smart-tags.spec.ts @@ -132,7 +132,7 @@ test('a smart-field pill does not shift its box on hover or click (no jitter)', } }); -test('a block clause keeps its amber left rail and box across hover/select (no jitter)', async ({ page }) => { +test('a block clause keeps its left rail and box across hover/select (no jitter)', async ({ page }) => { test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only'); 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 await page.waitForSelector(sel); // Block SDTs strip border + fill on .sdt-group-hover / .ProseMirror-selectednode; - // the demo overrides them. Guard the 4px amber left rail and box stay constant. + // the demo overrides them. Guard the 4px left rail and box stay constant. const box = () => page.evaluate((s) => { 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 .toBe(2); }); -test('clicking a clause card inserts a locked block clause at the cursor', async ({ page }) => { +test('the clause library is single-use: seeded clauses are In contract, others Add clause', async ({ page }) => { test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only'); 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 ); await page.waitForSelector('.clause[data-clause-id]'); - // Caret in the (unlocked) title so the clause inserts at a clean block boundary. + // A seeded clause is already in the contract; a library-only one is available. + await expect(page.locator('.clause[data-clause-id="permittedUse"] .clause-status')).toHaveText('In contract'); + await expect(page.locator('.clause[data-clause-id="permittedUse"]')).toHaveClass(/is-present/); + await expect(page.locator('.clause[data-clause-id="indemnification"] .clause-status')).toHaveText('Add clause'); + await expect(page.locator('.clause[data-clause-id="indemnification"]')).toHaveClass(/is-available/); +}); + +test('clicking an available clause adds it once (single-use, then In contract)', async ({ page }) => { + test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only'); + + await page.route('**/ingest.superdoc.dev/**', (r) => + r.fulfill({ status: 204, contentType: 'application/json', body: '{}' }), + ); + await page.goto('/'); + await page.waitForFunction( + () => (window as any).__demo?.state?.ui?.contentControls?.getSnapshot()?.items?.length > 0, + null, + { timeout: 30_000 }, + ); + await page.waitForSelector('.clause[data-clause-id="indemnification"]'); + + // Caret in the (unlocked) title so the clause adds at a clean block boundary. await page.evaluate(() => { (window as any).__demo.superdoc.activeEditor.commands?.setTextSelection?.({ from: 6, to: 6 }); }); - const sectionId = await page.getAttribute('.clause[data-clause-id]', 'data-clause-id'); - expect(sectionId).toBeTruthy(); - - // Count controls for this clause + confirm they're all locked. - const clauseInfo = () => - page.evaluate((sid) => { + const indemnificationInfo = () => + page.evaluate(() => { const doc = (window as any).__demo.doc(); const items = doc.contentControls.list({}).items.filter((c: any) => { try { - return JSON.parse(c.properties?.tag ?? '{}').sectionId === sid; + return JSON.parse(c.properties?.tag ?? '{}').sectionId === 'indemnification'; } catch { return false; } }); return { count: items.length, allLocked: items.every((c: any) => c.lockMode === 'contentLocked') }; - }, sectionId); + }); + + expect((await indemnificationInfo()).count).toBe(0); + await page.click('.clause[data-clause-id="indemnification"]'); - const before = await clauseInfo(); - await page.click(`.clause[data-clause-id="${sectionId}"]`); + // It's added once, locked, and the card flips to In contract. + await expect.poll(async () => (await indemnificationInfo()).count, { timeout: 6_000 }).toBe(1); + expect((await indemnificationInfo()).allLocked).toBe(true); + await expect(page.locator('.clause[data-clause-id="indemnification"] .clause-status')).toHaveText('In contract'); - // A new block clause for this section appears, and every occurrence is locked. - await expect.poll(async () => (await clauseInfo()).count, { timeout: 6_000 }).toBe(before.count + 1); - expect((await clauseInfo()).allLocked).toBe(true); + // Clicking again does NOT duplicate it (single-use; reveals the existing one). + await page.click('.clause[data-clause-id="indemnification"]'); + await page.waitForTimeout(500); + expect((await indemnificationInfo()).count).toBe(1); }); -test('inserting Permitted Use nests real smart fields that fill from the form', async ({ page }) => { +test('adding the Return of Materials clause nests a real smart field that fills from the form', async ({ page }) => { test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only'); await page.route('**/ingest.superdoc.dev/**', (r) => @@ -345,15 +368,14 @@ test('inserting Permitted Use nests real smart fields that fill from the form', null, { timeout: 30_000 }, ); - await page.waitForSelector('.clause[data-clause-id="permittedUse"]'); + await page.waitForSelector('.clause[data-clause-id="returnOfMaterials"]'); - // Caret in the (unlocked) title so the clause inserts at a clean block boundary. + // Caret in the (unlocked) title so the clause adds at a clean block boundary. await page.evaluate(() => { (window as any).__demo.superdoc.activeEditor.commands?.setTextSelection?.({ from: 6, to: 6 }); }); - // Count Receiving party smart fields in the document (an inline structuredContent - // whose tag carries that key) - the Permitted Use clause carries one as a slot. + // Receiving party smart fields in the document (Return of Materials carries one). const receivingPartyControls = () => page.evaluate(() => { const doc = (window as any).__demo.doc(); @@ -362,13 +384,13 @@ test('inserting Permitted Use nests real smart fields that fill from the form', }); const before = (await receivingPartyControls()).length; // 2 seeded - await page.click('.clause[data-clause-id="permittedUse"]'); + await page.click('.clause[data-clause-id="returnOfMaterials"]'); - // Inserting the clause adds a real nested Receiving party SDT (not plain text). + // Adding the clause creates a real nested Receiving party SDT (not plain text). await expect.poll(async () => (await receivingPartyControls()).length, { timeout: 6_000 }).toBe(before + 1); // Filling Receiving party in the Values form reaches every occurrence, - // including the one just nested inside the inserted clause. + // including the one just nested inside the added clause. await page.click('.tab[data-tab="values"]'); await page.fill('input[data-field="receivingParty"]', 'Beacon Bio'); await expect diff --git a/demos/contract-templates/README.md b/demos/contract-templates/README.md index 8c0ae35184..d2d974512d 100644 --- a/demos/contract-templates/README.md +++ b/demos/contract-templates/README.md @@ -12,8 +12,8 @@ The starting document is `public/nda-template.docx`: inline plain-text fields an **Template tab, the building-block library.** Two catalogs, fields and clauses, each styled to match what it inserts: -- 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. -- 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. +- 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. +- 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). 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`). diff --git a/demos/contract-templates/src/main.ts b/demos/contract-templates/src/main.ts index 8dc201f35e..adc6cd3d75 100644 --- a/demos/contract-templates/src/main.ts +++ b/demos/contract-templates/src/main.ts @@ -18,11 +18,13 @@ * - Every control is `contentLocked`, so it can't be edited by typing in the * document. This is a locked template surface; the custom UI drives changes. * - Template tab = the building-block library. Two catalogs: smart-field chips - * and clause cards (each with category / jurisdiction / version and a "used - * N times" count). Drag or click a chip to insert an inline field, or a card - * to insert a block clause. An unfilled field shows its field-name token - * (e.g. DISCLOSING_PARTY) as a stand-in placeholder - literal text content, - * not a native SDT placeholder. + * (reusable variables) and clause cards (governed sections, single-use). A + * chip drags/clicks in as an inline field. A clause card shows category / + * jurisdiction / version and a status: "Add clause" when available (drag or + * click to add) or "In contract" once placed (click reveals the existing one + * - a clause appears once, like an inclusion checklist). An unfilled field + * shows its field-name token (e.g. DISCLOSING_PARTY) as a stand-in + * placeholder - literal text content, not a native SDT placeholder. * - A clause is assembled from structured `parts`: prose plus `{ field }` * slots. Inserting it creates the block and wraps each slot as a nested, * locked inline smart field - so an inserted "Permitted Use" carries real @@ -113,7 +115,8 @@ type ClauseId = | 'termination' | 'governingLaw' | 'limitationOfLiability' - | 'indemnification'; + | 'indemnification' + | 'returnOfMaterials'; type ClauseCategory = 'Core' | 'Confidentiality' | 'Termination' | 'Risk Allocation'; @@ -202,7 +205,7 @@ const CLAUSE_LIBRARY: LibraryClause[] = [ parts: ['Each party’s aggregate liability under this Agreement is limited to fees paid in the twelve (12) months preceding the claim.'], }, { - // A library-only clause: not in the seeded document, so it starts "Used 0". + // A library-only clause: not in the seeded document, so it starts "Add clause". // Insert it to add a new governed section to the contract. id: 'indemnification', label: 'Indemnification', @@ -211,6 +214,20 @@ const CLAUSE_LIBRARY: LibraryClause[] = [ version: 'v1', parts: ['Each party will indemnify and hold the other harmless from third-party claims arising out of its breach of this Agreement.'], }, + { + // Library-only and carries a nested field slot: adding it shows that an + // inserted clause's embedded variables become real, broadcast-linked SDTs. + id: 'returnOfMaterials', + label: 'Return of Materials', + category: 'Confidentiality', + jurisdiction: 'General', + version: 'v1', + parts: [ + 'Upon termination or at the disclosing party’s request, ', + { field: 'receivingParty' }, + ' will promptly return or destroy all Confidential Information in its possession.', + ], + }, ]; // --------------------------------------------------------------------------- @@ -592,7 +609,7 @@ async function insertClause(clauseId: ClauseId, blockId: string): Promise } // Lock the clause now that its slots are wrapped, then refresh the cards - // ("Used N") and scroll the new clause into view. + // (the card flips to "In contract") and scroll the new clause into view. reportMutation(doc.contentControls.setLockMode({ target: clauseTarget, lockMode: 'contentLocked' }), 'Lock clause'); renderClausesPanel(); void ui.contentControls.scrollIntoView({ id: clauseTarget.nodeId, block: 'center' }); @@ -602,9 +619,14 @@ async function insertClause(clauseId: ClauseId, blockId: string): Promise function insertClauseAtCursor(clauseId: ClauseId): void { const ui = state.ui; if (!ui || !state.editor?.doc) return; + // Single-use: if it's already in the contract, reveal it instead of duplicating. + if (isClauseInDocument(clauseId)) { + revealClause(clauseId); + return; + } const seg = ui.selection.capture()?.target?.segments?.[0]; if (!seg) { - setStatus('Place the cursor in the document (or drag the clause in), then click a clause to insert it.'); + setStatus('Place the cursor in the document, then click a clause to add it.'); return; } void insertClause(clauseId, seg.blockId); @@ -651,7 +673,10 @@ function setupPaletteDragDrop(): () => void { insertField(field.key, field.label, { kind: 'selection', start: point, end: point }); } else if (clauseId) { const clause = CLAUSE_LIBRARY.find((c) => c.id === clauseId); - if (clause) void insertClause(clause.id, hit.point.blockId); // offset 0 (block boundary) + if (!clause) return; + // Single-use: a clause already in the contract reveals instead of duplicating. + if (isClauseInDocument(clause.id)) revealClause(clause.id); + else void insertClause(clause.id, hit.point.blockId); // offset 0 (block boundary) } }; @@ -785,8 +810,10 @@ function highlightActiveClause(): void { /** * Template tab: the contract's building blocks. Two catalogs - inline Smart tags - * (variable chips) and block Clauses (cards with metadata + usage count). Both - * drag or click to insert; values are filled on the Values tab. + * (reusable variable chips, drag or click to insert) and block Clauses (governed, + * single-use cards with metadata + a status; an available clause adds by drag or + * click, one already in the contract reveals it). Values are filled on the + * Values tab. */ function renderFieldsPanel(): void { fieldsPanelEl.innerHTML = ''; @@ -797,7 +824,7 @@ function renderFieldsPanel(): void { /** * Clauses section of the Template tab: a search + the clause cards. Mirrors the * Smart-tags section's style (group header, search) but the clauses render as - * compact amber cards, not pills, since they're block controls. Creates the + * compact blue cards, not pills, since they're block controls. Creates the * list container (clausesListEl) that renderClausesPanel re-renders into. */ function renderClausesSection(): void { @@ -873,11 +900,11 @@ function renderValuesPanel(): void { /** * Render the clause cards: one card per clause, styled like the in-document - * block clause (amber left rail). Like the smart-tag chips, a card is draggable + * block clause (blue left rail). Like the smart-tag chips, a card is draggable * into the document or click-to-insert at the cursor (insertClause snaps to a * block boundary). A card highlights when its clause is clicked in the document. */ -/** Every control in the document for a given clause (a clause can be placed more than once). */ +/** Every control in the document for a given clause (used internally for counts). */ function clauseControls(clauseId: ClauseId): ContentControlInfo[] { const doc = state.editor?.doc; if (!doc) return []; @@ -887,32 +914,51 @@ function clauseControls(clauseId: ClauseId): ContentControlInfo[] { }); } +/** A clause is single-use: it's either in the contract or available to add. */ +function isClauseInDocument(clauseId: ClauseId): boolean { + return clauseControls(clauseId).length > 0; +} + +/** Scroll the clause's placement into view and highlight its card. */ +function revealClause(clauseId: ClauseId): void { + const ctrl = clauseControls(clauseId)[0]; + if (!state.ui || !ctrl) return; + state.activeClauseId = clauseId; + highlightActiveClause(); + void state.ui.contentControls.scrollIntoView({ id: ctrl.target.nodeId, block: 'center' }); +} + /** - * Render the clause library catalog: a card per available clause with its - * category / jurisdiction / version and how many times it's placed in the - * document. A card wears the in-document block clause's amber look. Drag a card - * in, or click to insert it at the cursor; the card highlights when its clause - * is clicked in the document. + * Render the clause library as a single-use inclusion checklist. Each card shows + * the clause's category / jurisdiction / version and whether it's "In contract" + * or available to "Add clause". A clause is governed and appears once: a card + * that's already in the contract can't be inserted again - clicking it reveals + * the existing clause instead; an available card inserts (click) or drags in. */ function renderClausesPanel(): void { const list = clausesListEl; if (!list) return; list.innerHTML = ''; for (const clause of CLAUSE_LIBRARY) { - const used = clauseControls(clause.id).length; - const usedText = used === 0 ? 'Not used' : `Used ${used} time${used === 1 ? '' : 's'}`; + const inDoc = isClauseInDocument(clause.id); const card = document.createElement('article'); - card.className = 'clause' + (clause.id === state.activeClauseId ? ' is-active' : ''); + card.className = + 'clause ' + (inDoc ? 'is-present' : 'is-available') + (clause.id === state.activeClauseId ? ' is-active' : ''); card.dataset.clauseId = clause.id; - card.draggable = true; - card.title = `Drag into the document, or click to insert the ${clause.label} clause at the cursor`; + card.draggable = !inDoc; // single-use: can't drag a clause that's already in + card.title = inDoc + ? `${clause.label} is in the contract — click to reveal it` + : `Drag into the document, or click to add the ${clause.label} clause at the cursor`; card.innerHTML = ` -

${escapeHtml(clause.label)}

-

${escapeHtml(clause.category)} · ${escapeHtml(clause.jurisdiction)} · ${escapeHtml(clause.version)} · ${escapeHtml(usedText)}

+
+

${escapeHtml(clause.label)}

+ ${inDoc ? 'In contract' : 'Add clause'} +
+

${escapeHtml(clause.category)} · ${escapeHtml(clause.jurisdiction)} · ${escapeHtml(clause.version)}

`; - card.addEventListener('click', () => insertClauseAtCursor(clause.id)); + card.addEventListener('click', () => (isClauseInDocument(clause.id) ? revealClause(clause.id) : insertClauseAtCursor(clause.id))); card.addEventListener('dragstart', (event) => { - if (!event.dataTransfer) return; + if (!event.dataTransfer || isClauseInDocument(clause.id)) return; event.dataTransfer.setData(CLAUSE_MIME, clause.id); event.dataTransfer.effectAllowed = 'copy'; }); diff --git a/demos/contract-templates/src/style.css b/demos/contract-templates/src/style.css index dab6743569..ae499069a1 100644 --- a/demos/contract-templates/src/style.css +++ b/demos/contract-templates/src/style.css @@ -186,10 +186,11 @@ input:focus { /* ----------------------------------------------------------------------- Clause library (Template tab) - A catalog of governed clauses. Each card echoes the in-document block clause - (amber left rail, soft border, faint amber fill) and shows its metadata and - how many times it's placed. Drag a card in, or click to insert at the cursor. - The clauses themselves are locked blocks. + A single-use catalog of governed clauses. Each card echoes the in-document + block clause (blue left rail, soft border, faint blue fill) and shows its + metadata plus a status: an available clause ("Add clause") drags or clicks in, + while one already in the contract ("In contract") is a quieter card that + clicks to reveal the existing section. The clauses themselves are locked blocks. ----------------------------------------------------------------------- */ .clauses-section { margin-top: 16px; } @@ -201,13 +202,25 @@ input:focus { background-color: var(--tag-block-bg); padding: 7px 10px; margin-bottom: 6px; - cursor: grab; } .clause:hover { background-color: var(--tag-block-bg-hover); } -.clause:active { cursor: grabbing; } .clause.is-active { box-shadow: 0 0 0 2px var(--tag-color); } +/* Available clauses drag/click to add; clauses already in the contract are a + quieter card you click to reveal the existing section. */ +.clause.is-available { cursor: grab; } +.clause.is-available:active { cursor: grabbing; } +.clause.is-present { cursor: pointer; } +.clause.is-present { background-color: var(--tag-block-bg); } +.clause-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} .clause-label { margin: 0; + flex: 1; + min-width: 0; font-size: var(--sd-font-size-300, 13px); font-weight: 600; color: var(--demo-text); @@ -215,6 +228,23 @@ input:focus { text-overflow: ellipsis; white-space: nowrap; } +.clause-status { + flex-shrink: 0; + font-size: var(--sd-font-size-100, 10px); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 1px 6px; + border-radius: 999px; +} +.clause.is-present .clause-status { + color: var(--demo-text-muted); + background: color-mix(in srgb, var(--demo-text-muted) 14%, transparent); +} +.clause.is-available .clause-status { + color: var(--tag-fg); + background: var(--tag-bg); +} .clause-meta { margin: 2px 0 0; font-size: var(--sd-font-size-100, 11px); @@ -244,18 +274,16 @@ input:focus { /* Smart-tag token look, shared by the in-editor inline SDT and the Smart-tags palette chips so a sidebar tag and the field it inserts read as one object. */ :root { - /* Template fields wear a deliberate amber identity, distinct from the - SuperDoc-blue UI accent (--demo-accent), so a reader can see "this is a - template variable" at a glance. Amber isn't a built-in --sd-* token, so - this is an intentional demo-local accent (the theming the demo is showing - off, per demos/AGENTS.md). One token set, applied to both the palette chip - and the painted in-editor field. */ - --tag-color: var(--sd-color-amber-600, #d97706); + /* Template fields use the SuperDoc brand blue (brand.md: blue-500 / blue-600), + via the --sd-color-blue-* tokens. One token set, applied to both the palette + chip and the painted in-editor field. Fields read as tinted/outlined pills, + so they stay distinct from the solid-blue primary buttons. */ + --tag-color: var(--sd-color-blue-500, #1355ff); --tag-border: var(--tag-color); --tag-bg: color-mix(in srgb, var(--tag-color) 12%, transparent); - --tag-fg: var(--sd-color-amber-700, #b45309); + --tag-fg: var(--sd-color-blue-600, #0f44cc); --tag-bg-hover: color-mix(in srgb, var(--tag-color) 22%, transparent); - /* Block clauses are large regions, so they wear the amber language quietly: + /* Block clauses are large regions, so they wear the same blue quietly: a left rail + a faint fill + a soft border, lighter than the inline pill. */ --tag-block-border: color-mix(in srgb, var(--tag-color) 30%, var(--demo-border)); --tag-block-bg: color-mix(in srgb, var(--tag-color) 4%, var(--demo-bg)); @@ -274,7 +302,7 @@ input:focus { } .superdoc-cc-chrome-none .superdoc-structured-content-inline[data-sdt-tag*='smartField']:hover { /* Under chrome:'none' SuperDoc resets the field's border + fill (including on - hover) so the consumer owns the look. We re-assert the amber box so hover + hover) so the consumer owns the look. We re-assert the box so hover never moves or recolors the field. The !important wins over that reset without coupling to SuperDoc's selector specificity — a custom-UI styling rough edge today (no first-class per-control styling hook yet). */ @@ -283,8 +311,8 @@ input:focus { } /* Selecting a field is a ProseMirror NodeSelection (.ProseMirror-selectednode). Under chrome:'none' SuperDoc resets the border + fill to transparent in that - state too; without re-asserting, the field loses its amber and the box can - shift (~2px) on click. Keep the same box and a controlled amber "selected" + state too; without re-asserting, the field loses its fill and the box can + shift (~2px) on click. Keep the same box and a controlled blue "selected" fill so hover/click/selected stay on-brand and never move the field. */ .superdoc-cc-chrome-none .superdoc-structured-content-inline[data-sdt-tag*='smartField'].ProseMirror-selectednode { /* !important to win over the chrome-none reset; same rough edge as hover. */ @@ -336,9 +364,9 @@ input:focus { .smart-tag.is-active { box-shadow: 0 0 0 2px var(--demo-accent); } .smart-tag:focus-visible { outline: 1px solid var(--demo-accent); outline-offset: 1px; } -/* Block clauses: a quiet card with an amber left rail — same field language as +/* Block clauses: a quiet card with a blue left rail, same field language as the inline pills, but a region not a token: soft border, faint fill, a 4px - amber spine. */ + blue spine. */ .superdoc-cc-chrome-none .superdoc-structured-content-block[data-sdt-tag*='reusableSection'] { border: 1px solid var(--tag-block-border); border-left: 4px solid var(--tag-color);