@jjlmoya/utils-diy 1.26.0 → 1.28.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 (51) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +4 -1
  4. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  5. package/src/tests/locale_completeness.test.ts +2 -3
  6. package/src/tests/seo_translation_completeness.test.ts +69 -0
  7. package/src/tests/seo_wellformed_export.test.ts +65 -0
  8. package/src/tests/tool_validation.test.ts +2 -2
  9. package/src/tool/drywallCalculator/i18n/de.ts +1 -1
  10. package/src/tool/drywallCalculator/i18n/id.ts +2 -2
  11. package/src/tool/drywallCalculator/i18n/it.ts +2 -2
  12. package/src/tool/drywallCalculator/i18n/ja.ts +1 -1
  13. package/src/tool/drywallCalculator/i18n/ko.ts +1 -1
  14. package/src/tool/drywallCalculator/i18n/nl.ts +2 -2
  15. package/src/tool/drywallCalculator/i18n/pl.ts +3 -3
  16. package/src/tool/drywallCalculator/i18n/pt.ts +3 -3
  17. package/src/tool/drywallCalculator/i18n/ru.ts +2 -2
  18. package/src/tool/drywallCalculator/i18n/sv.ts +3 -3
  19. package/src/tool/drywallCalculator/i18n/tr.ts +3 -3
  20. package/src/tool/drywallCalculator/i18n/zh.ts +3 -3
  21. package/src/tool/drywallCalculator/seo.astro +12 -11
  22. package/src/tool/excavationVolumeCalculator/bibliography.astro +6 -0
  23. package/src/tool/excavationVolumeCalculator/bibliography.ts +12 -0
  24. package/src/tool/excavationVolumeCalculator/component.astro +80 -0
  25. package/src/tool/excavationVolumeCalculator/controller.ts +69 -0
  26. package/src/tool/excavationVolumeCalculator/dom-views.ts +63 -0
  27. package/src/tool/excavationVolumeCalculator/entry.ts +26 -0
  28. package/src/tool/excavationVolumeCalculator/evaluator.ts +21 -0
  29. package/src/tool/excavationVolumeCalculator/excavation-volume-calculator.css +442 -0
  30. package/src/tool/excavationVolumeCalculator/i18n/de.ts +120 -0
  31. package/src/tool/excavationVolumeCalculator/i18n/en.ts +120 -0
  32. package/src/tool/excavationVolumeCalculator/i18n/es.ts +120 -0
  33. package/src/tool/excavationVolumeCalculator/i18n/fr.ts +120 -0
  34. package/src/tool/excavationVolumeCalculator/i18n/id.ts +120 -0
  35. package/src/tool/excavationVolumeCalculator/i18n/it.ts +120 -0
  36. package/src/tool/excavationVolumeCalculator/i18n/ja.ts +120 -0
  37. package/src/tool/excavationVolumeCalculator/i18n/ko.ts +120 -0
  38. package/src/tool/excavationVolumeCalculator/i18n/nl.ts +120 -0
  39. package/src/tool/excavationVolumeCalculator/i18n/pl.ts +120 -0
  40. package/src/tool/excavationVolumeCalculator/i18n/pt.ts +120 -0
  41. package/src/tool/excavationVolumeCalculator/i18n/ru.ts +120 -0
  42. package/src/tool/excavationVolumeCalculator/i18n/sv.ts +120 -0
  43. package/src/tool/excavationVolumeCalculator/i18n/tr.ts +120 -0
  44. package/src/tool/excavationVolumeCalculator/i18n/zh.ts +120 -0
  45. package/src/tool/excavationVolumeCalculator/index.ts +11 -0
  46. package/src/tool/excavationVolumeCalculator/logic.test.ts +29 -0
  47. package/src/tool/excavationVolumeCalculator/logic.ts +35 -0
  48. package/src/tool/excavationVolumeCalculator/seo.astro +15 -0
  49. package/src/tool/excavationVolumeCalculator/storage.ts +28 -0
  50. package/src/tool/excavationVolumeCalculator/ui.ts +66 -0
  51. package/src/tools.ts +2 -1
@@ -0,0 +1,80 @@
1
+ ---
2
+ import './excavation-volume-calculator.css';
3
+ import type { ExcavationCalculatorUI } from './ui';
4
+
5
+ interface Props {
6
+ ui?: Record<string, unknown>;
7
+ }
8
+
9
+ const { ui } = Astro.props;
10
+ const t = (ui ?? {}) as ExcavationCalculatorUI;
11
+ ---
12
+
13
+ <div class="ev-root" data-ev-root data-ev-ui={JSON.stringify(t)}>
14
+ <div class="ev-board">
15
+ <div class="ev-intro">
16
+ <div class="ev-intro-mark" aria-hidden="true">∿</div>
17
+ <p>{t.onboarding}</p>
18
+ </div>
19
+
20
+ <section class="ev-scene-panel" aria-labelledby="ev-scene-title">
21
+ <div class="ev-result-heading"><span class="ev-kicker" id="ev-scene-title">{t.sceneTitle}</span><span class="ev-status" data-ev-status>{t.soilStatusLabel}: {t.soilStatusMedium}</span></div>
22
+ <div class="ev-scene" data-ev-scene></div>
23
+ <div class="ev-metrics">
24
+ <div class="ev-metric ev-metric-main"><span class="ev-metric-label">{t.bankVolumeLabel}</span><strong class="ev-metric-value"><span data-ev-bank>9.98</span> <span class="ev-unit-suffix" data-ev-volume-unit>m³</span></strong><span class="ev-metric-help">{t.bankVolumeHelp}</span></div>
25
+ <div class="ev-metric"><span class="ev-metric-label">{t.looseVolumeLabel}</span><strong class="ev-metric-value"><span data-ev-loose-low>11.48</span> to <span data-ev-loose-high>12.47</span> <span class="ev-unit-suffix" data-ev-volume-unit>m³</span></strong><span class="ev-metric-help">{t.looseVolumeHelp}</span></div>
26
+ </div>
27
+ <div class="ev-expansion"><span>{t.expansionLabel}</span><strong data-ev-expansion>15 to 25%</strong></div>
28
+ </section>
29
+
30
+ <div class="ev-workbench">
31
+ <section class="ev-control-panel" aria-labelledby="ev-dimensions-title">
32
+ <div class="ev-control-heading">
33
+ <span class="ev-kicker" id="ev-dimensions-title">{t.dimensionLabel}</span>
34
+ <div class="ev-segmented" role="group" aria-label={t.unitSystemLabel}>
35
+ <button type="button" class="ev-active" data-ev-unit="metric">{t.unitMetric}</button>
36
+ <button type="button" data-ev-unit="imperial">{t.unitImperial}</button>
37
+ </div>
38
+ </div>
39
+
40
+ <fieldset class="ev-fieldset">
41
+ <legend>{t.dimensionLabel}</legend>
42
+ <div class="ev-dimensions">
43
+ <div class="ev-field">
44
+ <label for="ev-length">{t.lengthLabel}</label>
45
+ <div class="ev-input"><input id="ev-length" data-ev-dimension="length" type="number" min="0" step="0.01" value="4.8" inputmode="decimal" /><span class="ev-unit" data-ev-unit-label>m</span></div>
46
+ </div>
47
+ <div class="ev-field">
48
+ <label for="ev-width">{t.widthLabel}</label>
49
+ <div class="ev-input"><input id="ev-width" data-ev-dimension="width" type="number" min="0" step="0.01" value="3.2" inputmode="decimal" /><span class="ev-unit" data-ev-unit-label>m</span></div>
50
+ </div>
51
+ <div class="ev-field">
52
+ <label for="ev-depth">{t.depthLabel}</label>
53
+ <div class="ev-input"><input id="ev-depth" data-ev-dimension="depth" type="number" min="0" step="0.01" value="0.65" inputmode="decimal" /><span class="ev-unit" data-ev-unit-label>m</span></div>
54
+ </div>
55
+ </div>
56
+ </fieldset>
57
+
58
+ <fieldset class="ev-fieldset ev-soil-fieldset">
59
+ <legend>{t.soilLabel}</legend>
60
+ <div class="ev-soil-grid">
61
+ <button type="button" class="ev-soil-button" data-ev-soil="sand"><span class="ev-soil-name">{t.soilSand}</span><span class="ev-soil-band">{t.soilSandBand}</span></button>
62
+ <button type="button" class="ev-soil-button ev-active" data-ev-soil="topsoil"><span class="ev-soil-name">{t.soilTopsoil}</span><span class="ev-soil-band">{t.soilTopsoilBand}</span></button>
63
+ <button type="button" class="ev-soil-button" data-ev-soil="clay"><span class="ev-soil-name">{t.soilClay}</span><span class="ev-soil-band">{t.soilClayBand}</span></button>
64
+ <button type="button" class="ev-soil-button" data-ev-soil="gravel"><span class="ev-soil-name">{t.soilGravel}</span><span class="ev-soil-band">{t.soilGravelBand}</span></button>
65
+ </div>
66
+ </fieldset>
67
+ </section>
68
+ </div>
69
+ <div class="ev-notes"><p class="ev-interpretation"><strong>{t.interpretationTitle}</strong> {t.interpretationText}</p><p class="ev-warning">{t.warning}</p></div>
70
+ </div>
71
+ </div>
72
+
73
+ <script>
74
+ import { mountExcavationCalculator } from './controller';
75
+ import type { ExcavationCalculatorUI } from './ui';
76
+
77
+ const root = document.querySelector('[data-ev-root]');
78
+ const ui = JSON.parse(root?.getAttribute('data-ev-ui') || '{}') as ExcavationCalculatorUI;
79
+ if (root) mountExcavationCalculator(root, ui);
80
+ </script>
@@ -0,0 +1,69 @@
1
+ import { calculateExcavation, convertDimension } from './logic';
2
+ import { renderResults } from './dom-views';
3
+ import { readExcavationState, writeExcavationState } from './storage';
4
+ import type { ExcavationCalculatorUI, ExcavationInput, ExcavationUnitSystem, SoilProfile } from './ui';
5
+
6
+ const initialState: ExcavationInput = { unitSystem: 'metric', length: 4.8, width: 3.2, depth: 0.65, soil: 'topsoil' };
7
+
8
+ export function mountExcavationCalculator(root: Element, ui: ExcavationCalculatorUI): void {
9
+ const state = readExcavationState(initialState);
10
+ setControls(root, state);
11
+ bindUnitButtons(root, state, ui);
12
+ bindDimensionInputs(root, state, ui);
13
+ bindSoilButtons(root, state, ui);
14
+ render(root, state, ui);
15
+ }
16
+
17
+ function bindUnitButtons(root: Element, state: ExcavationInput, ui: ExcavationCalculatorUI): void {
18
+ root.querySelectorAll('[data-ev-unit]').forEach((button) => button.addEventListener('click', () => {
19
+ const next = (button as HTMLElement).dataset.evUnit as ExcavationUnitSystem;
20
+ if (next === state.unitSystem) return;
21
+ ['length', 'width', 'depth'].forEach((key) => {
22
+ const value = state[key as keyof ExcavationInput] as number;
23
+ state[key as 'length' | 'width' | 'depth'] = convertDimension(value, state.unitSystem, next);
24
+ });
25
+ state.unitSystem = next;
26
+ setControls(root, state);
27
+ render(root, state, ui);
28
+ }));
29
+ }
30
+
31
+ function bindDimensionInputs(root: Element, state: ExcavationInput, ui: ExcavationCalculatorUI): void {
32
+ root.querySelectorAll<HTMLInputElement>('[data-ev-dimension]').forEach((input) => input.addEventListener('input', () => {
33
+ const key = input.dataset.evDimension as 'length' | 'width' | 'depth';
34
+ state[key] = Number(input.value) || 0;
35
+ render(root, state, ui);
36
+ }));
37
+ }
38
+
39
+ function bindSoilButtons(root: Element, state: ExcavationInput, ui: ExcavationCalculatorUI): void {
40
+ root.querySelectorAll('[data-ev-soil]').forEach((button) => button.addEventListener('click', () => {
41
+ state.soil = (button as HTMLElement).dataset.evSoil as SoilProfile;
42
+ setControls(root, state);
43
+ render(root, state, ui);
44
+ }));
45
+ }
46
+
47
+
48
+ function setControls(root: Element, state: ExcavationInput): void {
49
+ root.querySelectorAll('[data-ev-unit]').forEach((button) => button.classList.toggle('ev-active', (button as HTMLElement).dataset.evUnit === state.unitSystem));
50
+ root.querySelectorAll<HTMLInputElement>('[data-ev-dimension]').forEach((input) => {
51
+ const key = input.dataset.evDimension as 'length' | 'width' | 'depth';
52
+ input.value = String(roundInput(state[key]));
53
+ });
54
+ root.querySelectorAll('[data-ev-soil]').forEach((button) => button.classList.toggle('ev-active', (button as HTMLElement).dataset.evSoil === state.soil));
55
+ root.querySelectorAll('[data-ev-unit-label]').forEach((element) => { element.textContent = state.unitSystem === 'metric' ? 'm' : 'ft'; });
56
+ root.querySelectorAll('[data-ev-volume-unit]').forEach((element) => { element.textContent = state.unitSystem === 'metric' ? 'm³' : 'ft³'; });
57
+ }
58
+
59
+ function render(root: Element, state: ExcavationInput, ui: ExcavationCalculatorUI): void {
60
+ const output = calculateExcavation(state);
61
+ const system = state.unitSystem;
62
+ renderResults(root, output, state.soil, ui);
63
+ writeExcavationState(state);
64
+ root.dataset.evSystem = system;
65
+ }
66
+
67
+ function roundInput(value: number): number {
68
+ return Math.round(value * 100) / 100;
69
+ }
@@ -0,0 +1,63 @@
1
+ import type { ExcavationOutput, ExcavationCalculatorUI, SoilProfile } from './ui';
2
+ import { evaluateExcavation } from './evaluator';
3
+
4
+ const soilColors: Record<SoilProfile, string> = { sand: '#e6b86a', topsoil: '#86603b', clay: '#b8644b', gravel: '#8b9290' };
5
+
6
+ export function formatVolume(value: number): string {
7
+ return value.toLocaleString('en-US', { maximumFractionDigits: 2, minimumFractionDigits: 2 });
8
+ }
9
+
10
+ export function renderExcavationScene(root: Element, output: ExcavationOutput, soil: SoilProfile, ui: ExcavationCalculatorUI): void {
11
+ const scene = root.querySelector('[data-ev-scene]');
12
+ if (!scene) return;
13
+ const color = soilColors[soil];
14
+ const expansionWidth = Math.min(190, Math.max(40, output.looseHigh / Math.max(output.bankVolume, 0.01) * 105));
15
+ scene.innerHTML = `<svg class="ev-scene-svg" viewBox="0 0 640 320" role="img" aria-label="${ui.sceneTitle}">
16
+ <rect class="ev-sky" x="0" y="0" width="640" height="300" rx="20" />
17
+ <path class="ev-contour" d="M32 62 C126 22 205 75 293 45 S470 24 610 62 M42 83 C130 48 203 96 304 68 S477 49 600 85" />
18
+ <path class="ev-ground" d="M0 112 C120 102 190 119 290 108 C410 96 520 120 640 108 V300 H0Z" />
19
+ <path class="ev-strata" d="M0 160 C120 150 190 168 290 156 C410 144 520 167 640 156 M0 212 C120 202 190 220 290 208 C410 196 520 219 640 208" />
20
+ <path class="ev-cut" d="M150 112 V235 H405 V112" fill="${color}" />
21
+ <path class="ev-cut-line" d="M150 112 V235 H405 V112" />
22
+ <path class="ev-bank-fill" d="M178 142 H377 V214 H178Z" />
23
+ <path class="ev-loose-fill" d="M455 244 Q474 207 494 224 Q518 177 542 222 Q570 194 594 225 Q610 211 ${455 + expansionWidth} 244Z" />
24
+ <path class="ev-arrow" d="M420 178 C445 178 445 178 468 178 M458 168 L468 178 L458 188" />
25
+ <path class="ev-measure" d="M128 112 V235 M122 112 H134 M122 235 H134" />
26
+ <text class="ev-label ev-label-measure" x="105" y="181">D</text>
27
+ <text class="ev-label ev-label-bank" x="178" y="132">${ui.sceneBank}</text>
28
+ <text class="ev-label ev-label-loose" x="455" y="264">${ui.sceneLoose}</text>
29
+ <text class="ev-label ev-label-cut" x="158" y="286">${ui.sceneCut}</text>
30
+ <circle class="ev-sun" cx="570" cy="52" r="20" />
31
+ <path class="ev-grass" d="M0 112 ${grassPath()}" />
32
+ </svg>`;
33
+ }
34
+
35
+ function grassPath(): string {
36
+ return Array.from({ length: 28 }, (_, index) => {
37
+ const x = index * 24;
38
+ return `M${x} 112 l4 -10 M${x + 8} 112 l-2 -8`;
39
+ }).join(' ');
40
+ }
41
+
42
+ export function renderResults(root: Element, output: ExcavationOutput, soil: SoilProfile, ui: ExcavationCalculatorUI): void {
43
+ setText(root, '[data-ev-bank]', formatVolume(output.bankVolume));
44
+ setText(root, '[data-ev-loose-low]', formatVolume(output.looseLow));
45
+ setText(root, '[data-ev-loose-high]', formatVolume(output.looseHigh));
46
+ setText(root, '[data-ev-expansion]', `${Math.round(output.expansionLow * 100)}-${Math.round(output.expansionHigh * 100)}%`);
47
+ const evaluation = evaluateExcavation(output, soil);
48
+ const badge = root.querySelector('[data-ev-status]');
49
+ if (badge) {
50
+ badge.textContent = `${ui.soilStatusLabel}: ${ui[`soilStatus${capitalize(evaluation.status)}` as keyof ExcavationCalculatorUI]}`;
51
+ badge.setAttribute('data-ev-status-level', evaluation.status);
52
+ }
53
+ renderExcavationScene(root, output, soil, ui);
54
+ }
55
+
56
+ function setText(root: Element, selector: string, value: string): void {
57
+ const element = root.querySelector(selector);
58
+ if (element) element.textContent = value;
59
+ }
60
+
61
+ function capitalize(value: string): string {
62
+ return value.charAt(0).toUpperCase() + value.slice(1);
63
+ }
@@ -0,0 +1,26 @@
1
+ import type { DiyToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { ExcavationCalculatorUI } from './ui';
3
+
4
+ export type ExcavationVolumeCalculatorLocaleContent = ToolLocaleContent<ExcavationCalculatorUI>;
5
+
6
+ export const excavationVolumeCalculator: DiyToolEntry<ExcavationCalculatorUI> = {
7
+ id: 'excavation-volume-soil-swell-calculator',
8
+ icons: { bg: 'mdi:land-fields', fg: 'mdi:shovel' },
9
+ i18n: {
10
+ de: () => import('./i18n/de').then((m) => m.content),
11
+ en: () => import('./i18n/en').then((m) => m.content),
12
+ es: () => import('./i18n/es').then((m) => m.content),
13
+ fr: () => import('./i18n/fr').then((m) => m.content),
14
+ id: () => import('./i18n/id').then((m) => m.content),
15
+ it: () => import('./i18n/it').then((m) => m.content),
16
+ ja: () => import('./i18n/ja').then((m) => m.content),
17
+ ko: () => import('./i18n/ko').then((m) => m.content),
18
+ nl: () => import('./i18n/nl').then((m) => m.content),
19
+ pl: () => import('./i18n/pl').then((m) => m.content),
20
+ pt: () => import('./i18n/pt').then((m) => m.content),
21
+ ru: () => import('./i18n/ru').then((m) => m.content),
22
+ sv: () => import('./i18n/sv').then((m) => m.content),
23
+ tr: () => import('./i18n/tr').then((m) => m.content),
24
+ zh: () => import('./i18n/zh').then((m) => m.content),
25
+ },
26
+ };
@@ -0,0 +1,21 @@
1
+ import type { ExcavationOutput, SoilProfile } from './ui';
2
+
3
+ export type ExcavationStatus = 'low' | 'medium' | 'high';
4
+
5
+ export interface ExcavationEvaluation {
6
+ status: ExcavationStatus;
7
+ ratio: number;
8
+ soil: SoilProfile;
9
+ }
10
+
11
+ export function evaluateExcavation(output: ExcavationOutput, soil: SoilProfile): ExcavationEvaluation {
12
+ const ratio = output.expansionHigh;
13
+ const status = getStatus(ratio);
14
+ return { status, ratio, soil };
15
+ }
16
+
17
+ function getStatus(ratio: number): ExcavationStatus {
18
+ if (ratio > 0.25) return 'high';
19
+ if (ratio >= 0.15) return 'medium';
20
+ return 'low';
21
+ }
@@ -0,0 +1,442 @@
1
+ :root {
2
+ --n-ink: #1d2925;
3
+ --n-muted: #64736b;
4
+ --n-paper: #f5f1e8;
5
+ --n-surface: #fffdf8;
6
+ --n-line: #d8d0c2;
7
+ --n-accent: #2f7359;
8
+ --n-accent-soft: #dceadd;
9
+ --n-sand: #e6b86a;
10
+ --n-soil: #86603b;
11
+ --n-warning: #8a5b20;
12
+ }
13
+
14
+ .theme-dark {
15
+ --n-ink: #f4f0e8;
16
+ --n-muted: #b3c0b8;
17
+ --n-paper: #263630;
18
+ --n-surface: #31453a;
19
+ --n-line: #64796c;
20
+ --n-accent: #9bd0a7;
21
+ --n-accent-soft: #315340;
22
+ --n-sand: #d5a855;
23
+ --n-soil: #b58257;
24
+ --n-warning: #f0c77b;
25
+ }
26
+
27
+ .ev-root {
28
+ max-width: 1120px;
29
+ margin: 0 auto;
30
+ color: var(--n-ink);
31
+ font: 1rem/1.4 Georgia, serif;
32
+ }
33
+
34
+ .ev-board {
35
+ overflow: hidden;
36
+ border: 1px solid var(--n-line);
37
+ border-radius: 1.4rem;
38
+ padding: clamp(0.85rem, 2vw, 1.35rem);
39
+ background: var(--n-paper);
40
+ }
41
+
42
+ .ev-intro {
43
+ display: flex;
44
+ align-items: start;
45
+ gap: 1rem;
46
+ margin-bottom: 0.85rem;
47
+ }
48
+
49
+ .ev-intro-mark {
50
+ display: grid;
51
+ flex: 0 0 3rem;
52
+ place-items: center;
53
+ height: 3rem;
54
+ border-radius: 50%;
55
+ background: var(--n-accent);
56
+ color: var(--n-paper);
57
+ font-size: 1.35rem;
58
+ }
59
+
60
+ .ev-intro p {
61
+ max-width: 62ch;
62
+ margin: 0;
63
+ color: var(--n-muted);
64
+ line-height: 1.55;
65
+ }
66
+
67
+ .ev-scene-panel {
68
+ display: grid;
69
+ gap: 0.9rem;
70
+ border-bottom: 1px solid var(--n-line);
71
+ padding-bottom: 0.95rem;
72
+ }
73
+
74
+ .ev-kicker {
75
+ color: var(--n-accent);
76
+ font: 700 0.72rem/1.2 system-ui, sans-serif;
77
+ letter-spacing: 0.12em;
78
+ text-transform: uppercase;
79
+ }
80
+
81
+ .ev-result-heading {
82
+ display: flex;
83
+ align-items: center;
84
+ justify-content: space-between;
85
+ gap: 0.8rem;
86
+ }
87
+
88
+ .ev-status {
89
+ border-radius: 999px;
90
+ padding: 0.38rem 0.6rem;
91
+ background: var(--n-accent);
92
+ color: var(--n-paper);
93
+ font: 700 0.68rem system-ui, sans-serif;
94
+ }
95
+
96
+ .ev-status[data-ev-status-level="high"] {
97
+ background: var(--n-warning);
98
+ }
99
+
100
+ .ev-scene {
101
+ min-height: 260px;
102
+ border: 1px solid var(--n-line);
103
+ border-radius: 0.9rem;
104
+ background: var(--n-surface);
105
+ }
106
+
107
+ .ev-scene-svg {
108
+ display: block;
109
+ width: 100%;
110
+ height: auto;
111
+ }
112
+
113
+ .ev-sky {
114
+ fill: var(--n-accent-soft);
115
+ }
116
+
117
+ .ev-ground {
118
+ fill: var(--n-soil);
119
+ opacity: 0.8;
120
+ }
121
+
122
+ .ev-contour,
123
+ .ev-strata {
124
+ fill: none;
125
+ stroke: var(--n-accent);
126
+ stroke-width: 2;
127
+ opacity: 0.45;
128
+ }
129
+
130
+ .ev-strata {
131
+ stroke: var(--n-sand);
132
+ stroke-width: 3;
133
+ opacity: 0.35;
134
+ }
135
+
136
+ .ev-cut {
137
+ stroke: var(--n-ink);
138
+ stroke-width: 2;
139
+ }
140
+
141
+ .ev-cut-line {
142
+ fill: none;
143
+ stroke: var(--n-ink);
144
+ stroke-dasharray: 5 5;
145
+ stroke-width: 2;
146
+ }
147
+
148
+ .ev-bank-fill {
149
+ fill: var(--n-sand);
150
+ opacity: 0.9;
151
+ }
152
+
153
+ .ev-loose-fill {
154
+ fill: var(--n-sand);
155
+ stroke: var(--n-ink);
156
+ stroke-width: 2;
157
+ }
158
+
159
+ .ev-arrow {
160
+ fill: none;
161
+ stroke: var(--n-accent);
162
+ stroke-width: 3;
163
+ }
164
+
165
+ .ev-measure {
166
+ fill: none;
167
+ stroke: var(--n-ink);
168
+ stroke-width: 2;
169
+ }
170
+
171
+ .ev-label {
172
+ fill: var(--n-ink);
173
+ font: 700 13px system-ui, sans-serif;
174
+ }
175
+
176
+ .ev-sun {
177
+ fill: var(--n-sand);
178
+ }
179
+
180
+ .ev-grass {
181
+ fill: none;
182
+ stroke: var(--n-accent-soft);
183
+ stroke-width: 3;
184
+ }
185
+
186
+ .ev-metrics {
187
+ display: grid;
188
+ grid-template-columns: 1fr 1.2fr;
189
+ gap: 0.6rem;
190
+ }
191
+
192
+ .ev-metric {
193
+ border: 1px solid var(--n-line);
194
+ border-radius: 0.75rem;
195
+ padding: 0.8rem;
196
+ }
197
+
198
+ .ev-metric-main {
199
+ border-color: var(--n-accent);
200
+ background: var(--n-accent);
201
+ color: var(--n-paper);
202
+ }
203
+
204
+ .ev-metric-label {
205
+ display: block;
206
+ font: 700 0.72rem system-ui, sans-serif;
207
+ }
208
+
209
+ .ev-metric-value {
210
+ display: block;
211
+ overflow: hidden;
212
+ margin-top: 0.4rem;
213
+ white-space: nowrap;
214
+ font: 700 clamp(1.2rem, 3.5vw, 2.2rem)/1 system-ui, sans-serif;
215
+ text-overflow: ellipsis;
216
+ }
217
+
218
+ .ev-unit-suffix {
219
+ font-size: 0.55em;
220
+ vertical-align: super;
221
+ }
222
+
223
+ .ev-metric-help {
224
+ display: block;
225
+ margin-top: 0.35rem;
226
+ color: var(--n-muted);
227
+ font: 0.72rem/1.4 system-ui, sans-serif;
228
+ }
229
+
230
+ .ev-metric-main .ev-metric-help {
231
+ color: var(--n-paper);
232
+ opacity: 0.85;
233
+ }
234
+
235
+ .ev-expansion {
236
+ display: flex;
237
+ justify-content: space-between;
238
+ gap: 1rem;
239
+ border-top: 1px solid var(--n-line);
240
+ padding-top: 0.75rem;
241
+ font: 700 0.8rem system-ui, sans-serif;
242
+ }
243
+
244
+ .ev-expansion strong {
245
+ color: var(--n-accent);
246
+ }
247
+
248
+ .ev-workbench {
249
+ display: grid;
250
+ grid-template-columns: 1fr;
251
+ gap: 1.25rem;
252
+ padding-top: 0.95rem;
253
+ }
254
+
255
+ .ev-control-panel {
256
+ display: grid;
257
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
258
+ align-content: start;
259
+ gap: 0.8rem 1.25rem;
260
+ }
261
+
262
+ .ev-control-heading {
263
+ grid-column: 1 / -1;
264
+ display: flex;
265
+ align-items: center;
266
+ justify-content: space-between;
267
+ gap: 1rem;
268
+ }
269
+
270
+ .ev-segmented {
271
+ display: inline-flex;
272
+ gap: 0.2rem;
273
+ border: 1px solid var(--n-line);
274
+ border-radius: 999px;
275
+ padding: 0.2rem;
276
+ background: var(--n-surface);
277
+ }
278
+
279
+ .ev-segmented button,
280
+ .ev-soil-button {
281
+ cursor: pointer;
282
+ border: 1px solid transparent;
283
+ color: var(--n-muted);
284
+ background: transparent;
285
+ font: 700 0.75rem system-ui, sans-serif;
286
+ }
287
+
288
+ .ev-segmented button {
289
+ border-radius: 999px;
290
+ padding: 0.48rem 0.7rem;
291
+ }
292
+
293
+ .ev-segmented button.ev-active,
294
+ .ev-soil-button.ev-active {
295
+ background: var(--n-accent);
296
+ color: var(--n-paper);
297
+ }
298
+
299
+ .ev-soil-button.ev-active .ev-soil-name,
300
+ .ev-soil-button.ev-active .ev-soil-band {
301
+ color: var(--n-paper);
302
+ }
303
+
304
+ .ev-soil-button:hover {
305
+ border-color: var(--n-accent);
306
+ color: var(--n-accent);
307
+ }
308
+
309
+ .ev-fieldset {
310
+ display: grid;
311
+ gap: 0.6rem;
312
+ margin: 0;
313
+ border: 0;
314
+ padding: 0;
315
+ }
316
+
317
+ .ev-fieldset:first-of-type {
318
+ grid-column: 1;
319
+ }
320
+
321
+ .ev-soil-fieldset {
322
+ grid-column: 2;
323
+ }
324
+
325
+ .ev-fieldset legend,
326
+ .ev-field label {
327
+ font: 700 0.8rem system-ui, sans-serif;
328
+ }
329
+
330
+ .ev-dimensions {
331
+ display: grid;
332
+ grid-template-columns: repeat(3, 1fr);
333
+ gap: 0.55rem;
334
+ }
335
+
336
+ .ev-field {
337
+ display: grid;
338
+ gap: 0.3rem;
339
+ color: var(--n-muted);
340
+ }
341
+
342
+ .ev-input {
343
+ display: flex;
344
+ align-items: center;
345
+ border: 1px solid var(--n-line);
346
+ border-radius: 0.55rem;
347
+ background: var(--n-surface);
348
+ }
349
+
350
+ .ev-input input {
351
+ min-width: 0;
352
+ width: 100%;
353
+ border: 0;
354
+ outline: 0;
355
+ padding: 0.65rem;
356
+ color: var(--n-ink);
357
+ background: transparent;
358
+ font: 700 1rem system-ui, sans-serif;
359
+ }
360
+
361
+ .ev-unit {
362
+ padding-right: 0.65rem;
363
+ color: var(--n-muted);
364
+ font: 700 0.72rem system-ui, sans-serif;
365
+ }
366
+
367
+ .ev-soil-grid {
368
+ display: grid;
369
+ grid-template-columns: repeat(2, 1fr);
370
+ gap: 0.45rem;
371
+ }
372
+
373
+ .ev-soil-button {
374
+ padding: 0.6rem;
375
+ border-color: var(--n-line);
376
+ border-radius: 0.65rem;
377
+ text-align: left;
378
+ }
379
+
380
+ .ev-soil-name {
381
+ display: block;
382
+ color: var(--n-ink);
383
+ }
384
+
385
+ .ev-soil-band {
386
+ display: block;
387
+ margin-top: 0.2rem;
388
+ color: var(--n-muted);
389
+ font: 0.7rem system-ui, sans-serif;
390
+ }
391
+
392
+ .ev-notes {
393
+ display: grid;
394
+ grid-template-columns: 1fr 1fr;
395
+ gap: 0.85rem;
396
+ border-top: 1px solid var(--n-line);
397
+ padding-top: 0.8rem;
398
+ }
399
+
400
+ .ev-interpretation,
401
+ .ev-warning {
402
+ margin: 0;
403
+ color: var(--n-muted);
404
+ font: 0.78rem/1.5 system-ui, sans-serif;
405
+ }
406
+
407
+ .ev-warning {
408
+ border-left: 3px solid var(--n-warning);
409
+ padding: 0.65rem 0.75rem;
410
+ color: var(--n-warning);
411
+ }
412
+
413
+ button:focus-visible,
414
+ input:focus-visible {
415
+ outline: 3px solid var(--n-sand);
416
+ outline-offset: 2px;
417
+ }
418
+
419
+ @media (max-width: 820px) {
420
+ .ev-control-panel,
421
+ .ev-notes {
422
+ grid-template-columns: 1fr;
423
+ }
424
+
425
+ .ev-control-heading,
426
+ .ev-fieldset:first-of-type,
427
+ .ev-soil-fieldset {
428
+ grid-column: 1;
429
+ }
430
+ }
431
+
432
+ @media (max-width: 480px) {
433
+ .ev-control-heading {
434
+ align-items: start;
435
+ flex-direction: column;
436
+ }
437
+
438
+ .ev-dimensions,
439
+ .ev-metrics {
440
+ grid-template-columns: 1fr;
441
+ }
442
+ }