@jjlmoya/utils-civic 1.2.0 → 1.4.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 (68) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +3 -1
  3. package/src/entries.ts +4 -0
  4. package/src/index.ts +1 -0
  5. package/src/tests/locale_completeness.test.ts +1 -1
  6. package/src/tests/tool_validation.test.ts +1 -1
  7. package/src/tool/coalition-majority-calculator/bibliography.astro +6 -0
  8. package/src/tool/coalition-majority-calculator/bibliography.ts +12 -0
  9. package/src/tool/coalition-majority-calculator/coalition-majority-calculator.css +467 -0
  10. package/src/tool/coalition-majority-calculator/component.astro +62 -0
  11. package/src/tool/coalition-majority-calculator/contract.test.ts +14 -0
  12. package/src/tool/coalition-majority-calculator/controller.ts +89 -0
  13. package/src/tool/coalition-majority-calculator/dom-views.ts +145 -0
  14. package/src/tool/coalition-majority-calculator/entry.ts +28 -0
  15. package/src/tool/coalition-majority-calculator/evaluator.ts +13 -0
  16. package/src/tool/coalition-majority-calculator/i18n/de.ts +51 -0
  17. package/src/tool/coalition-majority-calculator/i18n/en.ts +74 -0
  18. package/src/tool/coalition-majority-calculator/i18n/es.ts +51 -0
  19. package/src/tool/coalition-majority-calculator/i18n/fr.ts +12 -0
  20. package/src/tool/coalition-majority-calculator/i18n/id.ts +45 -0
  21. package/src/tool/coalition-majority-calculator/i18n/it.ts +12 -0
  22. package/src/tool/coalition-majority-calculator/i18n/ja.ts +21 -0
  23. package/src/tool/coalition-majority-calculator/i18n/ko.ts +21 -0
  24. package/src/tool/coalition-majority-calculator/i18n/nl.ts +12 -0
  25. package/src/tool/coalition-majority-calculator/i18n/pl.ts +12 -0
  26. package/src/tool/coalition-majority-calculator/i18n/pt.ts +12 -0
  27. package/src/tool/coalition-majority-calculator/i18n/ru.ts +12 -0
  28. package/src/tool/coalition-majority-calculator/i18n/sv.ts +12 -0
  29. package/src/tool/coalition-majority-calculator/i18n/tr.ts +12 -0
  30. package/src/tool/coalition-majority-calculator/i18n/zh.ts +12 -0
  31. package/src/tool/coalition-majority-calculator/index.ts +11 -0
  32. package/src/tool/coalition-majority-calculator/logic.test.ts +60 -0
  33. package/src/tool/coalition-majority-calculator/logic.ts +146 -0
  34. package/src/tool/coalition-majority-calculator/seo.astro +12 -0
  35. package/src/tool/coalition-majority-calculator/storage.ts +32 -0
  36. package/src/tool/coalition-majority-calculator/ui.ts +80 -0
  37. package/src/tool/referendum-threshold-calculator/bibliography.astro +6 -0
  38. package/src/tool/referendum-threshold-calculator/bibliography.ts +6 -0
  39. package/src/tool/referendum-threshold-calculator/component.astro +117 -0
  40. package/src/tool/referendum-threshold-calculator/controller.ts +141 -0
  41. package/src/tool/referendum-threshold-calculator/dom-views.ts +71 -0
  42. package/src/tool/referendum-threshold-calculator/entry.ts +27 -0
  43. package/src/tool/referendum-threshold-calculator/evaluator.ts +19 -0
  44. package/src/tool/referendum-threshold-calculator/i18n/de.ts +56 -0
  45. package/src/tool/referendum-threshold-calculator/i18n/en.ts +79 -0
  46. package/src/tool/referendum-threshold-calculator/i18n/es.ts +56 -0
  47. package/src/tool/referendum-threshold-calculator/i18n/fr.ts +56 -0
  48. package/src/tool/referendum-threshold-calculator/i18n/id.ts +56 -0
  49. package/src/tool/referendum-threshold-calculator/i18n/it.ts +56 -0
  50. package/src/tool/referendum-threshold-calculator/i18n/ja.ts +56 -0
  51. package/src/tool/referendum-threshold-calculator/i18n/ko.ts +56 -0
  52. package/src/tool/referendum-threshold-calculator/i18n/nl.ts +56 -0
  53. package/src/tool/referendum-threshold-calculator/i18n/pl.ts +56 -0
  54. package/src/tool/referendum-threshold-calculator/i18n/pt.ts +56 -0
  55. package/src/tool/referendum-threshold-calculator/i18n/ru.ts +56 -0
  56. package/src/tool/referendum-threshold-calculator/i18n/sv.ts +56 -0
  57. package/src/tool/referendum-threshold-calculator/i18n/tr.ts +56 -0
  58. package/src/tool/referendum-threshold-calculator/i18n/zh.ts +56 -0
  59. package/src/tool/referendum-threshold-calculator/index.ts +11 -0
  60. package/src/tool/referendum-threshold-calculator/logic.test.ts +69 -0
  61. package/src/tool/referendum-threshold-calculator/logic.ts +120 -0
  62. package/src/tool/referendum-threshold-calculator/referendum-threshold-calculator.css +640 -0
  63. package/src/tool/referendum-threshold-calculator/seo.astro +14 -0
  64. package/src/tool/referendum-threshold-calculator/sharing.test.ts +23 -0
  65. package/src/tool/referendum-threshold-calculator/sharing.ts +42 -0
  66. package/src/tool/referendum-threshold-calculator/storage.ts +39 -0
  67. package/src/tool/referendum-threshold-calculator/ui.ts +126 -0
  68. package/src/tools.ts +4 -0
@@ -0,0 +1,640 @@
1
+ :root {
2
+ --n-ink: #1f2933;
3
+ --n-muted: #667085;
4
+ --n-paper: #f6f1e7;
5
+ --n-card: #fffdf8;
6
+ --n-line: #d8cfc0;
7
+ --n-accent: #b54a35;
8
+ --n-accent-soft: #f1d8cf;
9
+ --n-teal: #166b68;
10
+ --n-teal-soft: #d4ebe5;
11
+ --n-warn: #965b19;
12
+ --n-danger: #9c3030;
13
+ }
14
+
15
+ .theme-dark {
16
+ --n-ink: #f5efe5;
17
+ --n-muted: #b6b0a6;
18
+ --n-paper: #17191a;
19
+ --n-card: #232526;
20
+ --n-line: #49433b;
21
+ --n-accent: #ed8065;
22
+ --n-accent-soft: #512e27;
23
+ --n-teal: #75c5bd;
24
+ --n-teal-soft: #1e4543;
25
+ --n-warn: #edb66c;
26
+ --n-danger: #f08787;
27
+ }
28
+
29
+ .referendum-tool {
30
+ --n-tool-bg: var(--n-paper);
31
+ --n-tool-card: var(--n-card);
32
+ --n-tool-ink: var(--n-ink);
33
+ --n-tool-muted: var(--n-muted);
34
+ --n-tool-line: var(--n-line);
35
+ --n-tool-accent: var(--n-accent);
36
+ --n-tool-accent-soft: var(--n-accent-soft);
37
+ --n-tool-teal: var(--n-teal);
38
+ --n-tool-teal-soft: var(--n-teal-soft);
39
+ --n-tool-warn: var(--n-warn);
40
+ --n-tool-danger: var(--n-danger);
41
+
42
+ color: var(--n-tool-ink);
43
+ background: var(--n-tool-bg);
44
+ border: 1px solid var(--n-tool-line);
45
+ border-radius: 24px;
46
+ padding: clamp(1rem, 3vw, 2rem);
47
+ }
48
+
49
+ .rule-workbench {
50
+ display: grid;
51
+ grid-template-columns: minmax(0, 1.02fr) minmax(0, 0.98fr);
52
+ gap: 1px;
53
+ background: var(--n-tool-line);
54
+ border: 1px solid var(--n-tool-line);
55
+ }
56
+
57
+ .rule-form,
58
+ .decision-panel {
59
+ min-width: 0;
60
+ background: var(--n-tool-card);
61
+ padding: clamp(1rem, 2.5vw, 1.75rem);
62
+ }
63
+
64
+ fieldset {
65
+ min-width: 0;
66
+ margin: 0;
67
+ border: 0;
68
+ padding: 0;
69
+ }
70
+
71
+ legend,
72
+ .panel-heading h2,
73
+ .guardrails h2 {
74
+ color: var(--n-tool-ink);
75
+ font-size: 1rem;
76
+ font-weight: 800;
77
+ letter-spacing: -0.02em;
78
+ }
79
+
80
+ legend {
81
+ padding: 0;
82
+ }
83
+
84
+ .count-block {
85
+ padding-bottom: 1.5rem;
86
+ border-bottom: 1px solid var(--n-tool-line);
87
+ }
88
+
89
+ label,
90
+ .field-label {
91
+ display: grid;
92
+ gap: 0.4rem;
93
+ color: var(--n-tool-muted);
94
+ font-size: 0.78rem;
95
+ font-weight: 700;
96
+ }
97
+
98
+ input,
99
+ [data-select-trigger] {
100
+ width: 100%;
101
+ min-height: 2.8rem;
102
+ border: 1px solid var(--n-tool-line);
103
+ border-radius: 10px;
104
+ padding: 0.65rem 0.75rem;
105
+ color: var(--n-tool-ink);
106
+ background: var(--n-tool-bg);
107
+ font: inherit;
108
+ }
109
+
110
+ input:focus,
111
+ button:focus-visible {
112
+ outline: 3px solid var(--n-tool-accent-soft);
113
+ outline-offset: 2px;
114
+ }
115
+
116
+ small,
117
+ .quorum-block p,
118
+ .guardrails p,
119
+ .decision-banner p,
120
+ .next-step p {
121
+ color: var(--n-tool-muted);
122
+ font-size: 0.78rem;
123
+ line-height: 1.5;
124
+ }
125
+
126
+ small {
127
+ font-weight: 500;
128
+ }
129
+
130
+ .wide-field {
131
+ margin-top: 1rem;
132
+ }
133
+
134
+ .count-grid,
135
+ .quorum-fields {
136
+ display: grid;
137
+ grid-template-columns: repeat(2, minmax(0, 1fr));
138
+ gap: 0.8rem;
139
+ margin-top: 0.8rem;
140
+ }
141
+
142
+ .quorum-block {
143
+ padding: 1.35rem 0;
144
+ border-bottom: 1px solid var(--n-tool-line);
145
+ }
146
+
147
+ .quorum-block p {
148
+ margin: 0.45rem 0 0.9rem;
149
+ }
150
+
151
+ .approval-block {
152
+ border-bottom: 0;
153
+ }
154
+
155
+ .mode-switch {
156
+ display: inline-grid;
157
+ grid-template-columns: 1fr 1fr;
158
+ border: 1px solid var(--n-tool-line);
159
+ border-radius: 10px;
160
+ overflow: hidden;
161
+ }
162
+
163
+ button {
164
+ border: 0;
165
+ cursor: pointer;
166
+ font: inherit;
167
+ }
168
+
169
+ .mode-switch button {
170
+ min-height: 2.35rem;
171
+ padding: 0 0.8rem;
172
+ color: var(--n-tool-muted);
173
+ background: var(--n-tool-bg);
174
+ font-size: 0.75rem;
175
+ font-weight: 800;
176
+ }
177
+
178
+ .mode-switch button + button {
179
+ border-left: 1px solid var(--n-tool-line);
180
+ }
181
+
182
+ .mode-switch button.active {
183
+ color: var(--n-tool-card);
184
+ background: var(--n-tool-teal);
185
+ }
186
+
187
+ .comparison-row,
188
+ .preset-row {
189
+ display: flex;
190
+ align-items: center;
191
+ gap: 0.35rem;
192
+ flex-wrap: wrap;
193
+ margin-top: 0.8rem;
194
+ }
195
+
196
+ .comparison-row > span,
197
+ .preset-row > span {
198
+ width: 100%;
199
+ color: var(--n-tool-muted);
200
+ font-size: 0.7rem;
201
+ font-weight: 800;
202
+ }
203
+
204
+ .comparison-row button,
205
+ .preset-row button,
206
+ .share-action {
207
+ min-height: 2.2rem;
208
+ border: 1px solid var(--n-tool-line);
209
+ border-radius: 8px;
210
+ padding: 0.45rem 0.65rem;
211
+ color: var(--n-tool-muted);
212
+ background: var(--n-tool-bg);
213
+ font-size: 0.7rem;
214
+ font-weight: 800;
215
+ }
216
+
217
+ .comparison-row button.active,
218
+ .preset-row button:hover,
219
+ .share-action:hover {
220
+ border-color: var(--n-tool-teal);
221
+ color: var(--n-tool-teal);
222
+ background: var(--n-tool-teal-soft);
223
+ }
224
+
225
+ .preset-row {
226
+ display: grid;
227
+ grid-template-columns: repeat(2, minmax(0, 1fr));
228
+ }
229
+
230
+ .preset-row > span {
231
+ grid-column: 1 / -1;
232
+ }
233
+
234
+ .custom-select {
235
+ position: relative;
236
+ }
237
+
238
+ [data-select-trigger] {
239
+ margin-top: 0.4rem;
240
+ cursor: pointer;
241
+ text-align: left;
242
+ font-size: 0.82rem;
243
+ font-weight: 700;
244
+ }
245
+
246
+ .select-menu {
247
+ position: absolute;
248
+ z-index: 3;
249
+ top: 100%;
250
+ right: 0;
251
+ left: 0;
252
+ display: grid;
253
+ gap: 2px;
254
+ margin-top: 0.35rem;
255
+ padding: 0.35rem;
256
+ border: 1px solid var(--n-tool-line);
257
+ border-radius: 10px;
258
+ background: var(--n-tool-card);
259
+ box-shadow: 0 10px 24px var(--n-tool-accent-soft);
260
+ }
261
+
262
+ .select-menu[hidden],
263
+ .result-content[hidden],
264
+ .empty-result[hidden] {
265
+ display: none;
266
+ }
267
+
268
+ .select-menu button {
269
+ padding: 0.65rem;
270
+ border-radius: 7px;
271
+ color: var(--n-tool-ink);
272
+ background: var(--n-tool-card);
273
+ text-align: left;
274
+ font-size: 0.78rem;
275
+ }
276
+
277
+ .select-menu button:hover {
278
+ background: var(--n-tool-accent-soft);
279
+ }
280
+
281
+ .form-actions {
282
+ display: flex;
283
+ gap: 0.7rem;
284
+ flex-wrap: wrap;
285
+ }
286
+
287
+ .primary-action,
288
+ .secondary-action {
289
+ min-height: 2.75rem;
290
+ border-radius: 10px;
291
+ padding: 0.7rem 1rem;
292
+ font-size: 0.8rem;
293
+ font-weight: 800;
294
+ }
295
+
296
+ .primary-action {
297
+ color: var(--n-tool-card);
298
+ background: var(--n-tool-accent);
299
+ }
300
+
301
+ .secondary-action {
302
+ color: var(--n-tool-accent);
303
+ background: var(--n-tool-accent-soft);
304
+ }
305
+
306
+ .share-action {
307
+ color: var(--n-tool-teal);
308
+ background: var(--n-tool-teal-soft);
309
+ }
310
+
311
+ .tool-status {
312
+ min-height: 1.2rem;
313
+ margin: 0.7rem 0 0;
314
+ color: var(--n-tool-danger);
315
+ font-size: 0.75rem;
316
+ font-weight: 700;
317
+ }
318
+
319
+ .decision-panel {
320
+ background: var(--n-tool-bg);
321
+ }
322
+
323
+ .panel-heading {
324
+ display: flex;
325
+ align-items: center;
326
+ gap: 0.65rem;
327
+ }
328
+
329
+ .panel-heading h2 {
330
+ margin: 0;
331
+ }
332
+
333
+ .panel-index,
334
+ .guardrail-mark {
335
+ color: var(--n-tool-accent);
336
+ font-size: 0.68rem;
337
+ font-weight: 900;
338
+ letter-spacing: 0.08em;
339
+ }
340
+
341
+ .decision-scene {
342
+ margin: 1.4rem 0;
343
+ padding: 1rem;
344
+ border: 1px solid var(--n-tool-line);
345
+ background: var(--n-tool-card);
346
+ }
347
+
348
+ .ballot-path {
349
+ display: grid;
350
+ gap: 0.8rem;
351
+ }
352
+
353
+ .ballot-mark {
354
+ display: flex;
355
+ align-items: center;
356
+ gap: 0.7rem;
357
+ color: var(--n-tool-teal);
358
+ font-size: 0.82rem;
359
+ font-weight: 800;
360
+ }
361
+
362
+ .ballot-check,
363
+ .decision-seal {
364
+ display: grid;
365
+ place-items: center;
366
+ width: 2rem;
367
+ height: 2rem;
368
+ border: 2px solid currentcolor;
369
+ border-radius: 50%;
370
+ font-size: 1rem;
371
+ }
372
+
373
+ .path-line {
374
+ width: 2px;
375
+ height: 1rem;
376
+ margin-left: 1rem;
377
+ background: var(--n-tool-accent);
378
+ }
379
+
380
+ .gate-stack {
381
+ display: grid;
382
+ gap: 0.8rem;
383
+ }
384
+
385
+ .quorum-gate {
386
+ padding: 0.7rem;
387
+ border-left: 3px solid var(--n-tool-warn);
388
+ background: var(--n-tool-accent-soft);
389
+ }
390
+
391
+ .quorum-gate.pass {
392
+ border-left-color: var(--n-tool-teal);
393
+ background: var(--n-tool-teal-soft);
394
+ }
395
+
396
+ .gate-top,
397
+ .gate-bottom,
398
+ .metric-card-head,
399
+ .totals-strip {
400
+ display: flex;
401
+ justify-content: space-between;
402
+ gap: 0.7rem;
403
+ }
404
+
405
+ .gate-top {
406
+ color: var(--n-tool-ink);
407
+ font-size: 0.77rem;
408
+ font-weight: 800;
409
+ }
410
+
411
+ .gate-track {
412
+ position: relative;
413
+ height: 0.5rem;
414
+ margin: 0.6rem 0 0.45rem;
415
+ background: var(--n-tool-card);
416
+ }
417
+
418
+ .gate-track span {
419
+ display: block;
420
+ height: 100%;
421
+ background: var(--n-tool-accent);
422
+ }
423
+
424
+ .quorum-gate.pass .gate-track span {
425
+ background: var(--n-tool-teal);
426
+ }
427
+
428
+ .gate-track i {
429
+ position: absolute;
430
+ top: -0.2rem;
431
+ width: 2px;
432
+ height: 0.9rem;
433
+ background: var(--n-tool-ink);
434
+ }
435
+
436
+ .gate-bottom {
437
+ color: var(--n-tool-muted);
438
+ font-size: 0.68rem;
439
+ }
440
+
441
+ .empty-result {
442
+ display: grid;
443
+ min-height: 8rem;
444
+ place-items: center;
445
+ border: 1px dashed var(--n-tool-line);
446
+ padding: 1rem;
447
+ color: var(--n-tool-muted);
448
+ text-align: center;
449
+ font-size: 0.8rem;
450
+ }
451
+
452
+ .decision-banner {
453
+ display: flex;
454
+ align-items: flex-start;
455
+ gap: 0.75rem;
456
+ padding: 0.9rem;
457
+ border-left: 3px solid var(--n-tool-warn);
458
+ background: var(--n-tool-accent-soft);
459
+ }
460
+
461
+ .decision-banner.positive {
462
+ border-left-color: var(--n-tool-teal);
463
+ background: var(--n-tool-teal-soft);
464
+ }
465
+
466
+ .decision-banner.danger {
467
+ border-left-color: var(--n-tool-danger);
468
+ }
469
+
470
+ .decision-seal {
471
+ flex: 0 0 auto;
472
+ color: var(--n-tool-accent);
473
+ }
474
+
475
+ .decision-banner strong {
476
+ font-size: 0.88rem;
477
+ }
478
+
479
+ .decision-banner p {
480
+ margin: 0.25rem 0 0;
481
+ }
482
+
483
+ .metric-grid {
484
+ display: grid;
485
+ grid-template-columns: repeat(2, minmax(0, 1fr));
486
+ gap: 0.65rem;
487
+ margin-top: 0.8rem;
488
+ }
489
+
490
+ .metric-card {
491
+ min-width: 0;
492
+ border: 1px solid var(--n-tool-line);
493
+ padding: 0.8rem;
494
+ background: var(--n-tool-card);
495
+ }
496
+
497
+ .metric-card-head {
498
+ align-items: flex-start;
499
+ color: var(--n-tool-muted);
500
+ font-size: 0.7rem;
501
+ font-weight: 800;
502
+ }
503
+
504
+ .metric-state {
505
+ font-size: 0.62rem;
506
+ }
507
+
508
+ .metric-state.pass {
509
+ color: var(--n-tool-teal);
510
+ }
511
+
512
+ .metric-state.fail {
513
+ color: var(--n-tool-danger);
514
+ }
515
+
516
+ .metric-card > strong {
517
+ display: block;
518
+ margin: 0.55rem 0;
519
+ color: var(--n-tool-ink);
520
+ font-size: clamp(1.4rem, 4vw, 2.2rem);
521
+ line-height: 1;
522
+ }
523
+
524
+ dl {
525
+ display: grid;
526
+ gap: 0.35rem;
527
+ margin: 0;
528
+ }
529
+
530
+ dl div {
531
+ display: flex;
532
+ justify-content: space-between;
533
+ gap: 0.5rem;
534
+ border-top: 1px solid var(--n-tool-line);
535
+ padding-top: 0.35rem;
536
+ }
537
+
538
+ dt,
539
+ dd {
540
+ color: var(--n-tool-muted);
541
+ font-size: 0.65rem;
542
+ }
543
+
544
+ dd {
545
+ margin: 0;
546
+ color: var(--n-tool-ink);
547
+ font-weight: 700;
548
+ text-align: right;
549
+ }
550
+
551
+ .totals-strip {
552
+ flex-wrap: wrap;
553
+ margin-top: 0.8rem;
554
+ border-top: 1px solid var(--n-tool-line);
555
+ border-bottom: 1px solid var(--n-tool-line);
556
+ padding: 0.65rem 0;
557
+ color: var(--n-tool-muted);
558
+ font-size: 0.65rem;
559
+ }
560
+
561
+ .totals-strip b {
562
+ color: var(--n-tool-ink);
563
+ }
564
+
565
+ .warning-list,
566
+ .next-step,
567
+ .inline-message {
568
+ margin-top: 0.8rem;
569
+ border: 1px solid var(--n-tool-line);
570
+ padding: 0.75rem;
571
+ color: var(--n-tool-muted);
572
+ font-size: 0.7rem;
573
+ line-height: 1.45;
574
+ }
575
+
576
+ .warning-list strong,
577
+ .next-step strong {
578
+ color: var(--n-tool-ink);
579
+ }
580
+
581
+ .warning-list ul {
582
+ margin: 0.4rem 0 0;
583
+ padding-left: 1rem;
584
+ }
585
+
586
+ .next-step {
587
+ background: var(--n-tool-teal-soft);
588
+ }
589
+
590
+ .next-step p {
591
+ margin: 0.3rem 0 0;
592
+ }
593
+
594
+ .inline-message.danger {
595
+ border-color: var(--n-tool-danger);
596
+ color: var(--n-tool-danger);
597
+ }
598
+
599
+ .guardrails {
600
+ display: grid;
601
+ grid-template-columns: repeat(3, minmax(0, 1fr));
602
+ gap: 1px;
603
+ margin-top: 1px;
604
+ background: var(--n-tool-line);
605
+ border: 1px solid var(--n-tool-line);
606
+ }
607
+
608
+ .guardrails article {
609
+ min-width: 0;
610
+ padding: 1rem;
611
+ background: var(--n-tool-card);
612
+ }
613
+
614
+ .guardrails h2 {
615
+ margin: 0.45rem 0 0;
616
+ font-size: 0.8rem;
617
+ }
618
+
619
+ .guardrails p {
620
+ margin: 0.5rem 0 0;
621
+ }
622
+
623
+ @media (max-width: 780px) {
624
+ .rule-workbench,
625
+ .guardrails {
626
+ grid-template-columns: 1fr;
627
+ }
628
+ }
629
+
630
+ @media (max-width: 460px) {
631
+ .count-grid,
632
+ .quorum-fields,
633
+ .metric-grid {
634
+ grid-template-columns: 1fr;
635
+ }
636
+
637
+ .form-actions > button {
638
+ flex: 1 1 100%;
639
+ }
640
+ }
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { referendumThresholdCalculator } from './entry';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props as Props;
11
+ const content = await referendumThresholdCalculator.i18n[locale]?.();
12
+ ---
13
+
14
+ {content && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildShareUrl, readShareUrl } from './sharing';
3
+ import { getExampleInput } from './logic';
4
+
5
+ describe('referendum sharing', () => {
6
+ it('round trips counts and both declared rules', () => {
7
+ const input = getExampleInput();
8
+ input.rules.approval = { mode: 'percentage', value: 55, denominator: 'eligible-voters', comparison: 'more-than' };
9
+ const url = buildShareUrl('https://gamebob.dev/en/referendum-threshold-calculator?view=review', input);
10
+ expect(readShareUrl(url)).toEqual(input);
11
+ });
12
+
13
+ it('preserves the page route and unrelated parameters', () => {
14
+ const url = buildShareUrl('https://gamebob.dev/en/referendum-threshold-calculator?view=review', getExampleInput());
15
+ expect(url).toContain('/en/referendum-threshold-calculator');
16
+ expect(url).toContain('view=review');
17
+ });
18
+
19
+ it('rejects malformed or incomplete state', () => {
20
+ expect(readShareUrl('https://gamebob.dev/en/referendum-threshold-calculator?state=nope')).toBeNull();
21
+ expect(readShareUrl('https://gamebob.dev/en/referendum-threshold-calculator?state=%7B%7D')).toBeNull();
22
+ });
23
+ });
@@ -0,0 +1,42 @@
1
+ import type { ReferendumInput, ThresholdComparison, ThresholdMode, DenominatorMode } from './logic';
2
+
3
+ const STATE_PARAMETER = 'state';
4
+
5
+ function isRecord(value: unknown): value is Record<string, unknown> {
6
+ return Boolean(value && typeof value === 'object');
7
+ }
8
+
9
+ function isCounts(value: unknown): boolean {
10
+ if (!isRecord(value)) return false;
11
+ return ['eligibleVoters', 'yes', 'no', 'blank', 'invalid'].every((key) => typeof value[key] === 'number');
12
+ }
13
+
14
+ function isRule(value: unknown): boolean {
15
+ if (!isRecord(value)) return false;
16
+ const mode = value.mode as ThresholdMode;
17
+ const denominator = value.denominator as DenominatorMode;
18
+ const comparison = value.comparison as ThresholdComparison | undefined;
19
+ return typeof value.value === 'number' && (mode === 'percentage' || mode === 'absolute') && ['all-ballots', 'valid-votes', 'eligible-voters'].includes(denominator) && (comparison === undefined || comparison === 'at-least' || comparison === 'more-than');
20
+ }
21
+
22
+ function isReferendumInput(value: unknown): value is ReferendumInput {
23
+ if (!isRecord(value) || !isCounts(value.counts) || !isRecord(value.rules)) return false;
24
+ return isRule(value.rules.participation) && isRule(value.rules.approval);
25
+ }
26
+
27
+ export function buildShareUrl(currentUrl: string, input: ReferendumInput): string {
28
+ const url = new URL(currentUrl);
29
+ url.searchParams.set(STATE_PARAMETER, JSON.stringify(input));
30
+ return url.toString();
31
+ }
32
+
33
+ export function readShareUrl(currentUrl: string): ReferendumInput | null {
34
+ try {
35
+ const encoded = new URL(currentUrl).searchParams.get(STATE_PARAMETER);
36
+ if (!encoded) return null;
37
+ const parsed: unknown = JSON.parse(encoded);
38
+ return isReferendumInput(parsed) ? parsed : null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }