@jjlmoya/utils-civic 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +3 -1
  3. package/src/entries.ts +6 -0
  4. package/src/index.ts +2 -0
  5. package/src/pages/[locale]/[slug].astro +7 -4
  6. package/src/tests/locale_completeness.test.ts +1 -1
  7. package/src/tests/tool_validation.test.ts +1 -1
  8. package/src/tool/civic-priority-matrix/bibliography.astro +6 -0
  9. package/src/tool/civic-priority-matrix/bibliography.ts +12 -0
  10. package/src/tool/civic-priority-matrix/civic-priority-matrix.css +531 -0
  11. package/src/tool/civic-priority-matrix/component.astro +108 -0
  12. package/src/tool/civic-priority-matrix/contract.test.ts +16 -0
  13. package/src/tool/civic-priority-matrix/controller.ts +168 -0
  14. package/src/tool/civic-priority-matrix/dom-views.ts +123 -0
  15. package/src/tool/civic-priority-matrix/entry.ts +28 -0
  16. package/src/tool/civic-priority-matrix/evaluator.ts +13 -0
  17. package/src/tool/civic-priority-matrix/i18n/de.ts +52 -0
  18. package/src/tool/civic-priority-matrix/i18n/en.ts +73 -0
  19. package/src/tool/civic-priority-matrix/i18n/es.ts +52 -0
  20. package/src/tool/civic-priority-matrix/i18n/fr.ts +50 -0
  21. package/src/tool/civic-priority-matrix/i18n/id.ts +50 -0
  22. package/src/tool/civic-priority-matrix/i18n/it.ts +50 -0
  23. package/src/tool/civic-priority-matrix/i18n/ja.ts +121 -0
  24. package/src/tool/civic-priority-matrix/i18n/ko.ts +121 -0
  25. package/src/tool/civic-priority-matrix/i18n/nl.ts +45 -0
  26. package/src/tool/civic-priority-matrix/i18n/pl.ts +35 -0
  27. package/src/tool/civic-priority-matrix/i18n/pt.ts +32 -0
  28. package/src/tool/civic-priority-matrix/i18n/ru.ts +32 -0
  29. package/src/tool/civic-priority-matrix/i18n/sv.ts +32 -0
  30. package/src/tool/civic-priority-matrix/i18n/tr.ts +32 -0
  31. package/src/tool/civic-priority-matrix/i18n/zh.ts +121 -0
  32. package/src/tool/civic-priority-matrix/index.ts +11 -0
  33. package/src/tool/civic-priority-matrix/logic.test.ts +68 -0
  34. package/src/tool/civic-priority-matrix/logic.ts +124 -0
  35. package/src/tool/civic-priority-matrix/seo.astro +12 -0
  36. package/src/tool/civic-priority-matrix/storage.ts +50 -0
  37. package/src/tool/civic-priority-matrix/ui.ts +96 -0
  38. package/src/tool/referendum-threshold-calculator/bibliography.astro +6 -0
  39. package/src/tool/referendum-threshold-calculator/bibliography.ts +6 -0
  40. package/src/tool/referendum-threshold-calculator/component.astro +117 -0
  41. package/src/tool/referendum-threshold-calculator/controller.ts +141 -0
  42. package/src/tool/referendum-threshold-calculator/dom-views.ts +71 -0
  43. package/src/tool/referendum-threshold-calculator/entry.ts +27 -0
  44. package/src/tool/referendum-threshold-calculator/evaluator.ts +19 -0
  45. package/src/tool/referendum-threshold-calculator/i18n/de.ts +56 -0
  46. package/src/tool/referendum-threshold-calculator/i18n/en.ts +79 -0
  47. package/src/tool/referendum-threshold-calculator/i18n/es.ts +56 -0
  48. package/src/tool/referendum-threshold-calculator/i18n/fr.ts +56 -0
  49. package/src/tool/referendum-threshold-calculator/i18n/id.ts +56 -0
  50. package/src/tool/referendum-threshold-calculator/i18n/it.ts +56 -0
  51. package/src/tool/referendum-threshold-calculator/i18n/ja.ts +56 -0
  52. package/src/tool/referendum-threshold-calculator/i18n/ko.ts +56 -0
  53. package/src/tool/referendum-threshold-calculator/i18n/nl.ts +56 -0
  54. package/src/tool/referendum-threshold-calculator/i18n/pl.ts +56 -0
  55. package/src/tool/referendum-threshold-calculator/i18n/pt.ts +56 -0
  56. package/src/tool/referendum-threshold-calculator/i18n/ru.ts +56 -0
  57. package/src/tool/referendum-threshold-calculator/i18n/sv.ts +56 -0
  58. package/src/tool/referendum-threshold-calculator/i18n/tr.ts +56 -0
  59. package/src/tool/referendum-threshold-calculator/i18n/zh.ts +56 -0
  60. package/src/tool/referendum-threshold-calculator/index.ts +11 -0
  61. package/src/tool/referendum-threshold-calculator/logic.test.ts +69 -0
  62. package/src/tool/referendum-threshold-calculator/logic.ts +120 -0
  63. package/src/tool/referendum-threshold-calculator/referendum-threshold-calculator.css +640 -0
  64. package/src/tool/referendum-threshold-calculator/seo.astro +14 -0
  65. package/src/tool/referendum-threshold-calculator/sharing.test.ts +23 -0
  66. package/src/tool/referendum-threshold-calculator/sharing.ts +42 -0
  67. package/src/tool/referendum-threshold-calculator/storage.ts +39 -0
  68. package/src/tool/referendum-threshold-calculator/ui.ts +126 -0
  69. package/src/tools.ts +4 -0
@@ -0,0 +1,12 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { civicPriorityMatrix } from './entry';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props { locale?: KnownLocale; }
7
+
8
+ const { locale = 'en' } = Astro.props as Props;
9
+ const content = await civicPriorityMatrix.i18n[locale]?.() ?? await civicPriorityMatrix.i18n.en?.();
10
+ ---
11
+
12
+ {content && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -0,0 +1,50 @@
1
+ import type { InitiativeInput } from './logic';
2
+
3
+ export interface CivicPriorityState {
4
+ initiatives: InitiativeInput[];
5
+ }
6
+
7
+ const STORAGE_KEY = 'jjlmoya-civic-priority-matrix';
8
+
9
+ export const defaultInitiatives: InitiativeInput[] = [
10
+ { id: 'accessible-bus-stops', name: 'Accessible bus stops', impact: 5, urgency: 4, effort: 2 },
11
+ { id: 'cooling-centres', name: 'Cooling centres', impact: 4, urgency: 5, effort: 3 },
12
+ { id: 'meeting-transcripts', name: 'Public meeting transcripts', impact: 3, urgency: 2, effort: 1 },
13
+ { id: 'park-lighting', name: 'Park lighting repair', impact: 4, urgency: 3, effort: 4 },
14
+ ];
15
+
16
+ function isInitiative(value: unknown): value is InitiativeInput {
17
+ if (!value || typeof value !== 'object') return false;
18
+ const item = value as Record<string, unknown>;
19
+ return typeof item.id === 'string'
20
+ && typeof item.name === 'string'
21
+ && typeof item.impact === 'number'
22
+ && typeof item.urgency === 'number'
23
+ && typeof item.effort === 'number';
24
+ }
25
+
26
+ function isState(value: unknown): value is CivicPriorityState {
27
+ if (!value || typeof value !== 'object') return false;
28
+ const state = value as Record<string, unknown>;
29
+ return Array.isArray(state.initiatives) && state.initiatives.every(isInitiative);
30
+ }
31
+
32
+ export function loadCivicPriorityState(): CivicPriorityState | null {
33
+ try {
34
+ if (typeof window === 'undefined') return null;
35
+ const raw = window.localStorage.getItem(STORAGE_KEY);
36
+ if (!raw) return null;
37
+ const parsed: unknown = JSON.parse(raw);
38
+ return isState(parsed) ? parsed : null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ export function saveCivicPriorityState(state: CivicPriorityState): void {
45
+ try {
46
+ if (typeof window !== 'undefined') window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
47
+ } catch {
48
+ return;
49
+ }
50
+ }
@@ -0,0 +1,96 @@
1
+ export interface CivicPriorityUI {
2
+ [key: string]: string;
3
+ addInitiative: string;
4
+ initiativeList: string;
5
+ inputHint: string;
6
+ initiative: string;
7
+ impact: string;
8
+ urgency: string;
9
+ effort: string;
10
+ scoreScale: string;
11
+ scoreHint: string;
12
+ removeInitiative: string;
13
+ reset: string;
14
+ resultTitle: string;
15
+ rankingTitle: string;
16
+ matrixLabel: string;
17
+ summaryPrefix: string;
18
+ summarySuffix: string;
19
+ rank: string;
20
+ priorityScore: string;
21
+ denominator: string;
22
+ quadrant: string;
23
+ emptyState: string;
24
+ invalidState: string;
25
+ nameRequired: string;
26
+ impactInvalid: string;
27
+ urgencyInvalid: string;
28
+ effortInvalid: string;
29
+ actNow: string;
30
+ plan: string;
31
+ quickResponse: string;
32
+ defer: string;
33
+ highImpact: string;
34
+ lowImpact: string;
35
+ highUrgency: string;
36
+ lowUrgency: string;
37
+ methodTitle: string;
38
+ methodText: string;
39
+ notDoTitle: string;
40
+ notDoText: string;
41
+ edgeCasesTitle: string;
42
+ edgeCasesText: string;
43
+ warningTitle: string;
44
+ zeroEffortWarning: string;
45
+ invalidRowsWarning: string;
46
+ noInitiatives: string;
47
+ scoreOutOf: string;
48
+ }
49
+
50
+ export const ui: CivicPriorityUI = {
51
+ addInitiative: 'Add initiative',
52
+ initiativeList: 'Initiatives to score',
53
+ inputHint: 'Give every proposal the same 1 to 5 scale before comparing them.',
54
+ initiative: 'Initiative',
55
+ impact: 'Impact',
56
+ urgency: 'Urgency',
57
+ effort: 'Effort',
58
+ scoreScale: 'Score from 1 to 5',
59
+ scoreHint: 'Higher means more impact, more urgency, or more effort.',
60
+ removeInitiative: 'Remove initiative',
61
+ reset: 'Reset example',
62
+ resultTitle: 'Decision field',
63
+ rankingTitle: 'Explainable order',
64
+ matrixLabel: 'Initiatives placed by impact and urgency',
65
+ summaryPrefix: 'Showing',
66
+ summarySuffix: 'scored initiatives',
67
+ rank: 'Rank',
68
+ priorityScore: 'Priority score',
69
+ denominator: 'Effort denominator',
70
+ quadrant: 'Quadrant',
71
+ emptyState: 'Add an initiative to open the decision field.',
72
+ invalidState: 'Fix the highlighted values to calculate a priority order.',
73
+ nameRequired: 'Name is required.',
74
+ impactInvalid: 'Impact must be a whole number from 1 to 5.',
75
+ urgencyInvalid: 'Urgency must be a whole number from 1 to 5.',
76
+ effortInvalid: 'Effort must be a whole number from 0 to 5.',
77
+ actNow: 'Act now',
78
+ plan: 'Plan',
79
+ quickResponse: 'Quick response',
80
+ defer: 'Defer',
81
+ highImpact: 'High impact',
82
+ lowImpact: 'Low impact',
83
+ highUrgency: 'High urgency',
84
+ lowUrgency: 'Low urgency',
85
+ methodTitle: 'Method applied',
86
+ methodText: 'The priority score is impact multiplied by urgency, divided by effort. Impact, urgency, and effort use a visible 1 to 5 scale. Effort 0 is treated as 1 so a free or already-resourced action remains finite rather than becoming an infinite score. The ranking breaks ties by impact, urgency, lower effort, then input order.',
87
+ notDoTitle: 'What this tool does not do',
88
+ notDoText: 'It does not establish objective social value, measure outcomes automatically, or replace deliberation. A high score is a transparent scenario under your assumptions, not a mandate or a prediction of public benefit.',
89
+ edgeCasesTitle: 'Edge cases and warnings',
90
+ edgeCasesText: 'Keep the same interpretation of each score across every row. A missing or non-integer score excludes that row until fixed. Score 3 is the dividing line in the field. Equal scores remain in stable input order after the documented tie-breakers. Change one assessment at a time when discussing a result with a group.',
91
+ warningTitle: 'Review before deciding',
92
+ zeroEffortWarning: 'An effort score of 0 uses a denominator of 1 to keep the score finite and comparable.',
93
+ invalidRowsWarning: 'Some rows are excluded until their highlighted values are fixed.',
94
+ noInitiatives: 'No initiatives entered yet.',
95
+ scoreOutOf: 'of 25 maximum',
96
+ };
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliography } from './bibliography';
4
+ ---
5
+
6
+ <Bibliography links={bibliography} />
@@ -0,0 +1,6 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ { name: 'International IDEA, Direct Democracy Handbook, chapter on referendum rules', url: 'https://www.idea.int/sites/default/files/publications/direct-democracy-the-international-idea-handbook.pdf' },
5
+ { name: 'Commission de Venise, Code de bonne conduite en matière référendaire révisé', url: 'https://www.venice.coe.int/webforms/documents/?pdf=CDL-AD%282022%29015-f' },
6
+ ];
@@ -0,0 +1,117 @@
1
+ ---
2
+ import type { ReferendumThresholdUI } from './ui';
3
+ import './referendum-threshold-calculator.css';
4
+
5
+ interface Props {
6
+ ui: ReferendumThresholdUI;
7
+ }
8
+
9
+ const { ui } = Astro.props as Props;
10
+ ---
11
+
12
+ <div class="referendum-tool" data-referendum-threshold-tool>
13
+ <div class="rule-workbench">
14
+ <form class="rule-form" data-referendum-form>
15
+ <fieldset class="count-block">
16
+ <legend>{ui.countsHeading}</legend>
17
+ <label class="wide-field">{ui.eligibleLabel}<input name="eligibleVoters" type="number" min="0" step="1" value="1000" required /><small>{ui.eligibleHelp}</small></label>
18
+ <div class="count-grid">
19
+ <label>{ui.yesLabel}<input name="yes" type="number" min="0" step="1" value="520" required /></label>
20
+ <label>{ui.noLabel}<input name="no" type="number" min="0" step="1" value="300" required /></label>
21
+ <label>{ui.blankLabel}<input name="blank" type="number" min="0" step="1" value="20" required /></label>
22
+ <label>{ui.invalidLabel}<input name="invalid" type="number" min="0" step="1" value="10" required /></label>
23
+ </div>
24
+ </fieldset>
25
+
26
+ <fieldset class="quorum-block participation-block">
27
+ <legend>{ui.participationHeading}</legend>
28
+ <p>{ui.participationHelp}</p>
29
+ <div class="mode-switch" role="group" aria-label={ui.thresholdModeLabel}>
30
+ <button type="button" class="active" data-mode-prefix="participation" data-mode="percentage" aria-pressed="true">{ui.percentageOption}</button>
31
+ <button type="button" data-mode-prefix="participation" data-mode="absolute" aria-pressed="false">{ui.absoluteOption}</button>
32
+ </div>
33
+ <div class="comparison-row" role="group" aria-label={ui.comparisonLabel}>
34
+ <span>{ui.comparisonLabel}</span>
35
+ <input name="participationComparison" type="hidden" value="at-least" />
36
+ <button type="button" class="active" data-comparison-prefix="participation" data-comparison="at-least" aria-pressed="true">{ui.atLeastOption}</button>
37
+ <button type="button" data-comparison-prefix="participation" data-comparison="more-than" aria-pressed="false">{ui.moreThanOption}</button>
38
+ </div>
39
+ <div class="quorum-fields">
40
+ <input name="participationMode" type="hidden" value="percentage" />
41
+ <label>{ui.thresholdValueLabel}<input name="participationThreshold" type="number" min="0" max="100" step="0.1" value="50" required /></label>
42
+ <div class="custom-select" data-select>
43
+ <span class="field-label">{ui.denominatorLabel}</span>
44
+ <input name="participationDenominator" type="hidden" value="all-ballots" />
45
+ <button type="button" data-select-trigger aria-expanded="false">{ui.allBallotsOption}</button>
46
+ <div class="select-menu" data-select-menu hidden role="listbox">
47
+ <button type="button" data-option="all-ballots" role="option">{ui.allBallotsOption}</button>
48
+ <button type="button" data-option="valid-votes" role="option">{ui.validVotesOption}</button>
49
+ </div>
50
+ </div>
51
+ </div>
52
+ </fieldset>
53
+
54
+ <fieldset class="quorum-block approval-block">
55
+ <legend>{ui.approvalHeading}</legend>
56
+ <p>{ui.approvalHelp}</p>
57
+ <div class="mode-switch" role="group" aria-label={ui.thresholdModeLabel}>
58
+ <button type="button" class="active" data-mode-prefix="approval" data-mode="percentage" aria-pressed="true">{ui.percentageOption}</button>
59
+ <button type="button" data-mode-prefix="approval" data-mode="absolute" aria-pressed="false">{ui.absoluteOption}</button>
60
+ </div>
61
+ <div class="comparison-row" role="group" aria-label={ui.comparisonLabel}>
62
+ <span>{ui.comparisonLabel}</span>
63
+ <input name="approvalComparison" type="hidden" value="at-least" />
64
+ <button type="button" class="active" data-comparison-prefix="approval" data-comparison="at-least" aria-pressed="true">{ui.atLeastOption}</button>
65
+ <button type="button" data-comparison-prefix="approval" data-comparison="more-than" aria-pressed="false">{ui.moreThanOption}</button>
66
+ </div>
67
+ <div class="preset-row" role="group" aria-label={ui.presetsLabel}>
68
+ <span>{ui.presetsLabel}</span>
69
+ <button type="button" data-preset="simple-majority">{ui.simpleMajorityPreset}</button>
70
+ <button type="button" data-preset="electorate-majority">{ui.electorateMajorityPreset}</button>
71
+ <button type="button" data-preset="qualified-55">{ui.qualified55Preset}</button>
72
+ <button type="button" data-preset="qualified-60">{ui.qualified60Preset}</button>
73
+ </div>
74
+ <div class="quorum-fields">
75
+ <input name="approvalMode" type="hidden" value="percentage" />
76
+ <label>{ui.thresholdValueLabel}<input name="approvalThreshold" type="number" min="0" max="100" step="0.1" value="50" required /></label>
77
+ <div class="custom-select" data-select>
78
+ <span class="field-label">{ui.denominatorLabel}</span>
79
+ <input name="approvalDenominator" type="hidden" value="valid-votes" />
80
+ <button type="button" data-select-trigger aria-expanded="false">{ui.validVotesOption}</button>
81
+ <div class="select-menu" data-select-menu hidden role="listbox">
82
+ <button type="button" data-option="all-ballots" role="option">{ui.allBallotsOption}</button>
83
+ <button type="button" data-option="valid-votes" role="option">{ui.validVotesOption}</button>
84
+ <button type="button" data-option="eligible-voters" role="option">{ui.eligibleVotersOption}</button>
85
+ </div>
86
+ </div>
87
+ </div>
88
+ </fieldset>
89
+
90
+ <div class="form-actions"><button class="secondary-action" type="button" data-reset>{ui.loadExample}</button><button class="share-action" type="button" data-share>{ui.shareAction}</button></div>
91
+ <p class="tool-status" data-status role="status"></p>
92
+ </form>
93
+
94
+ <section class="decision-panel" aria-live="polite" aria-labelledby="referendum-result-heading">
95
+ <div class="panel-heading"><span class="panel-index">02</span><h2 id="referendum-result-heading">{ui.resultHeading}</h2></div>
96
+ <div class="decision-scene" data-scene></div>
97
+ <div class="empty-result" data-empty-result>{ui.emptyResult}</div>
98
+ <div class="result-content" data-result hidden></div>
99
+ </section>
100
+ </div>
101
+
102
+ <section class="guardrails" aria-label="Method and limits">
103
+ <article><span class="guardrail-mark">01</span><h2>{ui.methodHeading}</h2><p>{ui.methodText}</p></article>
104
+ <article><span class="guardrail-mark">02</span><h2>{ui.limitsHeading}</h2><p>{ui.limitsText}</p></article>
105
+ <article><span class="guardrail-mark">03</span><h2>{ui.warningsHeading}</h2><p>{ui.warningsText}</p></article>
106
+ </section>
107
+
108
+ <script is:inline type="application/json" data-referendum-threshold-config set:html={JSON.stringify({ ui })}></script>
109
+ </div>
110
+
111
+ <script>
112
+ import { mountReferendumThresholdTool } from './controller';
113
+
114
+ const root = document.querySelector<HTMLElement>('[data-referendum-threshold-tool]');
115
+ const config = root?.querySelector<HTMLScriptElement>('[data-referendum-threshold-config]');
116
+ if (root && config) mountReferendumThresholdTool(root, JSON.parse(config.textContent || '{}').ui);
117
+ </script>
@@ -0,0 +1,141 @@
1
+ import { evaluateReferendum, getExampleInput, type DenominatorMode, type ReferendumInput, type ThresholdComparison, type ThresholdMode, type ThresholdRule } from './logic';
2
+ import { renderDecision, renderDecisionScene, formatInputValue } from './dom-views';
3
+ import { loadSavedInput, saveInput } from './storage';
4
+ import { buildShareUrl, readShareUrl } from './sharing';
5
+ import type { ReferendumThresholdUI } from './ui';
6
+
7
+ function getInput(root: HTMLElement, name: string): HTMLInputElement {
8
+ return root.querySelector<HTMLInputElement>(`[name="${name}"]`)!;
9
+ }
10
+
11
+ function numericValue(root: HTMLElement, name: string): number {
12
+ return Number(getInput(root, name).value);
13
+ }
14
+
15
+ function selectedValue(root: HTMLElement, name: string): string {
16
+ return getInput(root, name).value;
17
+ }
18
+
19
+ function readRule(root: HTMLElement, prefix: string): ThresholdRule {
20
+ return { mode: selectedValue(root, `${prefix}Mode`) as ThresholdMode, value: numericValue(root, `${prefix}Threshold`), denominator: selectedValue(root, `${prefix}Denominator`) as DenominatorMode, comparison: selectedValue(root, `${prefix}Comparison`) as ThresholdComparison };
21
+ }
22
+
23
+ function readInput(root: HTMLElement): ReferendumInput {
24
+ return { counts: { eligibleVoters: numericValue(root, 'eligibleVoters'), yes: numericValue(root, 'yes'), no: numericValue(root, 'no'), blank: numericValue(root, 'blank'), invalid: numericValue(root, 'invalid') }, rules: { participation: readRule(root, 'participation'), approval: readRule(root, 'approval') } };
25
+ }
26
+
27
+ function updateSelectLabel(root: HTMLElement, name: string): void {
28
+ const field = getInput(root, name);
29
+ const select = field.closest<HTMLElement>('[data-select]');
30
+ const active = select?.querySelector<HTMLElement>(`[data-option="${field.value}"]`);
31
+ const trigger = select?.querySelector<HTMLButtonElement>('[data-select-trigger]');
32
+ if (trigger && active) trigger.textContent = active.textContent;
33
+ }
34
+
35
+ function updateModeButtons(root: HTMLElement, prefix: string, mode: ThresholdMode): void {
36
+ const threshold = getInput(root, `${prefix}Threshold`);
37
+ threshold.max = mode === 'percentage' ? '100' : '';
38
+ root.querySelectorAll<HTMLButtonElement>(`[data-mode-prefix="${prefix}"]`).forEach((button) => { const active = button.dataset.mode === mode; button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active)); });
39
+ }
40
+
41
+ function updateComparisonButtons(root: HTMLElement, prefix: string, comparison: ThresholdComparison): void {
42
+ root.querySelectorAll<HTMLButtonElement>(`[data-comparison-prefix="${prefix}"]`).forEach((button) => { const active = button.dataset.comparison === comparison; button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active)); });
43
+ }
44
+
45
+ function updateRule(root: HTMLElement, prefix: string, rule: ThresholdRule): void {
46
+ getInput(root, `${prefix}Mode`).value = rule.mode;
47
+ getInput(root, `${prefix}Threshold`).value = formatInputValue(rule.value);
48
+ getInput(root, `${prefix}Denominator`).value = rule.denominator;
49
+ getInput(root, `${prefix}Comparison`).value = rule.comparison ?? 'at-least';
50
+ updateSelectLabel(root, `${prefix}Denominator`);
51
+ updateModeButtons(root, prefix, rule.mode);
52
+ updateComparisonButtons(root, prefix, rule.comparison ?? 'at-least');
53
+ }
54
+
55
+ function emitChange(field: HTMLInputElement): void {
56
+ field.dispatchEvent(new Event('change', { bubbles: true }));
57
+ }
58
+
59
+ function writeInput(root: HTMLElement, input: ReferendumInput): void {
60
+ Object.entries(input.counts).forEach(([name, value]) => { getInput(root, name).value = formatInputValue(value); });
61
+ ['participation', 'approval'].forEach((prefix) => { updateRule(root, prefix, input.rules[prefix as 'participation' | 'approval'] as ThresholdRule); });
62
+ }
63
+
64
+ function wireSelect(root: HTMLElement, select: HTMLElement): void {
65
+ const trigger = select.querySelector<HTMLButtonElement>('[data-select-trigger]');
66
+ const menu = select.querySelector<HTMLElement>('[data-select-menu]');
67
+ const field = select.querySelector<HTMLInputElement>('input[type="hidden"]');
68
+ if (!trigger || !menu || !field) return;
69
+ trigger.addEventListener('click', () => { const open = menu.hidden; root.querySelectorAll<HTMLElement>('[data-select-menu]').forEach((item) => { item.hidden = true; }); menu.hidden = !open; trigger.setAttribute('aria-expanded', String(open)); });
70
+ menu.querySelectorAll<HTMLButtonElement>('[data-option]').forEach((option) => option.addEventListener('click', () => { field.value = option.dataset.option ?? ''; updateSelectLabel(root, field.name); menu.hidden = true; trigger.setAttribute('aria-expanded', 'false'); emitChange(field); }));
71
+ }
72
+
73
+ function wireModeButtons(root: HTMLElement): void {
74
+ root.querySelectorAll<HTMLButtonElement>('[data-mode-prefix]').forEach((button) => button.addEventListener('click', () => { const prefix = button.dataset.modePrefix!; const mode = button.dataset.mode as ThresholdMode; const field = getInput(root, `${prefix}Mode`); field.value = mode; updateModeButtons(root, prefix, mode); emitChange(field); }));
75
+ }
76
+
77
+ function wireComparisonButtons(root: HTMLElement): void {
78
+ root.querySelectorAll<HTMLButtonElement>('[data-comparison-prefix]').forEach((button) => button.addEventListener('click', () => { const prefix = button.dataset.comparisonPrefix!; const comparison = button.dataset.comparison as ThresholdComparison; const field = getInput(root, `${prefix}Comparison`); field.value = comparison; updateComparisonButtons(root, prefix, comparison); emitChange(field); }));
79
+ }
80
+
81
+ function wirePresets(root: HTMLElement): void {
82
+ const presets: Record<string, ThresholdRule> = {
83
+ 'simple-majority': { mode: 'percentage', value: 50, denominator: 'valid-votes', comparison: 'more-than' },
84
+ 'electorate-majority': { mode: 'percentage', value: 50, denominator: 'eligible-voters', comparison: 'more-than' },
85
+ 'qualified-55': { mode: 'percentage', value: 55, denominator: 'valid-votes', comparison: 'at-least' },
86
+ 'qualified-60': { mode: 'percentage', value: 60, denominator: 'valid-votes', comparison: 'at-least' },
87
+ };
88
+ root.querySelectorAll<HTMLButtonElement>('[data-preset]').forEach((button) => button.addEventListener('click', () => { const preset = presets[button.dataset.preset ?? '']; if (preset) { updateRule(root, 'approval', preset); emitChange(getInput(root, 'approvalThreshold')); } }));
89
+ }
90
+
91
+ function wireOutsideSelectClose(root: HTMLElement): void {
92
+ root.addEventListener('click', (event) => {
93
+ if ((event.target as Element).closest('[data-select]')) return;
94
+ root.querySelectorAll<HTMLElement>('[data-select-menu]').forEach((menu) => { menu.hidden = true; });
95
+ root.querySelectorAll<HTMLButtonElement>('[data-select-trigger]').forEach((trigger) => { trigger.setAttribute('aria-expanded', 'false'); });
96
+ });
97
+ }
98
+
99
+ function wireShare(root: HTMLElement, ui: ReferendumThresholdUI): void {
100
+ root.querySelector<HTMLButtonElement>('[data-share]')!.addEventListener('click', async () => {
101
+ const url = buildShareUrl(window.location.href, readInput(root));
102
+ window.history.replaceState({}, '', url);
103
+ try { if (!navigator.clipboard) throw new Error('clipboard-unavailable'); await navigator.clipboard.writeText(url); root.querySelector<HTMLElement>('[data-status]')!.textContent = ui.shareSuccess; } catch { root.querySelector<HTMLElement>('[data-status]')!.textContent = ui.shareFailure; }
104
+ });
105
+ }
106
+
107
+ function showEvaluation(root: HTMLElement, input: ReferendumInput, ui: ReferendumThresholdUI): void {
108
+ const evaluation = evaluateReferendum(input);
109
+ root.querySelector<HTMLElement>('[data-empty-result]')!.hidden = true;
110
+ root.querySelector<HTMLElement>('[data-result]')!.hidden = false;
111
+ renderDecisionScene(root.querySelector<HTMLElement>('[data-scene]')!, evaluation, ui);
112
+ renderDecision(root.querySelector<HTMLElement>('[data-result]')!, evaluation, ui);
113
+ root.querySelector<HTMLElement>('[data-status]')!.textContent = evaluation.errors.includes('ballots-exceed-eligible') ? ui.ballotsExceedEligible : '';
114
+ }
115
+
116
+ function refreshEvaluation(root: HTMLElement, ui: ReferendumThresholdUI): void {
117
+ const input = readInput(root);
118
+ saveInput(input);
119
+ showEvaluation(root, input, ui);
120
+ }
121
+
122
+ function wireForm(root: HTMLElement, ui: ReferendumThresholdUI): void {
123
+ const form = root.querySelector<HTMLFormElement>('[data-referendum-form]')!;
124
+ form.addEventListener('input', () => refreshEvaluation(root, ui));
125
+ form.addEventListener('change', () => refreshEvaluation(root, ui));
126
+ form.addEventListener('submit', (event) => { event.preventDefault(); refreshEvaluation(root, ui); });
127
+ root.querySelector<HTMLButtonElement>('[data-reset]')!.addEventListener('click', () => { writeInput(root, getExampleInput()); refreshEvaluation(root, ui); });
128
+ }
129
+
130
+ export function mountReferendumThresholdTool(root: HTMLElement, ui: ReferendumThresholdUI): void {
131
+ root.querySelectorAll<HTMLElement>('[data-select]').forEach((select) => wireSelect(root, select));
132
+ wireOutsideSelectClose(root);
133
+ wireModeButtons(root);
134
+ wireComparisonButtons(root);
135
+ wirePresets(root);
136
+ wireForm(root, ui);
137
+ wireShare(root, ui);
138
+ const initial = readShareUrl(window.location.href) ?? loadSavedInput() ?? getExampleInput();
139
+ writeInput(root, initial);
140
+ showEvaluation(root, initial, ui);
141
+ }
@@ -0,0 +1,71 @@
1
+ import type { ReferendumEvaluation, ThresholdRule } from './logic';
2
+ import { getStatusPresentation } from './evaluator';
3
+ import type { ReferendumThresholdUI } from './ui';
4
+
5
+ const numberFormatter = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });
6
+ const percentFormatter = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
7
+
8
+ function formatNumber(value: number): string {
9
+ return numberFormatter.format(value);
10
+ }
11
+
12
+ function formatPercent(value: number): string {
13
+ return `${percentFormatter.format(value)}%`;
14
+ }
15
+
16
+ function thresholdText(rule: ThresholdRule, ui: ReferendumThresholdUI): string {
17
+ const operator = rule.comparison === 'more-than' ? '> ' : '>= ';
18
+ if (rule.mode === 'absolute') return `${operator}${formatNumber(rule.value)} ${ui.peopleSuffix}`;
19
+ const denominator = denominatorText(rule.denominator, ui);
20
+ return `${operator}${formatNumber(rule.value)}% of ${denominator}`;
21
+ }
22
+
23
+ function denominatorText(mode: ThresholdRule['denominator'], ui: ReferendumThresholdUI): string {
24
+ if (mode === 'eligible-voters') return ui.eligibleVotersLabel;
25
+ if (mode === 'valid-votes') return ui.validVotesLabel;
26
+ return ui.allBallotsLabel;
27
+ }
28
+
29
+ function markerPosition(rule: ThresholdRule, denominator: number): number {
30
+ if (rule.mode === 'percentage') return rule.value;
31
+ if (denominator <= 0) return 100;
32
+ return (rule.value / denominator) * 100;
33
+ }
34
+
35
+ function statusTitle(evaluation: ReferendumEvaluation, ui: ReferendumThresholdUI): string {
36
+ if (!evaluation.valid) return ui.invalidResult;
37
+ if (evaluation.status === 'passed') return ui.passed;
38
+ if (evaluation.status === 'participation-failed') return ui.participationFailed;
39
+ if (evaluation.status === 'approval-failed') return ui.approvalFailed;
40
+ return ui.bothFailed;
41
+ }
42
+
43
+ function gateMarkup(label: string, rule: ThresholdRule, value: ReferendumEvaluation['participation'], ui: ReferendumThresholdUI): string {
44
+ const fill = Math.min(100, Math.max(0, value.rate));
45
+ const marker = markerPosition(rule, value.denominator);
46
+ const state = value.passed ? 'pass' : 'fail';
47
+ return `<div class="quorum-gate ${state}"><div class="gate-top"><span>${label}</span><strong>${formatPercent(value.rate)}</strong></div><div class="gate-track"><span style="width:${fill}%"></span><i style="left:${Math.min(100, Math.max(0, marker))}%"></i></div><div class="gate-bottom"><span>${formatNumber(value.numerator)} / ${formatNumber(value.denominator)}</span><span>required ${thresholdText(rule, ui)}</span></div></div>`;
48
+ }
49
+
50
+ export function renderDecisionScene(target: HTMLElement, evaluation: ReferendumEvaluation, ui: ReferendumThresholdUI): void {
51
+ const allBallotsRule: ThresholdRule = { mode: evaluation.participation.mode, value: evaluation.participation.threshold, denominator: evaluation.participation.denominatorMode, comparison: evaluation.participation.comparison };
52
+ const validVotesRule: ThresholdRule = { mode: evaluation.approval.mode, value: evaluation.approval.threshold, denominator: evaluation.approval.denominatorMode, comparison: evaluation.approval.comparison };
53
+ target.innerHTML = `<div class="ballot-path" aria-label="${ui.decisionPathLabel}"><div class="ballot-mark"><span class="ballot-check">O</span><span>${formatNumber(evaluation.totals.allBallots)} ${ui.allBallotsLabel}</span></div><div class="path-line"></div><div class="gate-stack">${gateMarkup(ui.participationMetric, allBallotsRule, evaluation.participation, ui)}${gateMarkup(ui.approvalMetric, validVotesRule, evaluation.approval, ui)}</div></div>`;
54
+ }
55
+
56
+ function ruleCard(title: string, evaluation: ReferendumEvaluation['participation'] | ReferendumEvaluation['approval'], rule: ThresholdRule, ui: ReferendumThresholdUI): string {
57
+ return `<article class="metric-card"><div class="metric-card-head"><span>${title}</span><b class="metric-state ${evaluation.passed ? 'pass' : 'fail'}">${evaluation.passed ? 'PASS' : 'FAIL'}</b></div><strong>${formatPercent(evaluation.rate)}</strong><dl><div><dt>${ui.observedLabel}</dt><dd>${formatNumber(evaluation.numerator)} / ${formatNumber(evaluation.denominator)}</dd></div><div><dt>${ui.thresholdLabel}</dt><dd>${thresholdText(rule, ui)}</dd></div></dl></article>`;
58
+ }
59
+
60
+ export function renderDecision(target: HTMLElement, evaluation: ReferendumEvaluation, ui: ReferendumThresholdUI): void {
61
+ const presentation = getStatusPresentation(evaluation);
62
+ const errors = evaluation.errors.length > 0 ? `<div class="inline-message danger" role="alert">${ui.invalidInput}</div>` : '';
63
+ const warnings = evaluation.warnings.map((warning) => warning === 'no-valid-votes' ? ui.noValidVotes : ui.noBallots).map((text) => `<li>${text}</li>`).join('');
64
+ const participationRule: ThresholdRule = { mode: evaluation.participation.mode, value: evaluation.participation.threshold, denominator: evaluation.participation.denominatorMode, comparison: evaluation.participation.comparison };
65
+ const approvalRule: ThresholdRule = { mode: evaluation.approval.mode, value: evaluation.approval.threshold, denominator: evaluation.approval.denominatorMode, comparison: evaluation.approval.comparison };
66
+ target.innerHTML = `<div class="decision-banner ${presentation.tone}"><span class="decision-seal">${evaluation.valid ? 'O' : '!'}</span><div><strong>${statusTitle(evaluation, ui)}</strong><p>${presentation.detail}</p></div></div>${errors}<div class="metric-grid">${ruleCard(ui.participationMetric, evaluation.participation, participationRule, ui)}${ruleCard(ui.approvalMetric, evaluation.approval, approvalRule, ui)}</div><div class="totals-strip"><span>${ui.yesShareLabel} <b>${formatNumber(evaluation.approval.numerator)}</b></span><span>${ui.allBallotsLabel} <b>${formatNumber(evaluation.totals.allBallots)}</b></span><span>${ui.validVotesLabel} <b>${formatNumber(evaluation.totals.validVotes)}</b></span><span>${ui.eligibleVotersLabel} <b>${formatNumber(evaluation.participation.denominator)}</b></span></div>${warnings ? `<div class="warning-list"><strong>${ui.warningsHeading}</strong><ul>${warnings}</ul></div>` : ''}<div class="next-step"><strong>${ui.whatNextLabel}</strong><p>${ui.whatNextText}</p></div>`;
67
+ }
68
+
69
+ export function formatInputValue(value: number): string {
70
+ return Number.isFinite(value) ? String(value) : '';
71
+ }
@@ -0,0 +1,27 @@
1
+ import type { CivicToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { ReferendumThresholdUI } from './ui';
3
+
4
+ export type ReferendumThresholdLocaleContent = ToolLocaleContent<ReferendumThresholdUI>;
5
+
6
+ export const referendumThresholdCalculator: CivicToolEntry<ReferendumThresholdUI> = {
7
+ id: 'referendum-threshold-calculator',
8
+ phase: 'localized',
9
+ icons: { bg: 'mdi:vote-outline', fg: 'mdi:scale-balance' },
10
+ i18n: {
11
+ de: () => import('./i18n/de').then((module) => module.content),
12
+ en: () => import('./i18n/en').then((module) => module.content),
13
+ es: () => import('./i18n/es').then((module) => module.content),
14
+ fr: () => import('./i18n/fr').then((module) => module.content),
15
+ id: () => import('./i18n/id').then((module) => module.content),
16
+ it: () => import('./i18n/it').then((module) => module.content),
17
+ ja: () => import('./i18n/ja').then((module) => module.content),
18
+ ko: () => import('./i18n/ko').then((module) => module.content),
19
+ nl: () => import('./i18n/nl').then((module) => module.content),
20
+ pl: () => import('./i18n/pl').then((module) => module.content),
21
+ pt: () => import('./i18n/pt').then((module) => module.content),
22
+ ru: () => import('./i18n/ru').then((module) => module.content),
23
+ sv: () => import('./i18n/sv').then((module) => module.content),
24
+ tr: () => import('./i18n/tr').then((module) => module.content),
25
+ zh: () => import('./i18n/zh').then((module) => module.content),
26
+ },
27
+ };
@@ -0,0 +1,19 @@
1
+ import type { ReferendumEvaluation, ReferendumStatus } from './logic';
2
+
3
+ export interface StatusPresentation {
4
+ tone: 'positive' | 'warning' | 'danger' | 'neutral';
5
+ title: string;
6
+ detail: string;
7
+ }
8
+
9
+ const statusDetails: Record<ReferendumStatus, StatusPresentation> = {
10
+ passed: { tone: 'positive', title: 'Both thresholds pass', detail: 'The declared rule accepts the result because participation and approval tests pass.' },
11
+ 'participation-failed': { tone: 'warning', title: 'Participation quorum fails', detail: 'The approval test may pass, but the declared participation threshold is not reached.' },
12
+ 'approval-failed': { tone: 'warning', title: 'Approval quorum fails', detail: 'Participation reaches its threshold, but the Yes count does not meet the declared approval rule.' },
13
+ 'both-failed': { tone: 'danger', title: 'Both thresholds fail', detail: 'Neither declared quorum is reached with the supplied counts.' },
14
+ invalid: { tone: 'danger', title: 'Check the declared counts', detail: 'The inputs contain a structural error, so no decision should be read from the result.' },
15
+ };
16
+
17
+ export function getStatusPresentation(evaluation: ReferendumEvaluation): StatusPresentation {
18
+ return statusDetails[evaluation.status];
19
+ }