diff --git a/js/constitution.js b/js/constitution.js
index 3bd05dc..c6ed2ea 100644
--- a/js/constitution.js
+++ b/js/constitution.js
@@ -46,11 +46,495 @@ function wordDiff(oldText, newText) {
}).join('');
}
-// Render constitution text with highlighted tags
+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 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 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));
+}
+
+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 (CONSTITUTION_OF_MEMBER_TAGS.has(tagName)) {
+ 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-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;
+
+ 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;
+
+ for (const tm of tagMatches) {
+ 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;
+ }
+ }
+ 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;
+
+ for (let i = 0; i < tagMatches.length; i++) {
+ const tm = tagMatches[i];
+ 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;
+ }
+
+ 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]);
+ }
+ }
+
+ if (insertAt < cursor) insertAt = cursor;
+ result += text.slice(cursor, insertAt);
+ result += tm[0];
+ cursor = insertAt;
+ }
+
+ result += text.slice(cursor);
+ return result;
+}
+
+// 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)
@@ -323,7 +807,7 @@ async function loadConstitutionContent() {
return;
}
if (freshGroup) {
- selectedGroup = freshGroup; // safe: we checked generation
+ syncSelectedGroup(freshGroup);
}
const constitutionHtml = `
${renderConstitution(selectedGroup.constitution)}
`;
@@ -495,7 +979,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') {
@@ -615,33 +1102,77 @@ 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 (oldText === newText) {
+ if (!preview) return;
+ const oldDisplay = stripConstitutionTags(oldText);
+ if (oldDisplay === displayText) {
preview.innerHTML = '
No changes yet.';
} else {
- preview.innerHTML = wordDiff(oldText, newText);
+ preview.innerHTML = wordDiff(oldDisplay, displayText);
}
}
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 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 (newText === oldText) { showToast('No changes detected in the constitution', 'error'); return; }
+ if (!title) {
+ showToast('Please enter a title for the amendment', 'error');
+ return;
+ }
- const threshold = parseAmendmentThreshold(oldText);
- const periodDays = parseVotingPeriodDays(oldText);
+ 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, 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 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
};
@@ -723,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;
}
@@ -783,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/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/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 5b617c8..f6875c8 100644
--- a/js/modals.js
+++ b/js/modals.js
@@ -243,13 +243,13 @@ function showModal(type) {
body.innerHTML = `
Propose Constitutional Amendment
-
-
+
+
@@ -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 eec857a..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;
@@ -2348,6 +2403,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 +2421,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 +2435,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;