@jjlmoya/utils-civic 1.1.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 (119) hide show
  1. package/.github/workflows/npm-publish.yml +40 -0
  2. package/.gitignore +6 -0
  3. package/.stylelintrc.json +98 -0
  4. package/astro.config.mjs +19 -0
  5. package/eslint.config.js +201 -0
  6. package/package.json +79 -0
  7. package/prompts/create_tool.md +98 -0
  8. package/prompts/i18n/de.md +16 -0
  9. package/prompts/i18n/en.md +16 -0
  10. package/prompts/i18n/es.md +16 -0
  11. package/prompts/i18n/fr.md +16 -0
  12. package/prompts/i18n/id.md +16 -0
  13. package/prompts/i18n/it.md +16 -0
  14. package/prompts/i18n/ja.md +16 -0
  15. package/prompts/i18n/ko.md +16 -0
  16. package/prompts/i18n/nl.md +16 -0
  17. package/prompts/i18n/pl.md +16 -0
  18. package/prompts/i18n/pt.md +16 -0
  19. package/prompts/i18n/ru.md +16 -0
  20. package/prompts/i18n/sv.md +16 -0
  21. package/prompts/i18n/tr.md +16 -0
  22. package/prompts/i18n/zh.md +16 -0
  23. package/prompts/seo.md +58 -0
  24. package/prompts/translations/french.md +33 -0
  25. package/scripts/postinstall.mjs +27 -0
  26. package/src/category/CivicCategorySEO.astro +9 -0
  27. package/src/category/i18n/de.ts +21 -0
  28. package/src/category/i18n/en.ts +25 -0
  29. package/src/category/i18n/es.ts +21 -0
  30. package/src/category/i18n/fr.ts +21 -0
  31. package/src/category/i18n/id.ts +21 -0
  32. package/src/category/i18n/it.ts +21 -0
  33. package/src/category/i18n/ja.ts +21 -0
  34. package/src/category/i18n/ko.ts +21 -0
  35. package/src/category/i18n/nl.ts +21 -0
  36. package/src/category/i18n/pl.ts +21 -0
  37. package/src/category/i18n/pt.ts +21 -0
  38. package/src/category/i18n/ru.ts +21 -0
  39. package/src/category/i18n/sv.ts +21 -0
  40. package/src/category/i18n/tr.ts +21 -0
  41. package/src/category/i18n/zh.ts +21 -0
  42. package/src/category/index.ts +24 -0
  43. package/src/components/PreviewNavSidebar.astro +116 -0
  44. package/src/components/PreviewToolbar.astro +143 -0
  45. package/src/data.ts +10 -0
  46. package/src/entries.ts +9 -0
  47. package/src/env.d.ts +5 -0
  48. package/src/index.ts +20 -0
  49. package/src/layouts/PreviewLayout.astro +117 -0
  50. package/src/pages/[locale]/[slug].astro +163 -0
  51. package/src/pages/[locale].astro +253 -0
  52. package/src/pages/index.astro +4 -0
  53. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  54. package/src/tests/category_seo_quality.test.ts +79 -0
  55. package/src/tests/diacritics_density.test.ts +118 -0
  56. package/src/tests/faq_count.test.ts +18 -0
  57. package/src/tests/i18n_coverage.test.ts +34 -0
  58. package/src/tests/inverted_punctuation.test.ts +84 -0
  59. package/src/tests/locale_completeness.test.ts +23 -0
  60. package/src/tests/mocks/astro_mock.js +2 -0
  61. package/src/tests/no_em_dash.test.ts +47 -0
  62. package/src/tests/no_en_dash.test.ts +70 -0
  63. package/src/tests/no_h1_in_components.test.ts +48 -0
  64. package/src/tests/pagespeed_best_practices.test.ts +198 -0
  65. package/src/tests/qa-test-helpers.ts +31 -0
  66. package/src/tests/qa_bibliography_links.test.ts +53 -0
  67. package/src/tests/qa_claim_evidence.test.ts +68 -0
  68. package/src/tests/qa_logic_reference_coverage.test.ts +45 -0
  69. package/src/tests/qa_runtime_i18n.test.ts +100 -0
  70. package/src/tests/registry_contract.test.ts +67 -0
  71. package/src/tests/schemas_fulfillment.test.ts +23 -0
  72. package/src/tests/script_density.test.ts +94 -0
  73. package/src/tests/seo_length.test.ts +23 -0
  74. package/src/tests/seo_parity.test.ts +60 -0
  75. package/src/tests/seo_translation_completeness.test.ts +75 -0
  76. package/src/tests/seo_wellformed_export.test.ts +65 -0
  77. package/src/tests/shared-test-helpers.ts +56 -0
  78. package/src/tests/slug_language_code_format.test.ts +23 -0
  79. package/src/tests/slug_uniqueness.test.ts +81 -0
  80. package/src/tests/spanish_leakage.test.ts +175 -0
  81. package/src/tests/title_quality.test.ts +55 -0
  82. package/src/tests/tool_exports.test.ts +34 -0
  83. package/src/tests/tool_validation.test.ts +16 -0
  84. package/src/tests/translation_copy.test.ts +123 -0
  85. package/src/tool/election-seat-apportionment-calculator/bibliography.astro +6 -0
  86. package/src/tool/election-seat-apportionment-calculator/bibliography.ts +16 -0
  87. package/src/tool/election-seat-apportionment-calculator/component.astro +69 -0
  88. package/src/tool/election-seat-apportionment-calculator/controller.ts +181 -0
  89. package/src/tool/election-seat-apportionment-calculator/dom-views.ts +75 -0
  90. package/src/tool/election-seat-apportionment-calculator/election-seat-apportionment-calculator.css +585 -0
  91. package/src/tool/election-seat-apportionment-calculator/entry.ts +28 -0
  92. package/src/tool/election-seat-apportionment-calculator/evaluator.ts +20 -0
  93. package/src/tool/election-seat-apportionment-calculator/i18n/de.ts +55 -0
  94. package/src/tool/election-seat-apportionment-calculator/i18n/en.ts +108 -0
  95. package/src/tool/election-seat-apportionment-calculator/i18n/es.ts +50 -0
  96. package/src/tool/election-seat-apportionment-calculator/i18n/fr.ts +48 -0
  97. package/src/tool/election-seat-apportionment-calculator/i18n/id.ts +48 -0
  98. package/src/tool/election-seat-apportionment-calculator/i18n/it.ts +48 -0
  99. package/src/tool/election-seat-apportionment-calculator/i18n/ja.ts +48 -0
  100. package/src/tool/election-seat-apportionment-calculator/i18n/ko.ts +48 -0
  101. package/src/tool/election-seat-apportionment-calculator/i18n/nl.ts +48 -0
  102. package/src/tool/election-seat-apportionment-calculator/i18n/pl.ts +48 -0
  103. package/src/tool/election-seat-apportionment-calculator/i18n/pt.ts +48 -0
  104. package/src/tool/election-seat-apportionment-calculator/i18n/ru.ts +48 -0
  105. package/src/tool/election-seat-apportionment-calculator/i18n/sv.ts +48 -0
  106. package/src/tool/election-seat-apportionment-calculator/i18n/tr.ts +48 -0
  107. package/src/tool/election-seat-apportionment-calculator/i18n/zh.ts +48 -0
  108. package/src/tool/election-seat-apportionment-calculator/index.ts +11 -0
  109. package/src/tool/election-seat-apportionment-calculator/logic.test.ts +90 -0
  110. package/src/tool/election-seat-apportionment-calculator/logic.ts +268 -0
  111. package/src/tool/election-seat-apportionment-calculator/seo.astro +14 -0
  112. package/src/tool/election-seat-apportionment-calculator/sharing.test.ts +34 -0
  113. package/src/tool/election-seat-apportionment-calculator/sharing.ts +88 -0
  114. package/src/tool/election-seat-apportionment-calculator/storage.ts +28 -0
  115. package/src/tool/election-seat-apportionment-calculator/ui.ts +92 -0
  116. package/src/tools.ts +7 -0
  117. package/src/types.ts +69 -0
  118. package/tsconfig.json +15 -0
  119. package/vitest.config.ts +20 -0
@@ -0,0 +1,75 @@
1
+ import type { ElectionSeatUI } from './ui';
2
+ import type { ApportionmentResult, DistrictAllocation, PartyAllocation, SeatEvent } from './logic';
3
+
4
+ const escapeHtml = (value: string): string => value
5
+ .replaceAll('&', '&')
6
+ .replaceAll('<', '&lt;')
7
+ .replaceAll('>', '&gt;')
8
+ .replaceAll('"', '&quot;')
9
+ .replaceAll("'", '&#039;');
10
+
11
+ const number = (value: number, maximumFractionDigits = 0): string => value.toLocaleString('en-US', { maximumFractionDigits });
12
+ const percent = (value: number): string => `${number(value, 2)}%`;
13
+
14
+ const partyColor = (index: number): string => `party-${(index % 8) + 1}`;
15
+
16
+ const renderLegend = (parties: PartyAllocation[]): string => parties.map((party, index) => `
17
+ <span class="chamber-legend-item"><i class="legend-swatch ${partyColor(index)}"></i><span>${escapeHtml(party.name)}</span><strong>${party.seats}</strong></span>
18
+ `).join('');
19
+
20
+ const renderChamber = (parties: PartyAllocation[], label: string): string => {
21
+ const seats = parties.flatMap((party, partyIndex) => Array.from({ length: party.seats }, (_, index) => ({ party, partyIndex, index })));
22
+ const columns = Math.max(1, Math.ceil(Math.sqrt(seats.length)));
23
+ const circles = seats.map((seat, index) => {
24
+ const x = 22 + (index % columns) * 22;
25
+ const y = 22 + Math.floor(index / columns) * 22;
26
+ return `<circle class="seat-dot ${partyColor(seat.partyIndex)}" cx="${x}" cy="${y}" r="7" aria-label="${escapeHtml(seat.party.name)} seat ${seat.index + 1}" />`;
27
+ }).join('');
28
+ const height = Math.max(70, Math.ceil(seats.length / columns) * 22 + 26);
29
+ return `<div class="chamber-wrap"><svg class="seat-chamber" viewBox="0 0 ${columns * 22 + 22} ${height}" role="img" aria-label="${escapeHtml(label)}">${circles}</svg><div class="chamber-legend">${renderLegend(parties)}</div></div>`;
30
+ };
31
+
32
+ const renderPartyRows = (parties: PartyAllocation[], ui: ElectionSeatUI): string => parties.map((party, index) => `
33
+ <tr class="${party.excluded ? 'is-excluded' : ''}">
34
+ <th scope="row"><span class="table-party"><i class="legend-swatch ${partyColor(index)}"></i>${escapeHtml(party.name)}</span></th>
35
+ <td>${number(party.votes)}</td>
36
+ <td>${percent(party.sharePercent)}</td>
37
+ <td class="seat-value">${party.seats} <small>${escapeHtml(ui.seatUnit)}</small></td>
38
+ <td>${percent(party.seatSharePercent)}</td>
39
+ <td>${party.excluded ? `<span class="status-pill">${escapeHtml(ui.excludedLabel)}</span>` : ''}</td>
40
+ </tr>
41
+ `).join('');
42
+
43
+ const renderEventRows = (events: SeatEvent[], ui: ElectionSeatUI): string => events.slice(0, 18).map((event, index) => `
44
+ <tr><td>${index + 1}</td><th scope="row">${escapeHtml(event.partyName)}</th><td>${number(event.quotient, 2)}</td><td>${event.remainder === undefined ? '-' : number(event.remainder, 2)}</td></tr>
45
+ `).join('') || `<tr><td colspan="4">${escapeHtml(ui.emptyState)}</td></tr>`;
46
+
47
+ const renderDistricts = (districts: DistrictAllocation[], ui: ElectionSeatUI): string => districts.length === 0 ? '' : `
48
+ <section class="district-results" aria-labelledby="district-results-title">
49
+ <div class="result-section-heading"><span class="eyebrow">${escapeHtml(ui.districtsLabel)}</span><h3 id="district-results-title">Local seat pattern</h3></div>
50
+ <p class="result-note">${escapeHtml(ui.districtAssumption)}</p>
51
+ <div class="district-grid">${districts.map((district) => `<article class="district-card"><div class="district-card-heading"><strong>${escapeHtml(district.label)}</strong><span>${district.seats} ${escapeHtml(ui.seatUnit)}</span></div>${renderChamber(district.parties, `${district.label} seat chamber`)}</article>`).join('')}</div>
52
+ </section>
53
+ `;
54
+
55
+ const renderSensitivityChange = (change: { thresholdPercent: number; seatsByParty: Record<string, number>; changedParties: string[] }, ui: ElectionSeatUI): string => {
56
+ const detail = change.changedParties.length > 0
57
+ ? `${escapeHtml(ui.changedLabel)}: ${change.changedParties.map((party) => `${escapeHtml(party)} ${change.seatsByParty[party] ?? 0}`).join(', ')}`
58
+ : escapeHtml(ui.noChangesLabel);
59
+ return `<div class="sensitivity-card"><span>${percent(change.thresholdPercent)}</span><p>${detail}</p></div>`;
60
+ };
61
+
62
+ const methodLabel = (method: ApportionmentResult['method'], ui: ElectionSeatUI): string => {
63
+ if (method === 'dhondt') return ui.dhondtLabel;
64
+ if (method === 'sainte-lague') return ui.sainteLagueLabel;
65
+ return ui.hareLabel;
66
+ };
67
+
68
+ export const renderResults = (result: ApportionmentResult, ui: ElectionSeatUI): string => `
69
+ <div class="result-topline"><div><span class="eyebrow">${escapeHtml(ui.allocatedSeatsLabel)}</span><strong>${result.totalSeats}</strong><span>${escapeHtml(ui.seatUnit)}</span></div><div><span class="eyebrow">${escapeHtml(ui.effectiveThresholdLabel)}</span><strong>${percent(result.effectiveThresholdPercent)}</strong></div></div>
70
+ <section class="chamber-section" aria-labelledby="chamber-title"><div class="result-section-heading"><span class="eyebrow">${escapeHtml(ui.chamberLabel)}</span><h3 id="chamber-title">${escapeHtml(methodLabel(result.method, ui))}</h3></div>${renderChamber(result.partyAllocations.filter((party) => !party.excluded), ui.chamberLabel)}</section>
71
+ <section class="allocation-section" aria-labelledby="allocation-title"><div class="result-section-heading"><span class="eyebrow">${escapeHtml(ui.partiesLabel)}</span><h3 id="allocation-title">${escapeHtml(ui.allocationTitle)}</h3></div><div class="table-scroll"><table><thead><tr><th scope="col">${escapeHtml(ui.partyNameLabel)}</th><th scope="col">${escapeHtml(ui.votesLabel)}</th><th scope="col">${escapeHtml(ui.voteShareLabel)}</th><th scope="col">${escapeHtml(ui.allocatedSeatsLabel)}</th><th scope="col">${escapeHtml(ui.seatShareLabel)}</th><th scope="col"></th></tr></thead><tbody>${renderPartyRows(result.partyAllocations, ui)}</tbody></table></div></section>
72
+ ${renderDistricts(result.districts, ui)}
73
+ <section class="evidence-section" aria-labelledby="evidence-title"><div class="result-section-heading"><span class="eyebrow">${escapeHtml(ui.eventLabel)}</span><h3 id="evidence-title">${escapeHtml(ui.eventsTitle)}</h3></div><div class="table-scroll"><table><thead><tr><th scope="col">#</th><th scope="col">${escapeHtml(ui.partyNameLabel)}</th><th scope="col">${escapeHtml(ui.quotientLabel)}</th><th scope="col">${escapeHtml(ui.remainderLabel)}</th></tr></thead><tbody>${renderEventRows(result.events, ui)}</tbody></table></div></section>
74
+ <section class="sensitivity-section" aria-labelledby="sensitivity-title"><div class="result-section-heading"><span class="eyebrow">${escapeHtml(ui.sensitivityLabel)}</span><h3 id="sensitivity-title">${escapeHtml(ui.sensitivityTitle)}</h3></div><div class="sensitivity-grid"><div><span class="sensitivity-label">${escapeHtml(ui.lowerThresholdLabel)}</span>${renderSensitivityChange(result.sensitivity.lower, ui)}</div><div><span class="sensitivity-label">${escapeHtml(ui.higherThresholdLabel)}</span>${renderSensitivityChange(result.sensitivity.higher, ui)}</div></div></section>
75
+ `;
@@ -0,0 +1,585 @@
1
+ :root {
2
+ --n-paper: #f5f0e6;
3
+ --n-card: #fffdf7;
4
+ --n-ink: #1f2730;
5
+ --n-muted: #68747d;
6
+ --n-line: #d9d4c9;
7
+ --n-blue: #1f5f83;
8
+ --n-blue-soft: #dbeaf0;
9
+ --n-red: #b24b3b;
10
+ --n-gold: #c28b38;
11
+ --n-green: #66856a;
12
+ --n-purple: #6e668d;
13
+ --n-shadow: #1f23;
14
+ }
15
+
16
+ .theme-dark {
17
+ --n-paper: #171b1e;
18
+ --n-card: #22282d;
19
+ --n-ink: #f4f0e7;
20
+ --n-muted: #b5bec1;
21
+ --n-line: #465057;
22
+ --n-blue: #7dc0dc;
23
+ --n-blue-soft: #294452;
24
+ --n-red: #e38b78;
25
+ --n-gold: #e0b15c;
26
+ --n-green: #9dc19c;
27
+ --n-purple: #b3a9d8;
28
+ --n-shadow: #0006;
29
+ }
30
+
31
+ .election-tool {
32
+ --n-bg: var(--n-paper);
33
+ --n-surface: var(--n-card);
34
+ --n-text: var(--n-ink);
35
+ --n-secondary: var(--n-muted);
36
+ --n-border: var(--n-line);
37
+ --n-accent: var(--n-blue);
38
+ --n-accent-soft: var(--n-blue-soft);
39
+ --n-error: var(--n-red);
40
+ --n-shadow-soft: var(--n-shadow);
41
+
42
+ background: var(--n-bg);
43
+ color: var(--n-text);
44
+ border: 1px solid var(--n-border);
45
+ border-radius: 1.25rem;
46
+ box-shadow: 0 1.5rem 3rem var(--n-shadow-soft);
47
+ overflow: hidden;
48
+ }
49
+
50
+ main,
51
+ .tool-page,
52
+ .section-tool {
53
+ min-width: 0;
54
+ }
55
+
56
+ .tool-layout {
57
+ display: grid;
58
+ grid-template-columns: minmax(18rem, 0.85fr) minmax(0, 1.5fr);
59
+ min-width: 0;
60
+ }
61
+
62
+ .control-surface,
63
+ .result-surface {
64
+ min-width: 0;
65
+ padding: clamp(1.25rem, 3vw, 2.4rem);
66
+ }
67
+
68
+ .control-surface {
69
+ border-right: 1px solid var(--n-border);
70
+ }
71
+
72
+ .result-surface {
73
+ background: var(--n-surface);
74
+ min-height: 42rem;
75
+ }
76
+
77
+ .method-fieldset,
78
+ .party-editor,
79
+ .district-control {
80
+ border: 0;
81
+ border-bottom: 1px solid var(--n-border);
82
+ margin: 0;
83
+ padding: 0 0 1.5rem;
84
+ }
85
+
86
+ .party-editor,
87
+ .district-control {
88
+ margin-top: 1.5rem;
89
+ }
90
+
91
+ .method-fieldset legend,
92
+ .control-heading h2,
93
+ .district-control h2 {
94
+ color: var(--n-text);
95
+ font-size: 0.82rem;
96
+ font-weight: 800;
97
+ letter-spacing: 0.02em;
98
+ margin: 0 0 0.8rem;
99
+ }
100
+
101
+ .method-option {
102
+ align-items: center;
103
+ border: 1px solid var(--n-border);
104
+ border-radius: 0.7rem;
105
+ cursor: pointer;
106
+ display: flex;
107
+ gap: 0.65rem;
108
+ margin-top: 0.55rem;
109
+ padding: 0.75rem;
110
+ }
111
+
112
+ .method-option:has(input:checked) {
113
+ background: var(--n-accent-soft);
114
+ border-color: var(--n-accent);
115
+ }
116
+
117
+ .method-option input {
118
+ accent-color: var(--n-accent);
119
+ flex: 0 0 auto;
120
+ height: 1.15rem;
121
+ min-height: 1.15rem;
122
+ padding: 0;
123
+ width: 1.15rem;
124
+ }
125
+
126
+ .method-option span,
127
+ label span,
128
+ .district-control p,
129
+ .result-intro p,
130
+ .tool-status,
131
+ .result-note,
132
+ .sensitivity-label {
133
+ color: var(--n-secondary);
134
+ font-size: 0.78rem;
135
+ line-height: 1.45;
136
+ }
137
+
138
+ .control-heading {
139
+ align-items: baseline;
140
+ display: flex;
141
+ gap: 0.55rem;
142
+ }
143
+
144
+ .control-heading h2 {
145
+ margin-bottom: 0;
146
+ }
147
+
148
+ .eyebrow {
149
+ color: var(--n-accent);
150
+ font-size: 0.63rem;
151
+ font-weight: 900;
152
+ letter-spacing: 0.16em;
153
+ text-transform: uppercase;
154
+ }
155
+
156
+ .party-list {
157
+ display: grid;
158
+ gap: 0.65rem;
159
+ }
160
+
161
+ .party-row {
162
+ align-items: end;
163
+ display: grid;
164
+ gap: 0.45rem;
165
+ grid-template-columns: 1.4rem minmax(5rem, 1fr) minmax(5rem, 0.7fr) 2rem;
166
+ }
167
+
168
+ .party-row-index {
169
+ color: var(--n-secondary);
170
+ font-size: 0.7rem;
171
+ padding-bottom: 0.7rem;
172
+ }
173
+
174
+ label {
175
+ display: grid;
176
+ gap: 0.35rem;
177
+ }
178
+
179
+ input {
180
+ background: var(--n-surface);
181
+ border: 1px solid var(--n-border);
182
+ border-radius: 0.5rem;
183
+ box-sizing: border-box;
184
+ color: var(--n-text);
185
+ font: inherit;
186
+ min-height: 2.45rem;
187
+ min-width: 0;
188
+ padding: 0.55rem 0.65rem;
189
+ width: 100%;
190
+ }
191
+
192
+ input:focus-visible,
193
+ button:focus-visible {
194
+ outline: 3px solid var(--n-gold);
195
+ outline-offset: 2px;
196
+ }
197
+
198
+ .icon-button,
199
+ .text-button,
200
+ .primary-button,
201
+ .secondary-button {
202
+ border: 0;
203
+ cursor: pointer;
204
+ font: inherit;
205
+ }
206
+
207
+ .icon-button {
208
+ background: transparent;
209
+ color: var(--n-secondary);
210
+ font-size: 1.2rem;
211
+ min-height: 2.45rem;
212
+ }
213
+
214
+ .text-button {
215
+ background: transparent;
216
+ color: var(--n-accent);
217
+ font-size: 0.78rem;
218
+ font-weight: 800;
219
+ margin-top: 0.85rem;
220
+ padding: 0;
221
+ }
222
+
223
+ .number-controls {
224
+ display: grid;
225
+ gap: 0.8rem;
226
+ grid-template-columns: 1fr 1fr;
227
+ margin-top: 1.5rem;
228
+ }
229
+
230
+ .district-control p {
231
+ margin: 0.65rem 0 0;
232
+ }
233
+
234
+ .action-row {
235
+ display: flex;
236
+ gap: 0.7rem;
237
+ margin-top: 1.5rem;
238
+ }
239
+
240
+ .primary-button,
241
+ .secondary-button {
242
+ border-radius: 0.55rem;
243
+ font-size: 0.8rem;
244
+ font-weight: 800;
245
+ padding: 0.75rem 1rem;
246
+ }
247
+
248
+ .primary-button {
249
+ background: var(--n-accent);
250
+ color: var(--n-card);
251
+ }
252
+
253
+ .secondary-button {
254
+ background: transparent;
255
+ border: 1px solid var(--n-border);
256
+ color: var(--n-text);
257
+ }
258
+
259
+ .tool-status {
260
+ min-height: 1.2rem;
261
+ margin: 0.85rem 0 0;
262
+ }
263
+
264
+ .tool-status.is-error {
265
+ color: var(--n-error);
266
+ }
267
+
268
+ .tool-status.is-positive {
269
+ color: var(--n-green);
270
+ }
271
+
272
+ .tool-status.is-caution {
273
+ color: var(--n-gold);
274
+ }
275
+
276
+ .result-intro {
277
+ align-items: baseline;
278
+ border-bottom: 1px solid var(--n-border);
279
+ display: flex;
280
+ gap: 0.75rem;
281
+ padding-bottom: 1rem;
282
+ }
283
+
284
+ .result-intro p {
285
+ margin: 0;
286
+ }
287
+
288
+ .empty-result {
289
+ color: var(--n-secondary);
290
+ font-size: clamp(1.4rem, 3vw, 2.4rem);
291
+ line-height: 1.05;
292
+ max-width: 16ch;
293
+ }
294
+
295
+ .result-content {
296
+ padding-top: 1.5rem;
297
+ }
298
+
299
+ .result-topline {
300
+ align-items: end;
301
+ border-bottom: 1px solid var(--n-border);
302
+ display: flex;
303
+ gap: 2rem;
304
+ justify-content: space-between;
305
+ padding-bottom: 1.5rem;
306
+ }
307
+
308
+ .result-topline div {
309
+ display: grid;
310
+ gap: 0.2rem;
311
+ }
312
+
313
+ .result-topline strong {
314
+ font-size: clamp(2.8rem, 8vw, 5.2rem);
315
+ letter-spacing: -0.08em;
316
+ line-height: 0.85;
317
+ }
318
+
319
+ .result-topline div:last-child strong {
320
+ color: var(--n-accent);
321
+ font-size: clamp(1.8rem, 4vw, 3rem);
322
+ letter-spacing: -0.05em;
323
+ }
324
+
325
+ .result-section-heading {
326
+ display: flex;
327
+ gap: 0.7rem;
328
+ justify-content: space-between;
329
+ }
330
+
331
+ .result-section-heading h3 {
332
+ font-size: 0.9rem;
333
+ margin: 0;
334
+ text-align: right;
335
+ }
336
+
337
+ .chamber-section,
338
+ .allocation-section,
339
+ .district-results,
340
+ .evidence-section,
341
+ .sensitivity-section {
342
+ border-bottom: 1px solid var(--n-border);
343
+ padding: 1.5rem 0;
344
+ }
345
+
346
+ .chamber-wrap {
347
+ margin-top: 1rem;
348
+ }
349
+
350
+ .seat-chamber {
351
+ display: block;
352
+ height: auto;
353
+ max-height: 20rem;
354
+ width: 100%;
355
+ }
356
+
357
+ .seat-dot {
358
+ stroke: var(--n-surface);
359
+ stroke-width: 2;
360
+ }
361
+
362
+ .party-1,
363
+ .legend-swatch.party-1 {
364
+ fill: var(--n-blue);
365
+ background: var(--n-blue);
366
+ }
367
+ .party-2,
368
+ .legend-swatch.party-2 {
369
+ fill: var(--n-red);
370
+ background: var(--n-red);
371
+ }
372
+ .party-3,
373
+ .legend-swatch.party-3 {
374
+ fill: var(--n-gold);
375
+ background: var(--n-gold);
376
+ }
377
+ .party-4,
378
+ .legend-swatch.party-4 {
379
+ fill: var(--n-green);
380
+ background: var(--n-green);
381
+ }
382
+ .party-5,
383
+ .legend-swatch.party-5 {
384
+ fill: var(--n-purple);
385
+ background: var(--n-purple);
386
+ }
387
+ .party-6,
388
+ .legend-swatch.party-6 {
389
+ fill: var(--n-blue-soft);
390
+ background: var(--n-blue-soft);
391
+ }
392
+ .party-7,
393
+ .legend-swatch.party-7 {
394
+ fill: var(--n-red);
395
+ background: var(--n-red);
396
+ }
397
+ .party-8,
398
+ .legend-swatch.party-8 {
399
+ fill: var(--n-gold);
400
+ background: var(--n-gold);
401
+ }
402
+
403
+ .chamber-legend {
404
+ display: flex;
405
+ flex-wrap: wrap;
406
+ gap: 0.5rem 0.8rem;
407
+ margin-top: 0.7rem;
408
+ }
409
+
410
+ .chamber-legend-item {
411
+ align-items: center;
412
+ display: inline-flex;
413
+ font-size: 0.7rem;
414
+ gap: 0.35rem;
415
+ }
416
+
417
+ .legend-swatch {
418
+ border-radius: 50%;
419
+ display: inline-block;
420
+ height: 0.65rem;
421
+ width: 0.65rem;
422
+ }
423
+
424
+ .chamber-legend-item strong {
425
+ color: var(--n-accent);
426
+ }
427
+
428
+ .table-scroll {
429
+ max-width: 100%;
430
+ overflow-x: auto;
431
+ margin-top: 1rem;
432
+ }
433
+
434
+ table {
435
+ border-collapse: collapse;
436
+ font-size: 0.74rem;
437
+ min-width: 38rem;
438
+ width: 100%;
439
+ }
440
+
441
+ th,
442
+ td {
443
+ border-bottom: 1px solid var(--n-border);
444
+ padding: 0.65rem 0.45rem;
445
+ text-align: right;
446
+ }
447
+
448
+ th:first-child,
449
+ td:first-child {
450
+ text-align: left;
451
+ }
452
+
453
+ thead th {
454
+ color: var(--n-secondary);
455
+ font-size: 0.64rem;
456
+ font-weight: 800;
457
+ letter-spacing: 0.05em;
458
+ text-transform: uppercase;
459
+ }
460
+
461
+ .table-party {
462
+ align-items: center;
463
+ display: inline-flex;
464
+ gap: 0.4rem;
465
+ }
466
+
467
+ .seat-value {
468
+ color: var(--n-accent);
469
+ font-size: 1rem;
470
+ font-weight: 900;
471
+ }
472
+
473
+ .seat-value small {
474
+ color: var(--n-secondary);
475
+ font-size: 0.6rem;
476
+ font-weight: 500;
477
+ }
478
+
479
+ .is-excluded {
480
+ opacity: 0.55;
481
+ }
482
+
483
+ .status-pill {
484
+ color: var(--n-error);
485
+ font-size: 0.6rem;
486
+ font-weight: 800;
487
+ white-space: nowrap;
488
+ }
489
+
490
+ .result-note {
491
+ margin: 0.8rem 0 0;
492
+ }
493
+
494
+ .district-grid,
495
+ .sensitivity-grid {
496
+ display: grid;
497
+ gap: 0.8rem;
498
+ grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
499
+ margin-top: 1rem;
500
+ }
501
+
502
+ .district-card,
503
+ .sensitivity-card {
504
+ background: var(--n-bg);
505
+ border: 1px solid var(--n-border);
506
+ border-radius: 0.7rem;
507
+ padding: 0.8rem;
508
+ }
509
+
510
+ .district-card-heading {
511
+ display: flex;
512
+ font-size: 0.72rem;
513
+ justify-content: space-between;
514
+ }
515
+
516
+ .district-card .chamber-wrap {
517
+ margin-top: 0.5rem;
518
+ }
519
+
520
+ .district-card .seat-chamber {
521
+ max-height: 8rem;
522
+ }
523
+
524
+ .sensitivity-label {
525
+ display: block;
526
+ font-size: 0.68rem;
527
+ margin-bottom: 0.45rem;
528
+ }
529
+
530
+ .sensitivity-card {
531
+ display: flex;
532
+ gap: 0.6rem;
533
+ }
534
+
535
+ .sensitivity-card > span {
536
+ color: var(--n-accent);
537
+ font-size: 1rem;
538
+ font-weight: 900;
539
+ white-space: nowrap;
540
+ }
541
+
542
+ .sensitivity-card p {
543
+ font-size: 0.72rem;
544
+ margin: 0;
545
+ }
546
+
547
+ @media (max-width: 1200px) {
548
+ .tool-layout {
549
+ grid-template-columns: 1fr;
550
+ }
551
+
552
+ .control-surface {
553
+ border-bottom: 1px solid var(--n-border);
554
+ border-right: 0;
555
+ }
556
+
557
+ .result-surface {
558
+ min-height: 0;
559
+ }
560
+ }
561
+
562
+ @media (max-width: 460px) {
563
+ .party-row {
564
+ grid-template-columns: 1.2rem minmax(4.4rem, 1fr) minmax(6.2rem, 0.9fr) 1.2rem;
565
+ }
566
+
567
+ .number-controls {
568
+ grid-template-columns: 1fr;
569
+ }
570
+
571
+ .result-topline {
572
+ align-items: start;
573
+ flex-direction: column;
574
+ gap: 1rem;
575
+ }
576
+
577
+ .result-section-heading {
578
+ align-items: start;
579
+ flex-direction: column;
580
+ }
581
+
582
+ .result-section-heading h3 {
583
+ text-align: left;
584
+ }
585
+ }
@@ -0,0 +1,28 @@
1
+ import type { CivicToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { ElectionSeatUI } from './ui';
3
+
4
+ export type { ElectionSeatUI } from './ui';
5
+
6
+ export type ElectionSeatLocaleContent = ToolLocaleContent<ElectionSeatUI>;
7
+
8
+ export const electionSeatApportionmentCalculator: CivicToolEntry<ElectionSeatUI> = {
9
+ id: 'election-seat-apportionment-calculator',
10
+ icons: { bg: 'mdi:bank', fg: 'mdi:scale-balance' },
11
+ i18n: {
12
+ en: () => import('./i18n/en').then((module) => module.content),
13
+ de: () => import('./i18n/de').then((module) => module.content),
14
+ es: () => import('./i18n/es').then((module) => module.content),
15
+ fr: () => import('./i18n/fr').then((module) => module.content),
16
+ id: () => import('./i18n/id').then((module) => module.content),
17
+ it: () => import('./i18n/it').then((module) => module.content),
18
+ ja: () => import('./i18n/ja').then((module) => module.content),
19
+ ko: () => import('./i18n/ko').then((module) => module.content),
20
+ nl: () => import('./i18n/nl').then((module) => module.content),
21
+ pl: () => import('./i18n/pl').then((module) => module.content),
22
+ pt: () => import('./i18n/pt').then((module) => module.content),
23
+ ru: () => import('./i18n/ru').then((module) => module.content),
24
+ sv: () => import('./i18n/sv').then((module) => module.content),
25
+ tr: () => import('./i18n/tr').then((module) => module.content),
26
+ zh: () => import('./i18n/zh').then((module) => module.content),
27
+ },
28
+ };
@@ -0,0 +1,20 @@
1
+ import type { ApportionmentResult } from './logic';
2
+ import type { ElectionSeatUI } from './ui';
3
+
4
+ export interface ElectionStatus {
5
+ tone: 'neutral' | 'caution' | 'positive';
6
+ text: string;
7
+ }
8
+
9
+ export const evaluateElection = (result: ApportionmentResult, ui: ElectionSeatUI): ElectionStatus => {
10
+ if (result.excludedVotes > 0) {
11
+ return {
12
+ tone: 'caution',
13
+ text: `${ui.excludedVotesLabel}: ${result.excludedVotes.toLocaleString()} votes sit below the eligibility threshold and do not enter the allocation.`,
14
+ };
15
+ }
16
+ return {
17
+ tone: 'positive',
18
+ text: `${ui.eligibleVotesLabel}: Every entered vote is eligible for this model.`,
19
+ };
20
+ };