From cfe2490159cbaa574d81bdc2abecb1f666bb7f66 Mon Sep 17 00:00:00 2001 From: rylan-vrar Date: Fri, 26 Jun 2026 11:15:48 -0700 Subject: [PATCH 1/3] Fixed dropdown menu behavior on contacts page --- index.html | 29 +++++++++++----- js/contacts.js | 89 +++++++++++++++++++++++++++++++++++++++++--------- styles.css | 47 +++++++++++++++++++++++++- 3 files changed, 140 insertions(+), 25 deletions(-) diff --git a/index.html b/index.html index 553d652..473a75b 100644 --- a/index.html +++ b/index.html @@ -171,15 +171,26 @@

Union

- +
+ + +
diff --git a/js/contacts.js b/js/contacts.js index 7039c9d..9351ab5 100644 --- a/js/contacts.js +++ b/js/contacts.js @@ -54,29 +54,88 @@ function bindContactsSearchInput() { input.dataset.bound = '1'; } +const CONTACTS_SORT_OPTIONS = [ + { mode: 'recent', label: 'Met' }, + { mode: 'age', label: 'Known' }, + { mode: 'trust', label: 'Trust' }, + { mode: 'custom', label: 'Custom' }, +]; + +function getContactsSortOption(mode) { + return CONTACTS_SORT_OPTIONS.find((o) => o.mode === mode) || CONTACTS_SORT_OPTIONS[2]; +} + +function setContactsSortMode(mode) { + if (mode !== 'recent' && mode !== 'age' && mode !== 'trust' && mode !== 'custom') return; + if (contactsSortMode === mode) return; + contactsSortMode = mode; + updateSortLabel(); + scheduleSortPrefsSave(); + renderContactsForCurrentQuery(); +} + +function closeContactsSortMenu() { + const menu = document.getElementById('contactsSortMenu'); + const btn = document.getElementById('contactsSortBtn'); + if (menu) menu.classList.add('hidden'); + if (btn) { + btn.classList.remove('active'); + btn.setAttribute('aria-expanded', 'false'); + } + document.removeEventListener('click', closeContactsSortMenuOnOutsideClick); +} + +function closeContactsSortMenuOnOutsideClick(e) { + const wrap = document.querySelector('.contacts-sort-wrap'); + if (wrap && wrap.contains(e.target)) return; + closeContactsSortMenu(); +} + +function toggleContactsSortMenu(e) { + if (e) e.stopPropagation(); + const menu = document.getElementById('contactsSortMenu'); + const btn = document.getElementById('contactsSortBtn'); + if (!menu) return; + if (menu.classList.contains('hidden')) { + menu.classList.remove('hidden'); + if (btn) { + btn.classList.add('active'); + btn.setAttribute('aria-expanded', 'true'); + } + setTimeout(() => { + document.addEventListener('click', closeContactsSortMenuOnOutsideClick); + }, 0); + } else { + closeContactsSortMenu(); + } +} + function bindContactsSortButton() { const btn = document.getElementById('contactsSortBtn'); - const label = document.getElementById('contactsSortLabel'); + const menu = document.getElementById('contactsSortMenu'); if (!btn || btn.dataset.bound === '1') return; - btn.addEventListener('click', () => { - if (contactsSortMode === 'recent') contactsSortMode = 'age'; - else if (contactsSortMode === 'age') contactsSortMode = 'trust'; - else if (contactsSortMode === 'trust') contactsSortMode = 'custom'; - else contactsSortMode = 'recent'; - updateSortLabel(); - scheduleSortPrefsSave(); - renderContactsForCurrentQuery(); - }); + btn.addEventListener('click', toggleContactsSortMenu); + if (menu) { + menu.querySelectorAll('.contacts-sort-option').forEach((option) => { + option.addEventListener('click', (e) => { + e.stopPropagation(); + closeContactsSortMenu(); + setContactsSortMode(option.dataset.sortMode); + }); + }); + } btn.dataset.bound = '1'; } function updateSortLabel() { const label = document.getElementById('contactsSortLabel'); - if (!label) return; - if (contactsSortMode === 'recent') label.textContent = 'Met'; - else if (contactsSortMode === 'age') label.textContent = 'Known'; - else if (contactsSortMode === 'trust') label.textContent = 'Trust'; - else label.textContent = 'Custom'; + const opt = getContactsSortOption(contactsSortMode); + if (label) label.textContent = opt.label; + document.querySelectorAll('.contacts-sort-option').forEach((btn) => { + const selected = btn.dataset.sortMode === contactsSortMode; + btn.classList.toggle('selected', selected); + btn.setAttribute('aria-selected', selected ? 'true' : 'false'); + }); } function getCustomOrderKey() { diff --git a/styles.css b/styles.css index eec857a..2a9134c 100644 --- a/styles.css +++ b/styles.css @@ -2348,6 +2348,10 @@ body.dm-screen-active .main-content { min-width: 0; } +.contacts-sort-wrap { + position: relative; + flex-shrink: 0; +} .contacts-sort-btn { display: inline-flex; align-items: center; @@ -2362,7 +2366,6 @@ body.dm-screen-active .main-content { font-weight: 500; cursor: pointer; white-space: nowrap; - flex-shrink: 0; transition: border-color 0.15s, background 0.15s; } .contacts-sort-btn:hover { @@ -2377,11 +2380,53 @@ body.dm-screen-active .main-content { display: inline-flex; align-items: center; opacity: 0.6; + transition: transform 0.15s; +} +.contacts-sort-btn.active .contacts-sort-arrow { + transform: rotate(180deg); } .contacts-sort-arrow svg.lucide-icon { width: 14px; height: 14px; } +.contacts-sort-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + min-width: 100%; + background: var(--white); + color: var(--text-color); + border: 1px solid var(--medium-gray); + border-radius: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14); + overflow: hidden; + z-index: 20; +} +.contacts-sort-option { + display: block; + width: 100%; + text-align: left; + background: none; + border: none; + padding: 0.65rem 0.85rem; + font: inherit; + font-size: 14px; + font-weight: 500; + color: inherit; + cursor: pointer; +} +.contacts-sort-option:hover, +.contacts-sort-option:focus-visible { + background: var(--light-gray); + outline: none; +} +.contacts-sort-option + .contacts-sort-option { + border-top: 1px solid var(--light-gray); +} +.contacts-sort-option.selected { + color: var(--primary-color); + background: rgba(58, 124, 165, 0.08); +} .contacts-search-icon { position: absolute; left: 0.75rem; From dbdf91302f98485de759ef0660eda27da25857a2 Mon Sep 17 00:00:00 2001 From: rylan-vrar Date: Fri, 26 Jun 2026 11:57:23 -0700 Subject: [PATCH 2/3] Updated governance tab and constitution amendment behavior --- js/constitution.js | 121 ++++++++++++++++++++++++++++++++++++++++----- js/modals.js | 2 +- 2 files changed, 111 insertions(+), 12 deletions(-) diff --git a/js/constitution.js b/js/constitution.js index 3bd05dc..a261972 100644 --- a/js/constitution.js +++ b/js/constitution.js @@ -46,11 +46,86 @@ function wordDiff(oldText, newText) { }).join(''); } -// Render constitution text with highlighted tags +const CONSTITUTION_TAG_RE = /\s*\$([A-Z_]+)/g; + +function stripConstitutionTags(text) { + if (!text) return ''; + return text.replace(CONSTITUTION_TAG_RE, ''); +} + +function getConstitutionTagAnchor(beforeTagStripped) { + const labelMatch = beforeTagStripped.match(/([^\n]*:\s*)$/); + if (labelMatch) return labelMatch[1]; + const periodMatch = beforeTagStripped.match(/([\s\S]*?\bperiod of\s*)$/i); + if (periodMatch) return periodMatch[1]; + return beforeTagStripped.slice(Math.max(0, beforeTagStripped.length - 40)); +} + +function findConstitutionTagInsertAt(text, valueStart, tagName) { + let end = text.length; + const commaWith = text.indexOf(', with', valueStart); + if (commaWith !== -1) end = Math.min(end, commaWith); + const commaAnd = text.indexOf(', and', valueStart); + if (commaAnd !== -1) end = Math.min(end, commaAnd); + const lineEnd = text.indexOf('\n', valueStart); + if (lineEnd !== -1) end = Math.min(end, lineEnd); + if (tagName.includes('PERCENTAGE') || tagName.includes('CURRENCY')) { + const ofMember = text.indexOf(' of member', valueStart); + if (ofMember !== -1) end = Math.min(end, ofMember); + } + if (tagName === 'VOTING_PERIOD_DAYS') { + const comma = text.indexOf(',', valueStart); + if (comma !== -1) end = Math.min(end, comma); + } + return end; +} + +// Re-insert machine-readable $TAG identifiers before saving edited display text. +function restoreConstitutionTags(displayText, templateText) { + const display = displayText ?? ''; + if (!templateText) return display; + + const tagMatches = [...templateText.matchAll(CONSTITUTION_TAG_RE)]; + if (tagMatches.length === 0) return display; + + let probe = display; + let hasAllTags = true; + for (const tm of tagMatches) { + const idx = probe.indexOf(tm[0]); + if (idx === -1) { + hasAllTags = false; + break; + } + probe = probe.slice(idx + tm[0].length); + } + if (hasAllTags) return display; + + let text = stripConstitutionTags(display); + for (let i = tagMatches.length - 1; i >= 0; i--) { + const tm = tagMatches[i]; + if (text.includes(tm[0])) continue; + + const before = stripConstitutionTags(templateText.slice(0, tm.index)); + const exactPos = text.indexOf(before); + if (exactPos !== -1) { + const insertAt = exactPos + before.length; + text = text.slice(0, insertAt) + tm[0] + text.slice(insertAt); + continue; + } + + const anchor = getConstitutionTagAnchor(before); + const anchorPos = text.indexOf(anchor); + if (anchorPos === -1) continue; + const insertAt = findConstitutionTagInsertAt(text, anchorPos + anchor.length, tm[1]); + text = text.slice(0, insertAt) + tm[0] + text.slice(insertAt); + } + return text; +} + +// Render constitution text for display (hide machine-readable $TAG identifiers) function renderConstitution(text) { if (!text) return 'No constitution yet.'; - return esc(text).replace(/\$([A-Z_]+)/g, - '$$$1'); + return esc(stripConstitutionTags(text)); } // Parse $AMENDMENT_PERCENTAGE from constitution text (returns 0-1) @@ -495,7 +570,10 @@ function renderAmendmentCard(a, votesMap, myVotesMap, activeMemberCount) { const isExpired = expires <= now; const timeLeft = isExpired ? 'Expired' : formatTimeLeft(expires - now); - let diffHtml = wordDiff(a.old_text || '', a.new_text || ''); + let diffHtml = wordDiff( + stripConstitutionTags(a.old_text || ''), + stripConstitutionTags(a.new_text || '') + ); let actions = ''; if (a.status === 'voting') { @@ -616,23 +694,44 @@ function formatTimeLeft(ms) { // Live diff preview in the propose amendment modal function updateAmendmentPreview() { const oldText = selectedGroup?.constitution || ''; - const newText = document.getElementById('amendmentEditor').value; + const newText = document.getElementById('amendmentEditor')?.value ?? ''; const preview = document.getElementById('amendmentDiffPreview'); - if (oldText === newText) { + if (!preview) return; + const oldDisplay = stripConstitutionTags(oldText); + const newDisplay = stripConstitutionTags(newText); + if (oldDisplay === newDisplay) { preview.innerHTML = 'No changes yet.'; } else { - preview.innerHTML = wordDiff(oldText, newText); + preview.innerHTML = wordDiff(oldDisplay, newDisplay); } } async function submitAmendment() { - if (!selectedGroup) return; + if (!selectedGroup) { + showToast('No group selected', 'error'); + return; + } const title = document.getElementById('amendmentTitle').value.trim(); - const newText = document.getElementById('amendmentEditor').value; + const displayText = document.getElementById('amendmentEditor').value; const oldText = selectedGroup.constitution || ''; - if (!title) { showToast('Please enter a title for the amendment', 'error'); return; } - if (newText === oldText) { showToast('No changes detected in the constitution', 'error'); return; } + if (!title) { + showToast('Please enter a title for the amendment', 'error'); + return; + } + if (stripConstitutionTags(displayText) === stripConstitutionTags(oldText)) { + showToast('No changes detected in the constitution', 'error'); + return; + } + + let newText; + try { + newText = restoreConstitutionTags(displayText, oldText); + } catch (e) { + console.error('restoreConstitutionTags failed:', e); + showToast('Could not prepare amendment text', 'error'); + return; + } const threshold = parseAmendmentThreshold(oldText); const periodDays = parseVotingPeriodDays(oldText); diff --git a/js/modals.js b/js/modals.js index 5b617c8..08e0ff1 100644 --- a/js/modals.js +++ b/js/modals.js @@ -249,7 +249,7 @@ function showModal(type) {
+ oninput="updateAmendmentPreview()">${esc(stripConstitutionTags(selectedGroup?.constitution || ''))}
From 7595372d09ee6275684db9cfebabd32b0a95d160 Mon Sep 17 00:00:00 2001 From: rylan-vrar Date: Fri, 26 Jun 2026 16:24:10 -0700 Subject: [PATCH 3/3] in governance, change vars in-line for amendments, which update UI --- js/constitution.js | 547 ++++++++++++++++++++++++++++++++++----- js/groups.js | 46 +++- js/modals.js | 11 +- js/realtime.js | 7 +- sql/fairshare-schema.sql | 88 ++++++- styles.css | 55 ++++ 6 files changed, 670 insertions(+), 84 deletions(-) diff --git a/js/constitution.js b/js/constitution.js index a261972..c6ed2ea 100644 --- a/js/constitution.js +++ b/js/constitution.js @@ -48,16 +48,67 @@ function wordDiff(oldText, newText) { const CONSTITUTION_TAG_RE = /\s*\$([A-Z_]+)/g; +const CONSTITUTION_OF_MEMBER_TAGS = new Set([ + 'NEW_MEMBER_PERCENTAGE', + 'AMENDMENT_PERCENTAGE', + 'ACCORD_PERCENTAGE', + 'CHANGE_CURRENCY_RATES_PERCENTAGE', +]); + +// Label patterns for constitutions that lost machine-readable $TAG markers. +const CONSTITUTION_TAG_REPAIRS = [ + { name: 'GROUP_NAME', pattern: /(Group Name:\s*[^\n$]+)(?!\s*\$GROUP_NAME)/i }, + { name: 'VOTING_PERIOD_DAYS', pattern: /(Voting will happen over a period of \d+\s*days)(?!\s*\$VOTING_PERIOD_DAYS)/i }, + { name: 'CURRENCY_NAME', pattern: /(Currency Name:\s*[^,\n$]+)(?!\s*\$CURRENCY_NAME)/i }, + { name: 'CURRENCY_SYMBOL', pattern: /(Currency Symbol:\s*[^,\n$]+)(?!\s*\$CURRENCY_SYMBOL)/i }, + { name: 'CHANGE_CURRENCY_RATES_PERCENTAGE', pattern: /(Change Currency Rates:\s*\d+%)(?!\s*\$CHANGE_CURRENCY_RATES_PERCENTAGE)/i }, + { name: 'NEW_MEMBER_PERCENTAGE', pattern: /(To Approve New Member:\s*\d+%)(?!\s*\$NEW_MEMBER_PERCENTAGE)/i }, + { name: 'AMENDMENT_PERCENTAGE', pattern: /(To Approve Amendment:\s*\d+%)(?!\s*\$AMENDMENT_PERCENTAGE)/i }, + { name: 'ACCORD_PERCENTAGE', pattern: /(To Approve a proposed accord:\s*\d+%)(?!\s*\$ACCORD_PERCENTAGE)/i }, +]; + +function normalizeConstitutionText(text) { + return (text ?? '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + function stripConstitutionTags(text) { if (!text) return ''; - return text.replace(CONSTITUTION_TAG_RE, ''); + return normalizeConstitutionText(text).replace(CONSTITUTION_TAG_RE, ''); +} + +function getConstitutionTags(text) { + return [...normalizeConstitutionText(text).matchAll(CONSTITUTION_TAG_RE)]; +} + +function constitutionHasAllTagsInOrder(text, tags) { + let probe = normalizeConstitutionText(text); + for (const tm of tags) { + const idx = probe.indexOf(tm[0]); + if (idx === -1) return false; + probe = probe.slice(idx + tm[0].length); + } + return true; +} + +function constitutionHasTag(text, tagName) { + return getConstitutionTags(text).some((m) => m[1] === tagName); +} + +function getStablePrefix(text, len = 40) { + return stripConstitutionTags(text).slice(0, len); } function getConstitutionTagAnchor(beforeTagStripped) { - const labelMatch = beforeTagStripped.match(/([^\n]*:\s*)$/); - if (labelMatch) return labelMatch[1]; const periodMatch = beforeTagStripped.match(/([\s\S]*?\bperiod of\s*)$/i); if (periodMatch) return periodMatch[1]; + + const lineStart = beforeTagStripped.lastIndexOf('\n') + 1; + const line = beforeTagStripped.slice(lineStart); + const colonIdx = line.lastIndexOf(':'); + if (colonIdx !== -1) { + return beforeTagStripped.slice(0, lineStart + colonIdx + 1) + ' '; + } + return beforeTagStripped.slice(Math.max(0, beforeTagStripped.length - 40)); } @@ -69,7 +120,7 @@ function findConstitutionTagInsertAt(text, valueStart, tagName) { if (commaAnd !== -1) end = Math.min(end, commaAnd); const lineEnd = text.indexOf('\n', valueStart); if (lineEnd !== -1) end = Math.min(end, lineEnd); - if (tagName.includes('PERCENTAGE') || tagName.includes('CURRENCY')) { + if (CONSTITUTION_OF_MEMBER_TAGS.has(tagName)) { const ofMember = text.indexOf(' of member', valueStart); if (ofMember !== -1) end = Math.min(end, ofMember); } @@ -80,46 +131,404 @@ function findConstitutionTagInsertAt(text, valueStart, tagName) { return end; } -// Re-insert machine-readable $TAG identifiers before saving edited display text. -function restoreConstitutionTags(displayText, templateText) { - const display = displayText ?? ''; - if (!templateText) return display; +// Re-attach $TAG markers when prose labels exist but tags were lost (e.g. after an earlier bad amendment). +function repairMissingConstitutionTags(text) { + let result = normalizeConstitutionText(text); + if (!result) return result; - const tagMatches = [...templateText.matchAll(CONSTITUTION_TAG_RE)]; - if (tagMatches.length === 0) return display; + for (const { name, pattern } of CONSTITUTION_TAG_REPAIRS) { + if (constitutionHasTag(result, name)) continue; + result = result.replace(pattern, `$1 $${name}`); + } + + return result; +} + +function getAmendmentConstitutionTemplate(group) { + return repairMissingConstitutionTags(group?.constitution || ''); +} + +// Editable constitution variables — each maps to a $TAG in the stored constitution text. +const CONSTITUTION_VARIABLE_FIELDS = [ + { + tag: 'GROUP_NAME', + label: 'Group name', + inputType: 'text', + extract(text) { + return text.match(/Group Name:\s*([^\n$]+)/i)?.[1]?.trim() ?? ''; + }, + apply(text, value) { + const v = (value || '').trim(); + if (!v) return text; + return text.replace(/(Group Name:\s*)[^\n$]+(\s*\$GROUP_NAME)/i, `$1${v}$2`); + }, + splitEditorSegment(rawSlice) { + const m = rawSlice.match(/^([\s\S]*?Group Name:\s*)([^\n$]+?)(\s*\$GROUP_NAME)$/i); + if (!m) return null; + return { textBefore: m[1], value: m[2].trim() }; + }, + }, + { + tag: 'VOTING_PERIOD_DAYS', + label: 'Voting period (days)', + inputType: 'number', + min: 1, + max: 365, + extract(text) { + return text.match(/period of (\d+)\s*days\s*\$VOTING_PERIOD_DAYS/i)?.[1] ?? ''; + }, + apply(text, value) { + const days = Math.min(365, Math.max(1, parseInt(value, 10) || 1)); + return text.replace( + /(Voting will happen over a period of )\d+(\s*days\s*\$VOTING_PERIOD_DAYS)/i, + `$1${days}$2` + ); + }, + splitEditorSegment(rawSlice) { + const m = rawSlice.match(/^([\s\S]*?period of )(\d+)(\s*days)(\s*\$VOTING_PERIOD_DAYS)$/i); + if (!m) return null; + return { textBefore: m[1], value: m[2], textAfter: m[3] }; + }, + }, + { + tag: 'CURRENCY_NAME', + label: 'Currency name', + inputType: 'text', + extract(text) { + return text.match(/Currency Name:\s*([^,\n$]+)/i)?.[1]?.trim() ?? ''; + }, + apply(text, value) { + const v = (value || '').trim(); + if (!v) return text; + return text.replace(/(Currency Name:\s*)[^,\n$]+(\s*\$CURRENCY_NAME)/i, `$1${v}$2`); + }, + splitEditorSegment(rawSlice) { + const m = rawSlice.match(/^([\s\S]*?Currency Name:\s*)([^,\n$]+?)(\s*\$CURRENCY_NAME)$/i); + if (!m) return null; + return { textBefore: m[1], value: m[2].trim() }; + }, + }, + { + tag: 'CURRENCY_SYMBOL', + label: 'Currency symbol', + inputType: 'text', + extract(text) { + return text.match(/Currency Symbol:\s*([^,\n$]+)/i)?.[1]?.trim() ?? ''; + }, + apply(text, value) { + const v = (value || '').trim(); + if (!v) return text; + return text.replace(/(Currency Symbol:\s*)[^,\n$]+(\s*\$CURRENCY_SYMBOL)/i, `$1${v}$2`); + }, + splitEditorSegment(rawSlice) { + const m = rawSlice.match(/^([\s\S]*?Currency Symbol:\s*)([^,\n$]+?)(\s*\$CURRENCY_SYMBOL)$/i); + if (!m) return null; + return { textBefore: m[1], value: m[2].trim() }; + }, + }, + { + tag: 'CHANGE_CURRENCY_RATES_PERCENTAGE', + label: 'Change currency rates (%)', + inputType: 'number', + min: 1, + max: 100, + extract(text) { + return text.match(/Change Currency Rates:\s*(\d+)%/i)?.[1] ?? ''; + }, + apply(text, value) { + const n = Math.min(100, Math.max(1, parseInt(value, 10) || 1)); + return text.replace( + /(Change Currency Rates:\s*)\d+%(\s*\$CHANGE_CURRENCY_RATES_PERCENTAGE)/i, + `$1${n}%$2` + ); + }, + splitEditorSegment(rawSlice) { + const m = rawSlice.match(/^([\s\S]*?Change Currency Rates:\s*)(\d+)(%)(\s*\$CHANGE_CURRENCY_RATES_PERCENTAGE)$/i); + if (!m) return null; + return { textBefore: m[1], value: m[2], textAfter: m[3] }; + }, + }, + { + tag: 'NEW_MEMBER_PERCENTAGE', + label: 'Approve new member (%)', + inputType: 'number', + min: 1, + max: 100, + extract(text) { + return text.match(/To Approve New Member:\s*(\d+)%/i)?.[1] ?? ''; + }, + apply(text, value) { + const n = Math.min(100, Math.max(1, parseInt(value, 10) || 1)); + return text.replace( + /(To Approve New Member:\s*)\d+%(\s*\$NEW_MEMBER_PERCENTAGE)/i, + `$1${n}%$2` + ); + }, + splitEditorSegment(rawSlice) { + const m = rawSlice.match(/^([\s\S]*?To Approve New Member:\s*)(\d+)(%)(\s*\$NEW_MEMBER_PERCENTAGE)$/i); + if (!m) return null; + return { textBefore: m[1], value: m[2], textAfter: m[3] }; + }, + }, + { + tag: 'AMENDMENT_PERCENTAGE', + label: 'Approve amendment (%)', + inputType: 'number', + min: 1, + max: 100, + extract(text) { + return text.match(/To Approve Amendment:\s*(\d+)%/i)?.[1] ?? ''; + }, + apply(text, value) { + const n = Math.min(100, Math.max(1, parseInt(value, 10) || 1)); + return text.replace( + /(To Approve Amendment:\s*)\d+%(\s*\$AMENDMENT_PERCENTAGE)/i, + `$1${n}%$2` + ); + }, + splitEditorSegment(rawSlice) { + const m = rawSlice.match(/^([\s\S]*?To Approve Amendment:\s*)(\d+)(%)(\s*\$AMENDMENT_PERCENTAGE)$/i); + if (!m) return null; + return { textBefore: m[1], value: m[2], textAfter: m[3] }; + }, + }, + { + tag: 'ACCORD_PERCENTAGE', + label: 'Approve accord (%)', + inputType: 'number', + min: 1, + max: 100, + extract(text) { + return text.match(/To Approve a proposed accord:\s*(\d+)%/i)?.[1] ?? ''; + }, + apply(text, value) { + const n = Math.min(100, Math.max(1, parseInt(value, 10) || 1)); + return text.replace( + /(To Approve a proposed accord:\s*)\d+%(\s*\$ACCORD_PERCENTAGE)/i, + `$1${n}%$2` + ); + }, + splitEditorSegment(rawSlice) { + const m = rawSlice.match(/^([\s\S]*?To Approve a proposed accord:\s*)(\d+)(%)(\s*\$ACCORD_PERCENTAGE)$/i); + if (!m) return null; + return { textBefore: m[1], value: m[2], textAfter: m[3] }; + }, + }, +]; + +function getActiveConstitutionVariableFields(template) { + return CONSTITUTION_VARIABLE_FIELDS.filter((f) => constitutionHasTag(template, f.tag)); +} + +function buildConstitutionFromVariables(template, values) { + let result = normalizeConstitutionText(template); + for (const field of CONSTITUTION_VARIABLE_FIELDS) { + if (!constitutionHasTag(result, field.tag)) continue; + if (values[field.tag] === undefined) continue; + result = field.apply(result, values[field.tag]); + } + return repairMissingConstitutionTags(result); +} + +function splitEditorSegmentFallback(field, rawSlice, tagName, fullText) { + const tagSuffix = rawSlice.match(new RegExp(`\\s*\\$${tagName}$`)); + if (!tagSuffix) return null; + + const withoutTag = rawSlice.slice(0, rawSlice.length - tagSuffix[0].length); + const value = String(field.extract(fullText) ?? ''); + + if (field.inputType === 'number') { + if (tagName === 'VOTING_PERIOD_DAYS') { + const daysMatch = withoutTag.match(/^([\s\S]*?)(\d+)(\s*days\s*)$/i); + if (daysMatch && daysMatch[2] === value) { + return { textBefore: daysMatch[1], value: daysMatch[2], textAfter: daysMatch[3] }; + } + } + const pctMatch = withoutTag.match(/^([\s\S]*?)(\d+)(%\s*)$/); + if (pctMatch && pctMatch[2] === value) { + return { textBefore: pctMatch[1], value: pctMatch[2], textAfter: pctMatch[3] }; + } + } + + if (value && withoutTag.endsWith(value)) { + return { textBefore: withoutTag.slice(0, withoutTag.length - value.length), value }; + } + + const idx = withoutTag.lastIndexOf(value); + if (value && idx !== -1) { + return { + textBefore: withoutTag.slice(0, idx), + value, + textAfter: withoutTag.slice(idx + value.length) || undefined, + }; + } + + return null; +} + +function parseConstitutionEditorSegments(text) { + const normalized = normalizeConstitutionText(text); + const tagMatches = getConstitutionTags(normalized); + const segments = []; + let cursor = 0; - let probe = display; - let hasAllTags = true; for (const tm of tagMatches) { - const idx = probe.indexOf(tm[0]); - if (idx === -1) { - hasAllTags = false; - break; + const tagEnd = tm.index + tm[0].length; + const rawSlice = normalized.slice(cursor, tagEnd); + const field = CONSTITUTION_VARIABLE_FIELDS.find((f) => f.tag === tm[1]); + + if (field) { + const parts = field.splitEditorSegment?.(rawSlice) + || splitEditorSegmentFallback(field, rawSlice, tm[1], normalized); + if (parts) { + if (parts.textBefore) segments.push({ type: 'text', content: parts.textBefore }); + segments.push({ + type: 'var', + tag: field.tag, + value: parts.value, + inputType: field.inputType, + min: field.min, + max: field.max, + }); + if (parts.textAfter) segments.push({ type: 'text', content: parts.textAfter }); + } else { + segments.push({ type: 'text', content: stripConstitutionTags(rawSlice) }); + } + } else { + segments.push({ type: 'text', content: stripConstitutionTags(rawSlice) }); + } + cursor = tagEnd; + } + + if (cursor < normalized.length) { + segments.push({ type: 'text', content: normalized.slice(cursor) }); + } + + if (segments.length === 0) { + segments.push({ type: 'text', content: stripConstitutionTags(normalized) }); + } + + return segments; +} + +function constitutionVarInputSize(value, inputType) { + const len = String(value ?? '').length; + const min = 2; + const max = inputType === 'number' ? 4 : 48; + return Math.min(max, Math.max(min, len + 1)); +} + +function resizeConstitutionVarInput(input) { + if (!input) return; + const type = input.type === 'number' ? 'number' : 'text'; + input.size = constitutionVarInputSize(input.value, type); +} + +function resizeConstitutionVarInputs(root) { + root?.querySelectorAll('.constitution-var-input').forEach(resizeConstitutionVarInput); +} + +function renderAmendmentConstitutionEditorHTML(template) { + return parseConstitutionEditorSegments(template).map((seg) => { + if (seg.type === 'text') { + return `${esc(seg.content)}`; + } + const min = seg.min != null ? ` min="${seg.min}"` : ''; + const max = seg.max != null ? ` max="${seg.max}"` : ''; + const type = seg.inputType || 'text'; + const size = constitutionVarInputSize(seg.value, type); + return ``; + }).join(''); +} + +function getAmendmentEditorDisplayText() { + const editor = document.getElementById('amendmentConstitutionEditor'); + if (!editor) return ''; + let out = ''; + for (const el of editor.children) { + if (el.classList.contains('constitution-prose')) { + out += el.textContent; + } else if (el.dataset.constitutionVar) { + out += el.value; } - probe = probe.slice(idx + tm[0].length); } - if (hasAllTags) return display; + return out; +} + +function getAmendmentEditorVariableValues() { + const values = {}; + document.querySelectorAll('#amendmentConstitutionEditor [data-constitution-var]').forEach((input) => { + values[input.dataset.constitutionVar] = input.value.trim(); + }); + return values; +} + +function initAmendmentConstitutionEditor(group) { + const template = getAmendmentConstitutionTemplate(group); + const editor = document.getElementById('amendmentConstitutionEditor'); + if (!editor) return; + editor.innerHTML = renderAmendmentConstitutionEditorHTML(template); + resizeConstitutionVarInputs(editor); + editor.addEventListener('input', (e) => { + if (e.target.classList?.contains('constitution-var-input')) { + resizeConstitutionVarInput(e.target); + } + updateAmendmentPreview(); + }); + updateAmendmentPreview(); +} + +function buildAmendmentConstitutionText(group) { + const template = getAmendmentConstitutionTemplate(group); + const displayText = getAmendmentEditorDisplayText(); + return restoreConstitutionTags(displayText, template); +} + +// Re-insert machine-readable $TAG identifiers before saving edited display text. +function restoreConstitutionTags(displayText, templateText) { + const display = normalizeConstitutionText(displayText); + const template = normalizeConstitutionText(templateText); + if (!template) return display; + + const tagMatches = getConstitutionTags(template); + if (tagMatches.length === 0) return display; + if (constitutionHasAllTagsInOrder(display, tagMatches)) return display; + + const text = stripConstitutionTags(display); + let result = ''; + let cursor = 0; - let text = stripConstitutionTags(display); - for (let i = tagMatches.length - 1; i >= 0; i--) { + for (let i = 0; i < tagMatches.length; i++) { const tm = tagMatches[i]; - if (text.includes(tm[0])) continue; + const prevEnd = i === 0 ? 0 : tagMatches[i - 1].index + tagMatches[i - 1][0].length; + const segBefore = stripConstitutionTags(template.slice(prevEnd, tm.index)); + const nextStart = tm.index + tm[0].length; + const nextEnd = i + 1 < tagMatches.length ? tagMatches[i + 1].index : template.length; + const segAfter = stripConstitutionTags(template.slice(nextStart, nextEnd)); + + let insertAt = text.length; + const nextPrefix = getStablePrefix(segAfter, 40); + if (nextPrefix.length >= 8) { + const nextPos = text.indexOf(nextPrefix, cursor); + if (nextPos !== -1) insertAt = nextPos; + } - const before = stripConstitutionTags(templateText.slice(0, tm.index)); - const exactPos = text.indexOf(before); - if (exactPos !== -1) { - const insertAt = exactPos + before.length; - text = text.slice(0, insertAt) + tm[0] + text.slice(insertAt); - continue; + if (insertAt === text.length) { + const anchor = getConstitutionTagAnchor(segBefore); + const anchorPos = text.indexOf(anchor, cursor); + if (anchorPos !== -1) { + insertAt = findConstitutionTagInsertAt(text, anchorPos + anchor.length, tm[1]); + } } - const anchor = getConstitutionTagAnchor(before); - const anchorPos = text.indexOf(anchor); - if (anchorPos === -1) continue; - const insertAt = findConstitutionTagInsertAt(text, anchorPos + anchor.length, tm[1]); - text = text.slice(0, insertAt) + tm[0] + text.slice(insertAt); + if (insertAt < cursor) insertAt = cursor; + result += text.slice(cursor, insertAt); + result += tm[0]; + cursor = insertAt; } - return text; + + result += text.slice(cursor); + return result; } // Render constitution text for display (hide machine-readable $TAG identifiers) @@ -398,7 +807,7 @@ async function loadConstitutionContent() { return; } if (freshGroup) { - selectedGroup = freshGroup; // safe: we checked generation + syncSelectedGroup(freshGroup); } const constitutionHtml = `
${renderConstitution(selectedGroup.constitution)}
`; @@ -693,16 +1102,16 @@ function formatTimeLeft(ms) { // Live diff preview in the propose amendment modal function updateAmendmentPreview() { - const oldText = selectedGroup?.constitution || ''; - const newText = document.getElementById('amendmentEditor')?.value ?? ''; + if (!selectedGroup) return; + const oldText = repairMissingConstitutionTags(selectedGroup.constitution || ''); + const displayText = getAmendmentEditorDisplayText(); const preview = document.getElementById('amendmentDiffPreview'); if (!preview) return; const oldDisplay = stripConstitutionTags(oldText); - const newDisplay = stripConstitutionTags(newText); - if (oldDisplay === newDisplay) { + if (oldDisplay === displayText) { preview.innerHTML = 'No changes yet.'; } else { - preview.innerHTML = wordDiff(oldDisplay, newDisplay); + preview.innerHTML = wordDiff(oldDisplay, displayText); } } @@ -712,35 +1121,58 @@ async function submitAmendment() { return; } const title = document.getElementById('amendmentTitle').value.trim(); - const displayText = document.getElementById('amendmentEditor').value; - const oldText = selectedGroup.constitution || ''; + const constitutionAtProposal = selectedGroup.constitution || ''; + const templateText = getAmendmentConstitutionTemplate(selectedGroup); + const displayText = getAmendmentEditorDisplayText(); + const values = getAmendmentEditorVariableValues(); if (!title) { showToast('Please enter a title for the amendment', 'error'); return; } - if (stripConstitutionTags(displayText) === stripConstitutionTags(oldText)) { - showToast('No changes detected in the constitution', 'error'); - return; + + const activeFields = getActiveConstitutionVariableFields(templateText); + for (const field of activeFields) { + if (field.tag === 'GROUP_NAME' && !values.GROUP_NAME) { + showToast('Please enter a group name', 'error'); + return; + } + if (field.inputType === 'number' && values[field.tag] !== '' && !Number.isFinite(Number(values[field.tag]))) { + showToast(`Please enter a valid number for ${field.label}`, 'error'); + return; + } } let newText; try { - newText = restoreConstitutionTags(displayText, oldText); - } catch (e) { - console.error('restoreConstitutionTags failed:', e); + newText = restoreConstitutionTags(displayText, templateText); + newText = buildConstitutionFromVariables(newText, values); + } catch (err) { + console.error('restoreConstitutionTags failed:', err); showToast('Could not prepare amendment text', 'error'); return; } + if (stripConstitutionTags(newText) === stripConstitutionTags(repairMissingConstitutionTags(constitutionAtProposal))) { + showToast('No changes detected in the constitution', 'error'); + return; + } - const threshold = parseAmendmentThreshold(oldText); - const periodDays = parseVotingPeriodDays(oldText); + const templateTagNames = getConstitutionTags(templateText).map((m) => m[1]); + const missingTagNames = templateTagNames.filter((name) => !constitutionHasTag(newText, name)); + if (missingTagNames.length > 0) { + console.error('Missing constitution tags after build:', missingTagNames); + showToast('Could not prepare amendment text', 'error'); + return; + } + + const threshold = parseAmendmentThreshold(constitutionAtProposal); + const periodDays = parseVotingPeriodDays(constitutionAtProposal); const amendmentRow = { group_id: selectedGroup.id, proposed_by: currentUser.id, title, - old_text: oldText, + old_text: constitutionAtProposal, new_text: newText, threshold }; @@ -822,14 +1254,8 @@ async function voteAmendment(amendmentId, approve) { ? `${data.approve_count}/${data.voter_count} voted` : `${data.approve_count}/${data.active_members} approved`; showToast(`Amendment passed! (${tally}, ${data.ratio}% ≥ ${data.threshold}% needed)`, 'success'); - // Refresh group data since constitution may have changed const { data: freshGroup } = await db.from('groups').select('*').eq('id', selectedGroup.id).single(); - if (freshGroup) { - selectedGroup = freshGroup; - const membership = myGroups.find(m => m.group_id === selectedGroup.id); - if (membership) membership.groups = freshGroup; - renderGroupList(); - } + if (freshGroup) syncSelectedGroup(freshGroup); await loadConstitutionContent(); return; } @@ -882,12 +1308,7 @@ async function resolveAmendment(amendmentId) { : `${data.approve_count}/${data.active_members} approved`; showToast(`Amendment passed! (${tally}, ${data.ratio}% ≥ ${data.threshold}% needed)`, 'success'); const { data: freshGroup } = await db.from('groups').select('*').eq('id', selectedGroup.id).single(); - if (freshGroup) { - selectedGroup = freshGroup; - const membership = myGroups.find(m => m.group_id === selectedGroup.id); - if (membership) membership.groups = freshGroup; - renderGroupList(); - } + if (freshGroup) syncSelectedGroup(freshGroup); } else { const tally = data.voting_period && data.voter_count != null ? `${data.approve_count}/${data.voter_count} voted` diff --git a/js/groups.js b/js/groups.js index 23946ce..8a9175c 100644 --- a/js/groups.js +++ b/js/groups.js @@ -10,6 +10,9 @@ async function loadMyGroups(autoNavigateGroupId) { if (error) { showToast('Failed to load groups', 'error'); return; } myGroups = data || []; + myGroups.forEach(m => { + if (m.groups) m.groups = syncGroupInMyGroups(m.groups); + }); // Fetch member counts for each group const groupIds = myGroups.map(m => m.group_id); @@ -61,24 +64,27 @@ function renderGroupList() { return; } hint.classList.add('hidden'); - el.innerHTML = myGroups.map(m => ` + el.innerHTML = myGroups.map(m => { + const groupName = getGroupDisplayName(m.groups); + return `
- ${renderGroupCardLogo(m.groups.logo_url, m.groups.name, m.groups.logo_updated_at)} + ${renderGroupCardLogo(m.groups.logo_url, groupName, m.groups.logo_updated_at)}
- ${esc(m.groups.name)} + ${esc(groupName)} ${m.status === 'pending' ? 'pending' : ''}
${m._memberCount || 0} member${m._memberCount === 1 ? '' : 's'}
- +
- `).join(''); + `; + }).join(''); if (typeof refreshLucideIcons === 'function') refreshLucideIcons(); } @@ -113,6 +119,34 @@ function updateGroupTabBar(group) { if (!currencyOn && activeTab === 'money') activeTab = 'members'; } +function getGroupDisplayName(group) { + if (!group) return ''; + const constitution = group.constitution || ''; + const tagged = constitution.match(/Group Name:\s*([^\n$]+?)\s*\$GROUP_NAME/i); + if (tagged?.[1]?.trim()) return tagged[1].trim(); + const labeled = constitution.match(/Group Name:\s*([^\n$]+)/i); + if (labeled?.[1]?.trim()) return labeled[1].trim(); + return group.name || ''; +} + +function syncGroupInMyGroups(groupData) { + if (!groupData?.id) return groupData; + const displayName = getGroupDisplayName(groupData); + const syncedGroup = displayName ? { ...groupData, name: displayName } : groupData; + const membership = myGroups.find(m => m.group_id === syncedGroup.id); + if (membership) membership.groups = syncedGroup; + if (selectedGroup?.id === syncedGroup.id) selectedGroup = syncedGroup; + return syncedGroup; +} + +function syncSelectedGroup(freshGroup) { + if (!freshGroup || !selectedGroup || freshGroup.id !== selectedGroup.id) return; + const syncedGroup = syncGroupInMyGroups(freshGroup); + const nameEl = document.getElementById('groupNameDisplay'); + if (nameEl) nameEl.textContent = syncedGroup.name; + renderGroupList(); +} + function selectGroupById(groupId) { const membership = myGroups.find(m => m.group_id === groupId); if (membership) selectGroup(membership.groups, membership); @@ -133,7 +167,7 @@ async function selectGroup(group, membership) { subscribeToGroup(group.id); // Show group name at top - document.getElementById('groupNameDisplay').textContent = group.name; + document.getElementById('groupNameDisplay').textContent = getGroupDisplayName(group); setGroupAvatar(group.logo_url || null, group.logo_updated_at); bindGroupLogoInput(); updateGroupLogoControl(membership?.status === 'active'); diff --git a/js/modals.js b/js/modals.js index 08e0ff1..f6875c8 100644 --- a/js/modals.js +++ b/js/modals.js @@ -243,13 +243,13 @@ function showModal(type) { body.innerHTML = `

Propose Constitutional Amendment

- - + +
- - + +

Variable values appear as blue fields inline. Hover a field to see its variable name.

+
@@ -265,6 +265,7 @@ function showModal(type) {
`; + initAmendmentConstitutionEditor(selectedGroup); } break; diff --git a/js/realtime.js b/js/realtime.js index 154ee7b..87e3fe5 100644 --- a/js/realtime.js +++ b/js/realtime.js @@ -140,12 +140,7 @@ async function handleGroupEvent(event) { // Amendment pass may have changed group settings if (event.event_type === 'amendment_passed') { const { data: freshGroup } = await db.from('groups').select('*').eq('id', selectedGroup.id).single(); - if (freshGroup) { - selectedGroup = freshGroup; - const membership = myGroups.find(m => m.group_id === selectedGroup.id); - if (membership) membership.groups = freshGroup; - renderGroupList(); - } + if (freshGroup) syncSelectedGroup(freshGroup); if (activeTab === 'money') await renderMoneyTab(); } break; diff --git a/sql/fairshare-schema.sql b/sql/fairshare-schema.sql index a557dcc..eab0378 100644 --- a/sql/fairshare-schema.sql +++ b/sql/fairshare-schema.sql @@ -1319,6 +1319,10 @@ declare v_constitution text; v_period_days int; v_window_end timestamptz; + v_group_name_applied boolean := false; + v_currency_name_applied boolean := false; + v_currency_symbol_applied boolean := false; + v_repaired_text text; begin -- Fetch the amendment select * into v_amendment @@ -1403,28 +1407,83 @@ begin end if; if v_passed then + -- Repair missing $TAG markers in stored constitution (e.g. after earlier bad amendments) + v_repaired_text := v_amendment.new_text; + if v_repaired_text !~ '\$GROUP_NAME' then + v_repaired_text := regexp_replace(v_repaired_text, '(Group Name:\s*[^\n$]+)', '\1 $GROUP_NAME', 'i'); + end if; + if v_repaired_text !~ '\$VOTING_PERIOD_DAYS' then + v_repaired_text := regexp_replace( + v_repaired_text, + '(Voting will happen over a period of \d+\s*days)', + '\1 $VOTING_PERIOD_DAYS', + 'i' + ); + end if; + if v_repaired_text !~ '\$CURRENCY_NAME' then + v_repaired_text := regexp_replace(v_repaired_text, '(Currency Name:\s*[^,\n$]+)', '\1 $CURRENCY_NAME', 'i'); + end if; + if v_repaired_text !~ '\$CURRENCY_SYMBOL' then + v_repaired_text := regexp_replace(v_repaired_text, '(Currency Symbol:\s*[^,\n$]+)', '\1 $CURRENCY_SYMBOL', 'i'); + end if; + if v_repaired_text !~ '\$CHANGE_CURRENCY_RATES_PERCENTAGE' then + v_repaired_text := regexp_replace( + v_repaired_text, + '(Change Currency Rates:\s*\d+%)', + '\1 $CHANGE_CURRENCY_RATES_PERCENTAGE', + 'i' + ); + end if; + if v_repaired_text !~ '\$NEW_MEMBER_PERCENTAGE' then + v_repaired_text := regexp_replace( + v_repaired_text, + '(To Approve New Member:\s*\d+%)', + '\1 $NEW_MEMBER_PERCENTAGE', + 'i' + ); + end if; + if v_repaired_text !~ '\$AMENDMENT_PERCENTAGE' then + v_repaired_text := regexp_replace( + v_repaired_text, + '(To Approve Amendment:\s*\d+%)', + '\1 $AMENDMENT_PERCENTAGE', + 'i' + ); + end if; + if v_repaired_text !~ '\$ACCORD_PERCENTAGE' then + v_repaired_text := regexp_replace( + v_repaired_text, + '(To Approve a proposed accord:\s*\d+%)', + '\1 $ACCORD_PERCENTAGE', + 'i' + ); + end if; + -- Update the constitution update public.groups - set constitution = v_amendment.new_text + set constitution = v_repaired_text where id = v_amendment.group_id; - -- Parse tagged variables from new_text and apply changes + -- Parse tagged variables from repaired text and apply changes -- Tags appear as $TAG_NAME anywhere in the text (not necessarily at end of line) -- The value is everything between the nearest preceding colon and the $TAG for v_parts in select (m)[1] as val, (m)[2] as tag - from regexp_matches(v_amendment.new_text, ':\s*([^:]*?)\s*\$([A-Z_]+)', 'g') as m + from regexp_matches(v_repaired_text, ':\s*([^:]*?)\s*\$([A-Z_]+)', 'g') as m loop - v_value := v_parts.val; + v_value := trim(v_parts.val); v_tag := v_parts.tag; case v_tag when 'GROUP_NAME' then update public.groups set name = v_value where id = v_amendment.group_id; + v_group_name_applied := true; when 'CURRENCY_NAME' then update public.groups set currency_name = v_value where id = v_amendment.group_id; + v_currency_name_applied := true; when 'CURRENCY_SYMBOL' then update public.groups set currency_symbol = v_value where id = v_amendment.group_id; + v_currency_symbol_applied := true; -- AMENDMENT_PERCENTAGE, NEW_MEMBER_PERCENTAGE, and -- CHANGE_CURRENCY_RATES_PERCENTAGE are read from constitution text -- at runtime, no separate column to update @@ -1433,6 +1492,27 @@ begin end case; end loop; + -- Always sync group name from constitution prose (authoritative after amendments) + select trim((regexp_match(v_repaired_text, 'Group Name:\s*([^\n$]+)', 'i'))[1]) into v_value; + if v_value is not null and v_value <> '' then + update public.groups set name = v_value where id = v_amendment.group_id; + v_group_name_applied := true; + end if; + + if not v_currency_name_applied then + select trim((regexp_match(v_repaired_text, 'Currency Name:\s*([^,\n$]+)', 'i'))[1]) into v_value; + if v_value is not null and v_value <> '' then + update public.groups set currency_name = v_value where id = v_amendment.group_id; + end if; + end if; + + if not v_currency_symbol_applied then + select trim((regexp_match(v_repaired_text, 'Currency Symbol:\s*([^,\n$]+)', 'i'))[1]) into v_value; + if v_value is not null and v_value <> '' then + update public.groups set currency_symbol = v_value where id = v_amendment.group_id; + end if; + end if; + -- Mark as passed update public.amendments set status = 'passed', resolved_at = now() diff --git a/styles.css b/styles.css index 2a9134c..45138b2 100644 --- a/styles.css +++ b/styles.css @@ -615,6 +615,10 @@ body { margin-bottom: 0.3rem; color: var(--accent-color); } +.required-marker { + color: var(--red); + margin-right: 0.15rem; +} .form-group input, .form-group select, .form-group textarea { width: 100%; padding: 0.6rem 0.8rem; @@ -1951,6 +1955,57 @@ body.dm-screen-active .main-content { border-radius: 4px; margin-left: 0.3rem; } +.constitution-editor { + font-family: 'Inter', sans-serif; + font-size: 1rem; + line-height: 1.8; + white-space: pre-wrap; + padding: 1rem; + background: var(--light-gray); + border-radius: 6px; + border: 1px solid var(--medium-gray); + min-height: 12rem; + max-height: 20rem; + overflow-y: auto; +} +.constitution-editor .constitution-prose { + outline: none; +} +.constitution-editor .constitution-var-input { + background: #e3f0f8; + color: var(--primary-color); + font-family: inherit; + font-size: 0.85rem; + font-weight: 600; + padding: 0.1rem 0.4rem; + border-radius: 4px; + border: 1px solid transparent; + margin: 0 0.15rem; + vertical-align: baseline; + field-sizing: content; + width: auto; + min-width: 0; + box-sizing: content-box; + cursor: text; +} +.constitution-editor .constitution-var-input:hover { + border-color: #b8d4e8; +} +.constitution-editor .constitution-var-input:focus { + border-color: var(--primary-color); + outline: none; +} +.amendment-section-label { + color: var(--accent-color); + font-size: 0.95rem; + margin: 0.25rem 0 0.35rem; +} +.amendment-section-hint { + color: var(--dark-gray); + font-size: 0.85rem; + margin: 0 0 0.75rem; + line-height: 1.45; +} .diff-display { font-family: 'Inter', sans-serif; font-size: 0.9rem;