@jjlmoya/utils-civic 1.5.0 → 1.7.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 (67) 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/participatory-budget-allocator/bibliography.astro +6 -0
  8. package/src/tool/participatory-budget-allocator/bibliography.ts +8 -0
  9. package/src/tool/participatory-budget-allocator/component.astro +61 -0
  10. package/src/tool/participatory-budget-allocator/contract.test.ts +13 -0
  11. package/src/tool/participatory-budget-allocator/controller.ts +108 -0
  12. package/src/tool/participatory-budget-allocator/dom-views.test.ts +14 -0
  13. package/src/tool/participatory-budget-allocator/dom-views.ts +58 -0
  14. package/src/tool/participatory-budget-allocator/entry.ts +27 -0
  15. package/src/tool/participatory-budget-allocator/evaluator.ts +24 -0
  16. package/src/tool/participatory-budget-allocator/i18n/de.ts +44 -0
  17. package/src/tool/participatory-budget-allocator/i18n/en.ts +79 -0
  18. package/src/tool/participatory-budget-allocator/i18n/es.ts +43 -0
  19. package/src/tool/participatory-budget-allocator/i18n/fr.ts +43 -0
  20. package/src/tool/participatory-budget-allocator/i18n/id.ts +43 -0
  21. package/src/tool/participatory-budget-allocator/i18n/it.ts +43 -0
  22. package/src/tool/participatory-budget-allocator/i18n/ja.ts +43 -0
  23. package/src/tool/participatory-budget-allocator/i18n/ko.ts +43 -0
  24. package/src/tool/participatory-budget-allocator/i18n/nl.ts +43 -0
  25. package/src/tool/participatory-budget-allocator/i18n/pl.ts +43 -0
  26. package/src/tool/participatory-budget-allocator/i18n/pt.ts +43 -0
  27. package/src/tool/participatory-budget-allocator/i18n/ru.ts +43 -0
  28. package/src/tool/participatory-budget-allocator/i18n/sv.ts +43 -0
  29. package/src/tool/participatory-budget-allocator/i18n/tr.ts +43 -0
  30. package/src/tool/participatory-budget-allocator/i18n/zh.ts +43 -0
  31. package/src/tool/participatory-budget-allocator/index.ts +11 -0
  32. package/src/tool/participatory-budget-allocator/logic.test.ts +48 -0
  33. package/src/tool/participatory-budget-allocator/logic.ts +146 -0
  34. package/src/tool/participatory-budget-allocator/participatory-budget-allocator.css +426 -0
  35. package/src/tool/participatory-budget-allocator/seo.astro +14 -0
  36. package/src/tool/participatory-budget-allocator/storage.ts +32 -0
  37. package/src/tool/participatory-budget-allocator/ui.ts +88 -0
  38. package/src/tool/public-budget-analyzer/bibliography.astro +15 -0
  39. package/src/tool/public-budget-analyzer/bibliography.ts +7 -0
  40. package/src/tool/public-budget-analyzer/component.astro +52 -0
  41. package/src/tool/public-budget-analyzer/controller.ts +84 -0
  42. package/src/tool/public-budget-analyzer/dom-views.ts +52 -0
  43. package/src/tool/public-budget-analyzer/entry.ts +27 -0
  44. package/src/tool/public-budget-analyzer/evaluator.ts +15 -0
  45. package/src/tool/public-budget-analyzer/i18n/de.ts +115 -0
  46. package/src/tool/public-budget-analyzer/i18n/en.ts +78 -0
  47. package/src/tool/public-budget-analyzer/i18n/es.ts +115 -0
  48. package/src/tool/public-budget-analyzer/i18n/fr.ts +115 -0
  49. package/src/tool/public-budget-analyzer/i18n/id.ts +115 -0
  50. package/src/tool/public-budget-analyzer/i18n/it.ts +115 -0
  51. package/src/tool/public-budget-analyzer/i18n/ja.ts +115 -0
  52. package/src/tool/public-budget-analyzer/i18n/ko.ts +115 -0
  53. package/src/tool/public-budget-analyzer/i18n/nl.ts +115 -0
  54. package/src/tool/public-budget-analyzer/i18n/pl.ts +115 -0
  55. package/src/tool/public-budget-analyzer/i18n/pt.ts +115 -0
  56. package/src/tool/public-budget-analyzer/i18n/ru.ts +115 -0
  57. package/src/tool/public-budget-analyzer/i18n/sv.ts +115 -0
  58. package/src/tool/public-budget-analyzer/i18n/tr.ts +115 -0
  59. package/src/tool/public-budget-analyzer/i18n/zh.ts +115 -0
  60. package/src/tool/public-budget-analyzer/index.ts +11 -0
  61. package/src/tool/public-budget-analyzer/logic.test.ts +81 -0
  62. package/src/tool/public-budget-analyzer/logic.ts +249 -0
  63. package/src/tool/public-budget-analyzer/public-budget-analyzer.css +461 -0
  64. package/src/tool/public-budget-analyzer/seo.astro +15 -0
  65. package/src/tool/public-budget-analyzer/storage.ts +28 -0
  66. package/src/tool/public-budget-analyzer/ui.ts +78 -0
  67. package/src/tools.ts +4 -0
@@ -0,0 +1,249 @@
1
+ export interface RawBudgetRecord {
2
+ [key: string]: unknown;
3
+ }
4
+
5
+ export interface ParsedBudgetText {
6
+ format: 'csv' | 'json';
7
+ records: RawBudgetRecord[];
8
+ errors: string[];
9
+ }
10
+
11
+ export interface NormalizedBudgetRow {
12
+ category: string;
13
+ path: string[];
14
+ amount: number;
15
+ population?: number | undefined;
16
+ priorAmount?: number | undefined;
17
+ period?: string | undefined;
18
+ currency?: string | undefined;
19
+ sourceRow: number;
20
+ }
21
+
22
+ export interface BudgetCategorySummary {
23
+ category: string;
24
+ path: string[];
25
+ amount: number;
26
+ share?: number | undefined;
27
+ priorAmount?: number | undefined;
28
+ variation?: number | undefined;
29
+ sourceRows: number[];
30
+ }
31
+
32
+ export interface BudgetAnalysis {
33
+ rows: NormalizedBudgetRow[];
34
+ categories: BudgetCategorySummary[];
35
+ total: number;
36
+ positiveTotal: number;
37
+ previousTotal?: number | undefined;
38
+ population?: number | undefined;
39
+ perCapita?: number | undefined;
40
+ variation?: number | undefined;
41
+ currency?: string | undefined;
42
+ period?: string | undefined;
43
+ warnings: string[];
44
+ errors: string[];
45
+ }
46
+
47
+ const aliases = {
48
+ category: ['category', 'categoryname', 'name', 'label', 'programme', 'program', 'function'],
49
+ amount: ['amount', 'spending', 'expenditure', 'expense', 'value', 'total'],
50
+ population: ['population', 'inhabitants', 'residents'],
51
+ priorAmount: ['prioramount', 'previousamount', 'prior', 'previous', 'lastperiod'],
52
+ period: ['period', 'year', 'fiscalyear', 'date'],
53
+ currency: ['currency', 'currencycode', 'unit'],
54
+ parent: ['parentcategory', 'parent', 'group'],
55
+ };
56
+
57
+ function normalizeKey(value: string): string {
58
+ return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]/g, '');
59
+ }
60
+
61
+ function findKey(record: RawBudgetRecord, names: string[]): string | undefined {
62
+ const keys = Object.keys(record);
63
+ const normalized = new Map(keys.map((key) => [normalizeKey(key), key]));
64
+ return names.map(normalizeKey).map((name) => normalized.get(name)).find(Boolean);
65
+ }
66
+
67
+ function readValue(record: RawBudgetRecord, names: string[]): unknown {
68
+ const key = findKey(record, names);
69
+ return key ? record[key] : undefined;
70
+ }
71
+
72
+ function parseNumber(value: unknown): number | undefined {
73
+ if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
74
+ if (typeof value !== 'string' || !value.trim()) return undefined;
75
+ const compact = value.trim().replace(/\s/g, '').replace(/[^\d,.-]/g, '');
76
+ if (!compact) return undefined;
77
+ const comma = compact.lastIndexOf(',');
78
+ const dot = compact.lastIndexOf('.');
79
+ const normalized = comma > dot ? compact.replace(/\./g, '').replace(',', '.') : compact.replace(/,/g, '');
80
+ const result = Number(normalized);
81
+ return Number.isFinite(result) ? result : undefined;
82
+ }
83
+
84
+ function splitPath(value: string): string[] {
85
+ return value.split(/\s*(?:>|\/|\\|:)\s*/).map((part) => part.trim()).filter(Boolean);
86
+ }
87
+
88
+ function validateRow(record: RawBudgetRecord, category: string, amount: number | undefined, sourceRow: number): string | undefined {
89
+ if (!category) return `Row ${sourceRow}: category is empty.`;
90
+ if (amount === undefined) return `Row ${sourceRow}: amount is missing or not numeric.`;
91
+ if (hasInvalidOptional(record, aliases.population)) return `Row ${sourceRow}: population is not numeric.`;
92
+ if (hasInvalidOptional(record, aliases.priorAmount)) return `Row ${sourceRow}: priorAmount is not numeric.`;
93
+ return undefined;
94
+ }
95
+
96
+ function normalizeRow(record: RawBudgetRecord, sourceRow: number): { row?: NormalizedBudgetRow; error?: string } {
97
+ const categoryValue = readValue(record, aliases.category);
98
+ const amountValue = readValue(record, aliases.amount);
99
+ const category = typeof categoryValue === 'string' ? categoryValue.trim() : String(categoryValue ?? '').trim();
100
+ const amount = parseNumber(amountValue);
101
+ const error = validateRow(record, category, amount, sourceRow);
102
+ if (error) return { error };
103
+ if (amount === undefined) return { error: `Row ${sourceRow}: amount is missing or not numeric.` };
104
+ const parentValue = readValue(record, aliases.parent);
105
+ const parent = typeof parentValue === 'string' ? splitPath(parentValue) : [];
106
+ const path = [...parent, ...splitPath(category)];
107
+ const population = parseOptional(record, aliases.population);
108
+ const priorAmount = parseOptional(record, aliases.priorAmount);
109
+ const period = stringValue(readValue(record, aliases.period));
110
+ const currency = stringValue(readValue(record, aliases.currency));
111
+ return { row: { category: path[path.length - 1] || category, path, amount, population, priorAmount, period, currency, sourceRow } };
112
+ }
113
+
114
+ function parseOptional(record: RawBudgetRecord, names: string[]): number | undefined {
115
+ const value = readValue(record, names);
116
+ return value === undefined || value === null || value === '' ? undefined : parseNumber(value);
117
+ }
118
+
119
+ function hasInvalidOptional(record: RawBudgetRecord, names: string[]): boolean {
120
+ const value = readValue(record, names);
121
+ return value !== undefined && value !== null && value !== '' && parseNumber(value) === undefined;
122
+ }
123
+
124
+ function stringValue(value: unknown): string | undefined {
125
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
126
+ }
127
+
128
+ function parseCsvLine(line: string): string[] {
129
+ const row: string[] = [];
130
+ let cell = '';
131
+ let quoted = false;
132
+ for (let index = 0; index < line.length; index += 1) {
133
+ const char = line[index];
134
+ const next = line[index + 1];
135
+ if (char === '"' && quoted && next === '"') { cell += '"'; index += 1; continue; }
136
+ if (char === '"') { quoted = !quoted; continue; }
137
+ if (char === ',' && !quoted) { row.push(cell.trim()); cell = ''; continue; }
138
+ cell += char;
139
+ }
140
+ row.push(cell.trim());
141
+ return row;
142
+ }
143
+
144
+ function parseCsvRows(text: string): string[][] {
145
+ return text.split(/\r?\n/).map(parseCsvLine).filter((row) => row.some(Boolean));
146
+ }
147
+
148
+ function hasUnclosedQuotes(line: string): boolean {
149
+ return ((line.match(/"/g) || []).length % 2) === 1;
150
+ }
151
+
152
+ function recordsFromCsv(text: string): ParsedBudgetText {
153
+ if (text.split(/\r?\n/).some(hasUnclosedQuotes)) return { format: 'csv', records: [], errors: ['CSV contains an unclosed quoted field.'] };
154
+ const rows = parseCsvRows(text);
155
+ if (rows.length < 2) return { format: 'csv', records: [], errors: ['CSV needs a header row and at least one data row.'] };
156
+ const headers = rows[0] || [];
157
+ const records = rows.slice(1).map((values) => Object.fromEntries(headers.map((header, index) => [header, values[index] ?? ''])));
158
+ return { format: 'csv', records, errors: [] };
159
+ }
160
+
161
+ function recordsFromJson(text: string): ParsedBudgetText {
162
+ try {
163
+ const parsed: unknown = JSON.parse(text);
164
+ const data = Array.isArray(parsed) ? parsed : objectValue(parsed)?.data;
165
+ if (!Array.isArray(data) || !data.every(isRecord)) return { format: 'json', records: [], errors: ['JSON must be an array of objects or an object with a data array.'] };
166
+ return { format: 'json', records: data, errors: [] };
167
+ } catch {
168
+ return { format: 'json', records: [], errors: ['JSON could not be parsed.'] };
169
+ }
170
+ }
171
+
172
+ function objectValue(value: unknown): RawBudgetRecord | undefined {
173
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as RawBudgetRecord : undefined;
174
+ }
175
+
176
+ function isRecord(value: unknown): value is RawBudgetRecord {
177
+ return Boolean(objectValue(value));
178
+ }
179
+
180
+ export function parseBudgetText(text: string, format?: 'csv' | 'json'): ParsedBudgetText {
181
+ const clean = text.trim();
182
+ if (!clean) return { format: format ?? 'csv', records: [], errors: ['The file is empty.'] };
183
+ const inferred = format ?? (clean.startsWith('{') || clean.startsWith('[') ? 'json' : 'csv');
184
+ return inferred === 'json' ? recordsFromJson(clean) : recordsFromCsv(clean);
185
+ }
186
+
187
+ function uniqueNumbers(rows: NormalizedBudgetRow[], property: 'population' | 'priorAmount'): number[] {
188
+ return [...new Set(rows.map((row) => row[property]).filter((value): value is number => value !== undefined))];
189
+ }
190
+
191
+ function groupCategories(rows: NormalizedBudgetRow[], total: number): BudgetCategorySummary[] {
192
+ const grouped = new Map<string, BudgetCategorySummary>();
193
+ rows.forEach((row) => {
194
+ const key = row.path.join(' > ');
195
+ const current = grouped.get(key) ?? { category: row.category, path: row.path, amount: 0, sourceRows: [] };
196
+ current.amount += row.amount;
197
+ if (row.priorAmount !== undefined) current.priorAmount = (current.priorAmount ?? 0) + row.priorAmount;
198
+ current.sourceRows.push(row.sourceRow);
199
+ grouped.set(key, current);
200
+ });
201
+ return [...grouped.values()].map((category) => ({
202
+ ...category,
203
+ share: total > 0 ? category.amount / total : undefined,
204
+ variation: category.priorAmount !== undefined && category.priorAmount !== 0 ? (category.amount - category.priorAmount) / Math.abs(category.priorAmount) : undefined,
205
+ })).sort((left, right) => right.amount - left.amount);
206
+ }
207
+
208
+ function collectWarnings(rows: NormalizedBudgetRow[], categories: BudgetCategorySummary[], total: number): string[] {
209
+ const warnings: string[] = [];
210
+ const duplicate = categories.filter((category) => category.sourceRows.length > 1).map((category) => category.path.join(' > '));
211
+ if (duplicate.length) warnings.push(`Duplicate category paths were grouped: ${duplicate.join(', ')}.`);
212
+ const populations = uniqueNumbers(rows, 'population');
213
+ if (populations.length > 1) warnings.push('More than one population denominator was found. Per-capita spending uses the first value and needs review.');
214
+ if (populations.some((population) => population <= 0)) warnings.push('Population contains a non-positive value, so the denominator needs review.');
215
+ if (rows.some((row) => row.amount < 0)) warnings.push('Negative amounts are preserved as adjustments but are excluded from the positive visual composition.');
216
+ if (total <= 0) warnings.push('The total is not positive, so category shares are not shown.');
217
+ if (rows.some((row) => row.priorAmount === 0)) warnings.push('A zero prior amount has no percentage variation and is shown without a change rate.');
218
+ if (rows.some((row) => row.path.length > 1)) warnings.push('Nested paths are grouped by the exact path shown in the source data. Check that parent rows are not also counted as child totals.');
219
+ return warnings;
220
+ }
221
+
222
+ export function analyzeBudgetRecords(records: RawBudgetRecord[]): BudgetAnalysis {
223
+ const normalized = records.map((record, index) => normalizeRow(record, index + 2));
224
+ const errors = normalized.flatMap((result) => result.error ? [result.error] : []);
225
+ const rows = normalized.flatMap((result) => result.row ? [result.row] : []);
226
+ const total = rows.reduce((sum, row) => sum + row.amount, 0);
227
+ const positiveTotal = rows.reduce((sum, row) => sum + (row.amount > 0 ? row.amount : 0), 0);
228
+ const categories = groupCategories(rows, total);
229
+ const populations = uniqueNumbers(rows, 'population');
230
+ const validPopulation = populations.find((value) => value > 0);
231
+ const previousTotal = rows.some((row) => row.priorAmount !== undefined) ? rows.reduce((sum, row) => sum + (row.priorAmount ?? 0), 0) : undefined;
232
+ const currencies = [...new Set(rows.map((row) => row.currency).filter(Boolean))];
233
+ const periods = [...new Set(rows.map((row) => row.period).filter(Boolean))];
234
+ const warnings = collectWarnings(rows, categories, total);
235
+ if (currencies.length > 1) warnings.push('More than one currency label was found. Values have not been converted.');
236
+ if (currencies.some((currency) => !/^[A-Za-z]{3}$/.test(currency || ''))) warnings.push('At least one currency label is not a three-letter code. Values have not been converted.');
237
+ if (periods.length > 1) warnings.push('More than one period label was found. The first period is shown and the source periods need review.');
238
+ return { rows, categories, total, positiveTotal, previousTotal, population: validPopulation, perCapita: validPopulation ? total / validPopulation : undefined, variation: previousTotal !== undefined && previousTotal !== 0 ? (total - previousTotal) / Math.abs(previousTotal) : undefined, currency: currencies[0], period: periods[0], warnings, errors };
239
+ }
240
+
241
+ export function exampleBudgetRecords(): RawBudgetRecord[] {
242
+ return [
243
+ { category: 'Education > Schools', amount: 480000, population: 24000, period: '2026', priorAmount: 430000, currency: 'EUR' },
244
+ { category: 'Health > Clinics', amount: 310000, population: 24000, period: '2026', priorAmount: 290000, currency: 'EUR' },
245
+ { category: 'Housing', amount: 220000, population: 24000, period: '2026', priorAmount: 250000, currency: 'EUR' },
246
+ { category: 'Parks', amount: 145000, population: 24000, period: '2026', priorAmount: 130000, currency: 'EUR' },
247
+ { category: 'Culture', amount: 95000, population: 24000, period: '2026', priorAmount: 90000, currency: 'EUR' },
248
+ ];
249
+ }
@@ -0,0 +1,461 @@
1
+ :root {
2
+ --n-paper: #f8f6ef;
3
+ --n-surface: #fffdf8;
4
+ --n-ink: #1d252c;
5
+ --n-muted: #5b6870;
6
+ --n-line: #cbd5d2;
7
+ --n-accent: #a6442f;
8
+ --n-accent-strong: #7d2f27;
9
+ --n-segment-a: #1e6870;
10
+ --n-segment-b: #c97943;
11
+ --n-good: #286c4a;
12
+ --n-warn-bg: #fff1d2;
13
+ --n-warn-ink: #704b0b;
14
+ --n-error-bg: #f9dfdb;
15
+ --n-error-ink: #7d2f27;
16
+ --n-shadow: rgba(29, 37, 44, 0.12);
17
+ }
18
+
19
+ .theme-dark {
20
+ --n-paper: #15171b;
21
+ --n-surface: #20242a;
22
+ --n-ink: #f3eee3;
23
+ --n-muted: #bcc3c5;
24
+ --n-line: #4b555b;
25
+ --n-accent: #e48663;
26
+ --n-accent-strong: #ffad86;
27
+ --n-segment-a: #63bcc0;
28
+ --n-segment-b: #e5a068;
29
+ --n-good: #7ad49e;
30
+ --n-warn-bg: #473818;
31
+ --n-warn-ink: #ffe09b;
32
+ --n-error-bg: #512923;
33
+ --n-error-ink: #ffb5a5;
34
+ --n-shadow: rgba(0, 0, 0, 0.3);
35
+ }
36
+
37
+ .public-budget-analyzer {
38
+ --n-paper: #f8f6ef;
39
+ --n-surface: #fffdf8;
40
+ --n-ink: #1d252c;
41
+ --n-muted: #5b6870;
42
+ --n-line: #cbd5d2;
43
+ --n-accent: #a6442f;
44
+ --n-accent-strong: #7d2f27;
45
+ --n-segment-a: #1e6870;
46
+ --n-segment-b: #c97943;
47
+ --n-good: #286c4a;
48
+ --n-warn-bg: #fff1d2;
49
+ --n-warn-ink: #704b0b;
50
+ --n-error-bg: #f9dfdb;
51
+ --n-error-ink: #7d2f27;
52
+ --n-shadow: rgba(29, 37, 44, 0.12);
53
+
54
+ background: var(--n-paper);
55
+ border: 1px solid var(--n-line);
56
+ border-radius: 1.25rem;
57
+ box-shadow: 0 1rem 2rem var(--n-shadow);
58
+ color: var(--n-ink);
59
+ min-width: 0;
60
+ overflow: hidden;
61
+ }
62
+
63
+ .theme-dark .public-budget-analyzer {
64
+ --n-paper: #15171b;
65
+ --n-surface: #20242a;
66
+ --n-ink: #f3eee3;
67
+ --n-muted: #bcc3c5;
68
+ --n-line: #4b555b;
69
+ --n-accent: #e48663;
70
+ --n-accent-strong: #ffad86;
71
+ --n-segment-a: #63bcc0;
72
+ --n-segment-b: #e5a068;
73
+ --n-good: #7ad49e;
74
+ --n-warn-bg: #473818;
75
+ --n-warn-ink: #ffe09b;
76
+ --n-error-bg: #512923;
77
+ --n-error-ink: #ffb5a5;
78
+ --n-shadow: rgba(0, 0, 0, 0.3);
79
+ }
80
+
81
+ main:has(.public-budget-analyzer) {
82
+ min-width: 0;
83
+ }
84
+
85
+ .budget-command-bar,
86
+ .budget-result,
87
+ .budget-guidance {
88
+ padding: 1.5rem;
89
+ }
90
+
91
+ .budget-command-bar {
92
+ align-items: stretch;
93
+ border-bottom: 1px solid var(--n-line);
94
+ display: grid;
95
+ gap: 1rem;
96
+ grid-template-columns: minmax(0, 1fr);
97
+ min-width: 0;
98
+ }
99
+
100
+ .budget-dropzone {
101
+ border: 1px dashed var(--n-accent);
102
+ border-radius: 0.75rem;
103
+ padding: 1rem;
104
+ position: relative;
105
+ min-width: 0;
106
+ }
107
+
108
+ .budget-dropzone.is-dragging {
109
+ background: var(--n-warn-bg);
110
+ }
111
+
112
+ .budget-dropzone input {
113
+ block-size: 100%;
114
+ cursor: pointer;
115
+ inline-size: 100%;
116
+ inset: 0;
117
+ opacity: 0;
118
+ position: absolute;
119
+ }
120
+
121
+ .budget-dropzone label {
122
+ color: var(--n-accent-strong);
123
+ display: block;
124
+ font-size: 1rem;
125
+ font-weight: 800;
126
+ }
127
+
128
+ .budget-dropzone p,
129
+ .budget-dropzone small {
130
+ color: var(--n-muted);
131
+ display: block;
132
+ line-height: 1.5;
133
+ margin: 0.35rem 0 0;
134
+ overflow-wrap: anywhere;
135
+ }
136
+
137
+ .budget-actions {
138
+ align-items: center;
139
+ display: flex;
140
+ flex-wrap: wrap;
141
+ gap: 0.5rem;
142
+ justify-content: flex-start;
143
+ min-width: 0;
144
+ }
145
+
146
+ .budget-actions button {
147
+ background: var(--n-surface);
148
+ border: 1px solid var(--n-line);
149
+ border-radius: 999px;
150
+ color: var(--n-ink);
151
+ cursor: pointer;
152
+ flex: 1 1 8rem;
153
+ font: inherit;
154
+ font-size: 0.82rem;
155
+ min-width: 0;
156
+ max-width: 16rem;
157
+ padding: 0.65rem 0.85rem;
158
+ white-space: normal;
159
+ }
160
+
161
+ .budget-actions button:first-child {
162
+ background: var(--n-accent);
163
+ border-color: var(--n-accent);
164
+ color: var(--n-surface);
165
+ }
166
+
167
+ .budget-actions button:hover,
168
+ .budget-actions button:focus-visible {
169
+ border-color: var(--n-accent-strong);
170
+ outline: 2px solid var(--n-accent);
171
+ outline-offset: 2px;
172
+ }
173
+
174
+ .budget-result {
175
+ background: var(--n-surface);
176
+ min-height: 16rem;
177
+ min-width: 0;
178
+ }
179
+
180
+ .budget-result-head,
181
+ .budget-detail-heading,
182
+ .budget-landscape-axis,
183
+ .budget-category-label,
184
+ .budget-facts {
185
+ align-items: baseline;
186
+ display: flex;
187
+ gap: 1rem;
188
+ justify-content: space-between;
189
+ }
190
+
191
+ .budget-result-head h2,
192
+ .budget-detail-heading h3,
193
+ .budget-guidance h2,
194
+ .budget-notice h3 {
195
+ color: var(--n-ink);
196
+ margin: 0;
197
+ }
198
+
199
+ .budget-result-head > p,
200
+ .budget-detail-heading > span,
201
+ .budget-category-row small,
202
+ .budget-landscape-axis,
203
+ .budget-facts span {
204
+ color: var(--n-muted);
205
+ font-size: 0.78rem;
206
+ }
207
+
208
+ .budget-status {
209
+ border: 1px solid var(--n-line);
210
+ border-radius: 999px;
211
+ display: inline-block;
212
+ font-size: 0.7rem;
213
+ font-weight: 800;
214
+ letter-spacing: 0.04em;
215
+ margin-bottom: 0.55rem;
216
+ padding: 0.25rem 0.55rem;
217
+ text-transform: uppercase;
218
+ }
219
+
220
+ .budget-status-ready { color: var(--n-good); }
221
+ .budget-status-review { color: var(--n-warn-ink); }
222
+ .budget-status-empty { color: var(--n-muted); }
223
+
224
+ .budget-metrics {
225
+ display: grid;
226
+ gap: 0.6rem;
227
+ grid-template-columns: repeat(4, minmax(0, 1fr));
228
+ margin: 1.5rem 0;
229
+ }
230
+
231
+ .budget-metric {
232
+ border-left: 3px solid var(--n-accent);
233
+ min-width: 0;
234
+ padding: 0.25rem 0 0.25rem 0.75rem;
235
+ }
236
+
237
+ .budget-metric span,
238
+ .budget-metric strong,
239
+ .budget-facts strong,
240
+ .budget-facts span {
241
+ display: block;
242
+ }
243
+
244
+ .budget-metric span {
245
+ color: var(--n-muted);
246
+ font-size: 0.75rem;
247
+ }
248
+
249
+ .budget-metric strong {
250
+ font-size: 1.08rem;
251
+ margin-top: 0.25rem;
252
+ overflow-wrap: anywhere;
253
+ }
254
+
255
+ .budget-landscape {
256
+ border-top: 1px solid var(--n-line);
257
+ padding-top: 0.8rem;
258
+ }
259
+
260
+ .budget-category-row {
261
+ margin-top: 0.9rem;
262
+ }
263
+
264
+ .budget-category-label strong {
265
+ font-size: 0.86rem;
266
+ overflow-wrap: anywhere;
267
+ white-space: normal;
268
+ }
269
+
270
+ .budget-category-label span {
271
+ overflow-wrap: anywhere;
272
+ min-width: 0;
273
+ }
274
+
275
+ .budget-bar {
276
+ background: var(--n-paper);
277
+ border: 1px solid var(--n-line);
278
+ height: 1rem;
279
+ margin-top: 0.35rem;
280
+ overflow: hidden;
281
+ }
282
+
283
+ .budget-bar span {
284
+ background: var(--n-segment-a);
285
+ display: block;
286
+ height: 100%;
287
+ width: var(--segment-width);
288
+ }
289
+
290
+ .budget-bar .budget-segment-b {
291
+ background: var(--n-segment-b);
292
+ }
293
+
294
+ .budget-category-row small {
295
+ display: block;
296
+ margin-top: 0.3rem;
297
+ }
298
+
299
+ .budget-detail {
300
+ border-top: 1px solid var(--n-line);
301
+ margin-top: 1.5rem;
302
+ min-width: 0;
303
+ padding-top: 1.25rem;
304
+ }
305
+
306
+ .budget-table-wrap {
307
+ min-width: 0;
308
+ overflow-x: auto;
309
+ }
310
+
311
+ .budget-table-wrap table {
312
+ border-collapse: collapse;
313
+ margin-top: 0.8rem;
314
+ min-width: 42rem;
315
+ width: 100%;
316
+ }
317
+
318
+ .budget-table-wrap th,
319
+ .budget-table-wrap td {
320
+ border-bottom: 1px solid var(--n-line);
321
+ padding: 0.65rem 0.5rem;
322
+ text-align: left;
323
+ vertical-align: top;
324
+ }
325
+
326
+ .budget-table-wrap th {
327
+ color: var(--n-muted);
328
+ font-size: 0.72rem;
329
+ text-transform: uppercase;
330
+ }
331
+
332
+ .budget-table-wrap td {
333
+ font-size: 0.84rem;
334
+ }
335
+
336
+ .budget-notice {
337
+ border-left: 4px solid var(--n-accent);
338
+ margin-top: 1.25rem;
339
+ padding: 0.8rem 1rem;
340
+ }
341
+
342
+ .budget-notice-warning {
343
+ background: var(--n-warn-bg);
344
+ border-color: var(--n-warn-ink);
345
+ color: var(--n-warn-ink);
346
+ }
347
+
348
+ .budget-notice-error {
349
+ background: var(--n-error-bg);
350
+ border-color: var(--n-error-ink);
351
+ color: var(--n-error-ink);
352
+ }
353
+
354
+ .budget-notice h3 {
355
+ color: var(--n-ink);
356
+ font-size: 0.95rem;
357
+ }
358
+
359
+ .budget-notice ul {
360
+ line-height: 1.5;
361
+ margin: 0.5rem 0 0;
362
+ padding-left: 1.15rem;
363
+ }
364
+
365
+ .budget-facts {
366
+ border-top: 1px solid var(--n-line);
367
+ flex-wrap: wrap;
368
+ margin-top: 1.5rem;
369
+ padding-top: 1rem;
370
+ }
371
+
372
+ .budget-facts div {
373
+ min-width: 10rem;
374
+ }
375
+
376
+ .budget-facts strong {
377
+ margin-top: 0.25rem;
378
+ overflow-wrap: anywhere;
379
+ }
380
+
381
+ .budget-empty-note,
382
+ .budget-inline-message {
383
+ border: 1px solid var(--n-line);
384
+ color: var(--n-muted);
385
+ margin: 0;
386
+ padding: 1rem;
387
+ }
388
+
389
+ .budget-inline-error {
390
+ background: var(--n-error-bg);
391
+ border-color: var(--n-error-ink);
392
+ color: var(--n-error-ink);
393
+ }
394
+
395
+ .budget-guidance {
396
+ border-top: 1px solid var(--n-line);
397
+ display: grid;
398
+ gap: 1rem;
399
+ grid-template-columns: repeat(3, minmax(0, 1fr));
400
+ }
401
+
402
+ .budget-guidance section {
403
+ border-left: 2px solid var(--n-accent);
404
+ padding-left: 0.8rem;
405
+ }
406
+
407
+ .budget-guidance h2 {
408
+ font-size: 0.92rem;
409
+ }
410
+
411
+ .budget-guidance p {
412
+ color: var(--n-muted);
413
+ font-size: 0.83rem;
414
+ line-height: 1.55;
415
+ margin: 0.5rem 0 0;
416
+ }
417
+
418
+ @media (max-width: 800px) {
419
+ .budget-command-bar,
420
+ .budget-guidance {
421
+ grid-template-columns: 1fr;
422
+ }
423
+
424
+ .budget-actions {
425
+ justify-content: flex-start;
426
+ }
427
+
428
+ .budget-metrics {
429
+ grid-template-columns: repeat(2, minmax(0, 1fr));
430
+ }
431
+ }
432
+
433
+ @media (max-width: 480px) {
434
+ .budget-command-bar,
435
+ .budget-result,
436
+ .budget-guidance {
437
+ padding: 1rem;
438
+ }
439
+
440
+ .budget-metrics {
441
+ grid-template-columns: 1fr;
442
+ }
443
+
444
+ .budget-result-head,
445
+ .budget-detail-heading,
446
+ .budget-category-label {
447
+ align-items: flex-start;
448
+ flex-direction: column;
449
+ gap: 0.25rem;
450
+ }
451
+ }
452
+
453
+ @media print {
454
+ .public-budget-analyzer {
455
+ box-shadow: none;
456
+ }
457
+
458
+ .budget-command-bar {
459
+ display: none;
460
+ }
461
+ }