@jjlmoya/utils-textiles 1.24.0 → 1.26.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 (54) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -0
  3. package/src/entries.ts +4 -1
  4. package/src/index.ts +1 -0
  5. package/src/tests/legacy_logic_reference.test.ts +65 -0
  6. package/src/tests/qa-test-helpers.ts +32 -0
  7. package/src/tests/qa_bibliography_links.test.ts +49 -0
  8. package/src/tests/qa_claim_evidence.test.ts +69 -0
  9. package/src/tests/qa_logic_reference_coverage.test.ts +46 -0
  10. package/src/tests/qa_runtime_i18n.test.ts +99 -0
  11. package/src/tests/registry_contract.test.ts +67 -0
  12. package/src/tests/tool_validation.test.ts +2 -2
  13. package/src/tool/burnTest/component.astro +2 -2
  14. package/src/tool/embroideryStitchPricingEstimator/component.astro +2 -2
  15. package/src/tool/embroideryStitchPricingEstimator/dom-views.ts +12 -5
  16. package/src/tool/fabricProjectCalculator/component.astro +7 -5
  17. package/src/tool/fabricTruth/component.astro +2 -2
  18. package/src/tool/knittingGauge/component.astro +1 -2
  19. package/src/tool/laundryGuide/bibliography.ts +1 -1
  20. package/src/tool/quiltBindingCalculator/bibliography.astro +6 -0
  21. package/src/tool/quiltBindingCalculator/bibliography.ts +12 -0
  22. package/src/tool/quiltBindingCalculator/component.astro +110 -0
  23. package/src/tool/quiltBindingCalculator/controller.ts +257 -0
  24. package/src/tool/quiltBindingCalculator/dom-views.ts +56 -0
  25. package/src/tool/quiltBindingCalculator/entry.ts +29 -0
  26. package/src/tool/quiltBindingCalculator/evaluator.ts +13 -0
  27. package/src/tool/quiltBindingCalculator/i18n/de.ts +3 -0
  28. package/src/tool/quiltBindingCalculator/i18n/en.ts +200 -0
  29. package/src/tool/quiltBindingCalculator/i18n/es.ts +3 -0
  30. package/src/tool/quiltBindingCalculator/i18n/fr.ts +3 -0
  31. package/src/tool/quiltBindingCalculator/i18n/id.ts +3 -0
  32. package/src/tool/quiltBindingCalculator/i18n/it.ts +3 -0
  33. package/src/tool/quiltBindingCalculator/i18n/ja.ts +3 -0
  34. package/src/tool/quiltBindingCalculator/i18n/ko.ts +3 -0
  35. package/src/tool/quiltBindingCalculator/i18n/nl.ts +3 -0
  36. package/src/tool/quiltBindingCalculator/i18n/pl.ts +3 -0
  37. package/src/tool/quiltBindingCalculator/i18n/pt.ts +3 -0
  38. package/src/tool/quiltBindingCalculator/i18n/ru.ts +3 -0
  39. package/src/tool/quiltBindingCalculator/i18n/shared.ts +514 -0
  40. package/src/tool/quiltBindingCalculator/i18n/sv.ts +3 -0
  41. package/src/tool/quiltBindingCalculator/i18n/tr.ts +3 -0
  42. package/src/tool/quiltBindingCalculator/i18n/zh.ts +3 -0
  43. package/src/tool/quiltBindingCalculator/index.ts +11 -0
  44. package/src/tool/quiltBindingCalculator/logic.test.ts +70 -0
  45. package/src/tool/quiltBindingCalculator/logic.ts +93 -0
  46. package/src/tool/quiltBindingCalculator/quilt-binding-length-and-strip-calculator.css +474 -0
  47. package/src/tool/quiltBindingCalculator/seo.astro +15 -0
  48. package/src/tool/quiltBindingCalculator/storage.ts +26 -0
  49. package/src/tool/quiltBindingCalculator/ui.ts +51 -0
  50. package/src/tool/stainChemistry/bibliography.ts +1 -1
  51. package/src/tool/stainChemistry/component.astro +4 -3
  52. package/src/tool/yarnCalculator/component.astro +5 -6
  53. package/src/tool/yarnCalculator/validation.ts +17 -0
  54. package/src/tools.ts +2 -2
@@ -2,5 +2,5 @@ import type { BibliographyEntry } from '../../types';
2
2
 
3
3
  export const bibliography: BibliographyEntry[] = [
4
4
  { name: 'ISO 3758 - Textile Care Symbols', url: 'https://www.iso.org/standard/60465.html' },
5
- { name: 'FTC - Textile Care Labels', url: 'https://www.ftc.gov/' },
5
+ { name: 'FTC - Textile Care Labeling Rule', url: 'https://www.ftc.gov/legal-library/browse/rules/care-labeling-textile-wearing-apparel-certain-piece-goods' },
6
6
  ];
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliography } from './bibliography';
4
+ ---
5
+
6
+ <SharedBibliography links={bibliography} />
@@ -0,0 +1,12 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'BERNINA WeAllSew, Simple Binding Basics',
6
+ url: 'https://weallsew.com/simple-binding-basics/',
7
+ },
8
+ {
9
+ name: 'Brother SewingCraft France, Finir un bord de quilt',
10
+ url: 'https://sewingcraft.brother.eu/fr-ch/blog/quilt-club/2024/how-to-bind-a-quilt',
11
+ },
12
+ ];
@@ -0,0 +1,110 @@
1
+ ---
2
+ import { renderIdleScene } from './dom-views';
3
+ import type { QuiltBindingCalculatorUI } from './ui';
4
+ import './quilt-binding-length-and-strip-calculator.css';
5
+
6
+ const { ui } = Astro.props as { ui: QuiltBindingCalculatorUI };
7
+ const serializedUI = JSON.stringify(ui).replace(/</g, '\\u003c');
8
+ const metric = { width: '90', length: '120', stripWidth: '6.5', fabricWidth: '110', seam: '0.6' };
9
+ ---
10
+
11
+ <div class="qb-tool" data-qb-tool data-ui={serializedUI}>
12
+ <div class="qb-layout">
13
+ <section class="qb-stage" aria-labelledby="qb-stage-label">
14
+ <div class="qb-stage-head">
15
+ <span id="qb-stage-label" class="qb-stage-label">{ui.stageLabel}</span>
16
+ <span class="qb-unit-readout" data-output="unit">{ui.metricLabel}</span>
17
+ </div>
18
+ <div class="qb-scene" data-scene set:html={renderIdleScene()}></div>
19
+ <p class="qb-scene-note">{ui.canvasAlt}</p>
20
+ </section>
21
+
22
+ <section class="qb-workbench" aria-labelledby="qb-controls-label">
23
+ <div class="qb-workbench-head">
24
+ <h2 id="qb-controls-label">{ui.controlsLabel}</h2>
25
+ <div class="qb-unit-toggle" role="group" aria-label={ui.unitLabel}>
26
+ <button type="button" class="qb-unit-button is-active" data-unit="metric">{ui.metricLabel}</button>
27
+ <button type="button" class="qb-unit-button" data-unit="imperial">{ui.imperialLabel}</button>
28
+ </div>
29
+ </div>
30
+
31
+ <fieldset class="qb-fieldset">
32
+ <legend>{ui.dimensionsLabel}</legend>
33
+ <div class="qb-fields">
34
+ <label class="qb-field"><span>{ui.widthLabel}</span><input data-field="quiltWidth" type="number" min="0.1" step="0.1" value={metric.width} /><small>{ui.widthHint}</small></label>
35
+ <label class="qb-field"><span>{ui.lengthLabel}</span><input data-field="quiltLength" type="number" min="0.1" step="0.1" value={metric.length} /><small>{ui.lengthHint}</small></label>
36
+ <label class="qb-field"><span>{ui.cornersLabel}</span><input data-field="cornerCount" type="number" min="0" max="100" step="1" value="4" /><small>{ui.cornersHint}</small></label>
37
+ </div>
38
+ </fieldset>
39
+
40
+ <fieldset class="qb-fieldset">
41
+ <legend>{ui.fabricLabel}</legend>
42
+ <div class="qb-fields">
43
+ <label class="qb-field"><span>{ui.stripWidthLabel}</span><input data-field="stripWidth" type="number" min="0.1" step="0.1" value={metric.stripWidth} /><small>{ui.stripWidthHint}</small></label>
44
+ <label class="qb-field"><span>{ui.fabricWidthLabel}</span><input data-field="fabricWidth" type="number" min="0.1" step="0.1" value={metric.fabricWidth} /><small>{ui.fabricWidthHint}</small></label>
45
+ <label class="qb-field"><span>{ui.seamAllowanceLabel}</span><input data-field="seamAllowance" type="number" min="0.1" step="0.1" value={metric.seam} /><small>{ui.seamAllowanceHint}</small></label>
46
+ </div>
47
+ </fieldset>
48
+
49
+ <div class="qb-choice-row">
50
+ <div class="qb-choice" data-select="joinMethod">
51
+ <span class="qb-choice-label">{ui.methodLabel}</span>
52
+ <button type="button" data-select-trigger aria-haspopup="listbox" aria-expanded="false">{ui.diagonalLabel}</button>
53
+ <div class="qb-options" data-select-options hidden role="listbox">
54
+ <button type="button" data-value="diagonal" class="is-active" role="option">{ui.diagonalLabel}</button>
55
+ <button type="button" data-value="straight" role="option">{ui.straightLabel}</button>
56
+ </div>
57
+ <input data-field="joinMethod" type="hidden" value="diagonal" />
58
+ </div>
59
+ <div class="qb-choice" data-select="safetyPercent">
60
+ <span class="qb-choice-label">{ui.safetyLabel}</span>
61
+ <button type="button" data-select-trigger aria-haspopup="listbox" aria-expanded="false">{ui.safetyTen}</button>
62
+ <div class="qb-options" data-select-options hidden role="listbox">
63
+ <button type="button" data-value="0.05" role="option">{ui.safetyFive}</button>
64
+ <button type="button" data-value="0.1" class="is-active" role="option">{ui.safetyTen}</button>
65
+ <button type="button" data-value="0.15" role="option">{ui.safetyFifteen}</button>
66
+ </div>
67
+ <input data-field="safetyPercent" type="hidden" value="0.1" />
68
+ </div>
69
+ </div>
70
+ </section>
71
+ </div>
72
+
73
+ <section class="qb-result" aria-labelledby="qb-result-label" aria-live="polite">
74
+ <div class="qb-result-head">
75
+ <h2 id="qb-result-label">{ui.resultLabel}</h2>
76
+ <span class="qb-status" data-status><span data-output="status">{ui.emptyResult}</span></span>
77
+ </div>
78
+ <div class="qb-result-body is-empty" data-result-body>
79
+ <p class="qb-empty" data-output="empty">{ui.emptyResult}</p>
80
+ <div class="qb-metrics">
81
+ <div><span>{ui.perimeterLabel}</span><strong data-output="perimeter">-</strong></div>
82
+ <div><span>{ui.requiredLabel}</span><strong data-output="required">-</strong></div>
83
+ <div><span>{ui.stripsLabel}</span><strong data-output="strips">-</strong></div>
84
+ <div><span>{ui.cutSizeLabel}</span><strong data-output="cutSize">-</strong></div>
85
+ <div><span>{ui.joinedLengthLabel}</span><strong data-output="joined">-</strong></div>
86
+ <div><span>{ui.surplusLabel}</span><strong data-output="surplus">-</strong></div>
87
+ <div><span>{ui.joinsLabel}</span><strong data-output="joins">-</strong></div>
88
+ </div>
89
+ <p class="qb-plan"><span>{ui.cuttingPlanLabel}</span><strong data-output="plan">-</strong></p>
90
+ <p class="qb-warning" data-output="warning"></p>
91
+ </div>
92
+ <div class="qb-result-foot">
93
+ <p class="qb-feedback" data-output="feedback"></p>
94
+ <div class="qb-actions"><button type="button" class="qb-secondary" data-reset>{ui.reset}</button><button type="button" class="qb-primary" data-copy>{ui.copySummary}</button></div>
95
+ </div>
96
+ </section>
97
+
98
+ <details class="qb-notes">
99
+ <summary>{ui.formulaTitle}</summary>
100
+ <p>{ui.formulaText}</p>
101
+ <p><strong>{ui.noteTitle}</strong> {ui.noteText}</p>
102
+ </details>
103
+ </div>
104
+
105
+ <script>
106
+ import { createQuiltBindingController } from './controller';
107
+
108
+ const root = document.querySelector<HTMLElement>('[data-qb-tool]');
109
+ if (root) createQuiltBindingController(root);
110
+ </script>
@@ -0,0 +1,257 @@
1
+ import {
2
+ calculateBinding,
3
+ convertLengthFromCm,
4
+ convertLengthToCm,
5
+ type BindingInputs,
6
+ type JoinMethod,
7
+ type SafetyPercent,
8
+ type UnitSystem,
9
+ } from './logic';
10
+ import { evaluateBinding } from './evaluator';
11
+ import { formatLength, formatWhole, renderBindingScene, renderIdleScene, resultSummary } from './dom-views';
12
+ import { loadBindingDraft, saveBindingDraft, type SavedBindingDraft } from './storage';
13
+ import type { QuiltBindingCalculatorUI } from './ui';
14
+
15
+ export const DEFAULT_INPUTS: BindingInputs = {
16
+ quiltWidthCm: 90,
17
+ quiltLengthCm: 120,
18
+ cornerCount: 4,
19
+ stripWidthCm: 6.5,
20
+ fabricWidthCm: 110,
21
+ seamAllowanceCm: 0.6,
22
+ joinMethod: 'diagonal',
23
+ safetyPercent: 0.1,
24
+ };
25
+
26
+ interface ControllerState {
27
+ unit: UnitSystem;
28
+ inputs: BindingInputs;
29
+ ui: QuiltBindingCalculatorUI;
30
+ }
31
+
32
+ function readUi(root: HTMLElement): QuiltBindingCalculatorUI {
33
+ return JSON.parse(root.dataset.ui ?? '{}') as QuiltBindingCalculatorUI;
34
+ }
35
+
36
+ function readNumber(root: HTMLElement, field: string): number {
37
+ const input = root.querySelector<HTMLInputElement>(`[data-field="${field}"]`);
38
+ return Number(input?.value ?? '0');
39
+ }
40
+
41
+ function readInputs(root: HTMLElement, unit: UnitSystem): BindingInputs {
42
+ return {
43
+ quiltWidthCm: convertLengthToCm(readNumber(root, 'quiltWidth'), unit),
44
+ quiltLengthCm: convertLengthToCm(readNumber(root, 'quiltLength'), unit),
45
+ cornerCount: Math.round(readNumber(root, 'cornerCount')),
46
+ stripWidthCm: convertLengthToCm(readNumber(root, 'stripWidth'), unit),
47
+ fabricWidthCm: convertLengthToCm(readNumber(root, 'fabricWidth'), unit),
48
+ seamAllowanceCm: convertLengthToCm(readNumber(root, 'seamAllowance'), unit),
49
+ joinMethod: (root.querySelector<HTMLInputElement>('[data-field="joinMethod"]')?.value ?? 'diagonal') as JoinMethod,
50
+ safetyPercent: Number(root.querySelector<HTMLInputElement>('[data-field="safetyPercent"]')?.value ?? '0.1') as SafetyPercent,
51
+ };
52
+ }
53
+
54
+ function inputValue(valueCm: number, unit: UnitSystem): string {
55
+ const value = convertLengthFromCm(valueCm, unit);
56
+ return value.toFixed(unit === 'imperial' ? 2 : 1).replace(/\.0+$|(?<=\.[0-9])0+$/, '');
57
+ }
58
+
59
+ function writeLength(root: HTMLElement, field: string, valueCm: number, unit: UnitSystem): void {
60
+ const input = root.querySelector<HTMLInputElement>(`[data-field="${field}"]`);
61
+ if (input) input.value = inputValue(valueCm, unit);
62
+ }
63
+
64
+ function writeInputs(root: HTMLElement, inputs: BindingInputs, unit: UnitSystem): void {
65
+ writeLength(root, 'quiltWidth', inputs.quiltWidthCm, unit);
66
+ writeLength(root, 'quiltLength', inputs.quiltLengthCm, unit);
67
+ writeLength(root, 'stripWidth', inputs.stripWidthCm, unit);
68
+ writeLength(root, 'fabricWidth', inputs.fabricWidthCm, unit);
69
+ writeLength(root, 'seamAllowance', inputs.seamAllowanceCm, unit);
70
+ const corners = root.querySelector<HTMLInputElement>('[data-field="cornerCount"]');
71
+ if (corners) corners.value = String(inputs.cornerCount);
72
+ setSelect(root, 'joinMethod', inputs.joinMethod);
73
+ setSelect(root, 'safetyPercent', String(inputs.safetyPercent));
74
+ }
75
+
76
+ function setSelect(root: HTMLElement, field: string, value: string): void {
77
+ const hidden = root.querySelector<HTMLInputElement>(`[data-field="${field}"]`);
78
+ const option = root.querySelector<HTMLElement>(`[data-select="${field}"] [data-value="${value}"]`);
79
+ const trigger = root.querySelector<HTMLButtonElement>(`[data-select="${field}"] [data-select-trigger]`);
80
+ if (hidden) hidden.value = value;
81
+ if (trigger && option) {
82
+ trigger.textContent = option.textContent;
83
+ trigger.setAttribute('aria-expanded', 'false');
84
+ }
85
+ root.querySelectorAll(`[data-select="${field}"] [data-value]`).forEach((item) => {
86
+ item.classList.toggle('is-active', item.getAttribute('data-value') === value);
87
+ });
88
+ }
89
+
90
+ function setOutput(root: HTMLElement, field: string, value: string): void {
91
+ const output = root.querySelector<HTMLElement>(`[data-output="${field}"]`);
92
+ if (output) output.textContent = value;
93
+ }
94
+
95
+ function unitSuffix(unit: UnitSystem): string {
96
+ return unit === 'imperial' ? 'in' : 'cm';
97
+ }
98
+
99
+ function warningText(ui: QuiltBindingCalculatorUI, warning: ReturnType<typeof evaluateBinding>['warning']): string {
100
+ if (warning === 'large-waste') return ui.warningLargeWaste;
101
+ if (warning === 'small-surplus') return ui.warningSmallSurplus;
102
+ return '';
103
+ }
104
+
105
+ function renderMetrics(root: HTMLElement, state: ControllerState, result: Extract<ReturnType<typeof calculateBinding>, { valid: true }>): void {
106
+ const suffix = unitSuffix(state.unit);
107
+ setOutput(root, 'perimeter', `${formatLength(result.perimeterCm, state.unit)} ${suffix}`);
108
+ setOutput(root, 'required', `${formatLength(result.requiredBindingCm, state.unit)} ${suffix}`);
109
+ setOutput(root, 'strips', formatWhole(result.strips));
110
+ setOutput(root, 'cutSize', `${formatLength(state.inputs.stripWidthCm, state.unit)} x ${formatLength(state.inputs.fabricWidthCm, state.unit)}`);
111
+ setOutput(root, 'joined', `${formatLength(result.joinedLengthCm, state.unit)} ${suffix}`);
112
+ setOutput(root, 'surplus', `${formatLength(result.surplusCm, state.unit)} ${suffix}`);
113
+ setOutput(root, 'joins', formatWhole(result.joinCount));
114
+ setOutput(root, 'plan', resultSummary(state.inputs, result, state.unit));
115
+ }
116
+
117
+ function renderStatus(root: HTMLElement, evaluation: ReturnType<typeof evaluateBinding>): void {
118
+ root.querySelector('[data-status]')?.classList.remove('is-error');
119
+ root.querySelector('[data-status]')?.classList.toggle('is-ready', evaluation.status === 'ready');
120
+ root.querySelector('[data-status]')?.classList.toggle('is-review', evaluation.status === 'review');
121
+ root.querySelector('[data-result-body]')?.classList.remove('is-empty');
122
+ }
123
+
124
+ function renderInvalid(root: HTMLElement, ui: QuiltBindingCalculatorUI): void {
125
+ setOutput(root, 'status', ui.invalidMessage);
126
+ setOutput(root, 'empty', ui.invalidMessage);
127
+ ['perimeter', 'required', 'strips', 'cutSize', 'joined', 'surplus', 'joins', 'plan', 'warning'].forEach((field) => setOutput(root, field, '-'));
128
+ const scene = root.querySelector<HTMLElement>('[data-scene]');
129
+ if (scene) scene.innerHTML = renderIdleScene();
130
+ root.querySelector('[data-status]')?.classList.add('is-error');
131
+ root.querySelector('[data-status]')?.classList.remove('is-ready', 'is-review');
132
+ root.querySelector('[data-result-body]')?.classList.add('is-empty');
133
+ }
134
+
135
+ function renderValid(root: HTMLElement, state: ControllerState, result: Extract<ReturnType<typeof calculateBinding>, { valid: true }>): void {
136
+ const evaluation = evaluateBinding(result);
137
+ setOutput(root, 'status', evaluation.status === 'ready' ? state.ui.readyBadge : state.ui.reviewBadge);
138
+ renderMetrics(root, state, result);
139
+ renderStatus(root, evaluation);
140
+ setOutput(root, 'warning', warningText(state.ui, evaluation.warning));
141
+ const scene = root.querySelector<HTMLElement>('[data-scene]');
142
+ if (scene) scene.innerHTML = renderBindingScene(state.inputs, result, state.unit);
143
+ }
144
+
145
+ function render(root: HTMLElement, state: ControllerState): void {
146
+ const result = calculateBinding(state.inputs);
147
+ setOutput(root, 'unit', state.unit === 'imperial' ? state.ui.imperialLabel : state.ui.metricLabel);
148
+ if (!result.valid) {
149
+ renderInvalid(root, state.ui);
150
+ return;
151
+ }
152
+ renderValid(root, state, result);
153
+ saveBindingDraft({ unit: state.unit, inputs: state.inputs });
154
+ }
155
+
156
+ function closeSelects(root: HTMLElement): void {
157
+ root.querySelectorAll<HTMLElement>('[data-select].is-open').forEach((select) => {
158
+ select.classList.remove('is-open');
159
+ const options = select.querySelector<HTMLElement>('[data-select-options]');
160
+ if (options) options.hidden = true;
161
+ select.querySelector('[data-select-trigger]')?.setAttribute('aria-expanded', 'false');
162
+ });
163
+ }
164
+
165
+ function toggleSelect(select: HTMLElement): void {
166
+ const options = select.querySelector<HTMLElement>('[data-select-options]');
167
+ if (!options) return;
168
+ const open = !select.classList.contains('is-open');
169
+ select.classList.toggle('is-open', open);
170
+ options.hidden = !open;
171
+ select.querySelector('[data-select-trigger]')?.setAttribute('aria-expanded', String(open));
172
+ }
173
+
174
+ function handleSelectClick(root: HTMLElement, target: HTMLElement, state: ControllerState): boolean {
175
+ const select = target.closest<HTMLElement>('[data-select]');
176
+ if (!select) return false;
177
+ if (target.closest('[data-select-trigger]')) {
178
+ closeSelects(root);
179
+ toggleSelect(select);
180
+ return true;
181
+ }
182
+ const option = target.closest<HTMLElement>('[data-value]');
183
+ if (!option) return true;
184
+ const field = select.dataset.select ?? '';
185
+ setSelect(root, field, option.dataset.value ?? '');
186
+ state.inputs = readInputs(root, state.unit);
187
+ render(root, state);
188
+ closeSelects(root);
189
+ return true;
190
+ }
191
+
192
+ function switchUnit(root: HTMLElement, state: ControllerState, unit: UnitSystem): void {
193
+ if (unit === state.unit) return;
194
+ state.inputs = readInputs(root, state.unit);
195
+ state.unit = unit;
196
+ writeInputs(root, state.inputs, state.unit);
197
+ switchUnitButtons(root, state.unit);
198
+ render(root, state);
199
+ }
200
+
201
+ async function copySummary(root: HTMLElement, state: ControllerState): Promise<void> {
202
+ const result = calculateBinding(state.inputs);
203
+ if (!result.valid) return;
204
+ const summary = resultSummary(state.inputs, result, state.unit);
205
+ await navigator.clipboard?.writeText(summary);
206
+ setOutput(root, 'feedback', state.ui.copied);
207
+ }
208
+
209
+ function switchUnitButtons(root: HTMLElement, unit: UnitSystem): void {
210
+ root.querySelectorAll<HTMLElement>('[data-unit]').forEach((button) => {
211
+ button.classList.toggle('is-active', button.dataset.unit === unit);
212
+ });
213
+ }
214
+
215
+ function handleClick(root: HTMLElement, event: MouseEvent, state: ControllerState): void {
216
+ const target = event.target as HTMLElement;
217
+ if (handleSelectClick(root, target, state)) return;
218
+ const unit = target.closest<HTMLElement>('[data-unit]')?.dataset.unit as UnitSystem | undefined;
219
+ if (unit) {
220
+ switchUnit(root, state, unit);
221
+ return;
222
+ }
223
+ if (target.closest('[data-reset]')) {
224
+ state.unit = 'metric';
225
+ state.inputs = DEFAULT_INPUTS;
226
+ writeInputs(root, state.inputs, state.unit);
227
+ switchUnitButtons(root, state.unit);
228
+ render(root, state);
229
+ return;
230
+ }
231
+ if (target.closest('[data-copy]')) void copySummary(root, state);
232
+ }
233
+
234
+ function initialiseState(root: HTMLElement): ControllerState {
235
+ const saved = loadBindingDraft() as SavedBindingDraft | null;
236
+ const state: ControllerState = {
237
+ unit: saved?.unit ?? 'metric',
238
+ inputs: saved?.inputs ?? DEFAULT_INPUTS,
239
+ ui: readUi(root),
240
+ };
241
+ writeInputs(root, state.inputs, state.unit);
242
+ switchUnitButtons(root, state.unit);
243
+ return state;
244
+ }
245
+
246
+ export function createQuiltBindingController(root: HTMLElement): void {
247
+ const state = initialiseState(root);
248
+ root.addEventListener('input', () => {
249
+ state.inputs = readInputs(root, state.unit);
250
+ render(root, state);
251
+ });
252
+ root.addEventListener('click', (event) => handleClick(root, event, state));
253
+ document.addEventListener('click', (event) => {
254
+ if (!root.contains(event.target as Node)) closeSelects(root);
255
+ });
256
+ render(root, state);
257
+ }
@@ -0,0 +1,56 @@
1
+ import { convertLengthFromCm, type BindingInputs, type BindingResult, type UnitSystem } from './logic';
2
+
3
+ export function formatLength(valueCm: number, unit: UnitSystem, locale = 'en-US'): string {
4
+ const value = convertLengthFromCm(valueCm, unit);
5
+ return new Intl.NumberFormat(locale, { maximumFractionDigits: unit === 'imperial' ? 2 : 1 }).format(value);
6
+ }
7
+
8
+ export function formatWhole(value: number, locale = 'en-US'): string {
9
+ return new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(value);
10
+ }
11
+
12
+ function sceneScale(inputs: BindingInputs): { width: number; height: number } {
13
+ const ratio = inputs.quiltWidthCm / inputs.quiltLengthCm;
14
+ const maxWidth = 212;
15
+ const maxHeight = 126;
16
+ if (ratio >= maxWidth / maxHeight) return { width: maxWidth, height: maxWidth / ratio };
17
+ return { width: maxHeight * ratio, height: maxHeight };
18
+ }
19
+
20
+ function stripMarks(result: BindingResult, y: number): string {
21
+ const count = Math.min(result.strips, 8);
22
+ const marks = Array.from({ length: count }, (_, index) => {
23
+ const x = 38 + index * 27;
24
+ return `<rect class="qb-strip-mark" x="${x}" y="${y}" width="21" height="8" rx="2" />`;
25
+ }).join('');
26
+ const extra = result.strips > 8 ? `<text class="qb-strip-extra" x="257" y="${y + 7}">+${result.strips - 8}</text>` : '';
27
+ return `${marks}${extra}`;
28
+ }
29
+
30
+ export function renderIdleScene(): string {
31
+ return '<svg viewBox="0 0 300 230" role="img" aria-hidden="true"><rect class="qb-scene-paper" x="8" y="8" width="284" height="214" rx="22" /><path class="qb-ribbon-idle" d="M58 52H242V168H58Z" /><rect class="qb-quilt-idle" x="76" y="70" width="148" height="80" rx="4" /></svg>';
32
+ }
33
+
34
+ export function renderBindingScene(inputs: BindingInputs, result: BindingResult, unit: UnitSystem): string {
35
+ const size = sceneScale(inputs);
36
+ const x = 150 - size.width / 2;
37
+ const y = 90 - size.height / 2;
38
+ const right = x + size.width;
39
+ const bottom = y + size.height;
40
+ const cornerDots = [
41
+ `<circle class="qb-corner-dot" cx="${x}" cy="${y}" r="4" />`,
42
+ `<circle class="qb-corner-dot" cx="${right}" cy="${y}" r="4" />`,
43
+ `<circle class="qb-corner-dot" cx="${right}" cy="${bottom}" r="4" />`,
44
+ `<circle class="qb-corner-dot" cx="${x}" cy="${bottom}" r="4" />`,
45
+ ].join('');
46
+ const label = `${formatLength(result.requiredBindingCm, unit)} ${unit === 'imperial' ? 'in' : 'cm'} binding`;
47
+ const labelY = 183;
48
+ const countY = 197;
49
+ const marksY = 209;
50
+ return `<svg viewBox="0 0 300 230" role="img" aria-label="${label}"><rect class="qb-scene-paper" x="8" y="8" width="284" height="214" rx="22" /><path class="qb-ribbon" d="M${x - 10} ${y - 10}H${right + 10}V${bottom + 10}H${x - 10}Z" /><rect class="qb-quilt" x="${x}" y="${y}" width="${size.width}" height="${size.height}" rx="4" />${cornerDots}<text class="qb-scene-measure" x="150" y="${labelY}">${label}</text><text class="qb-scene-count" x="150" y="${countY}">${formatWhole(result.strips)} strips to join</text>${stripMarks(result, marksY)}</svg>`;
51
+ }
52
+
53
+ export function resultSummary(inputs: BindingInputs, result: BindingResult, unit: UnitSystem): string {
54
+ const cut = `${formatLength(inputs.stripWidthCm, unit)} x ${formatLength(inputs.fabricWidthCm, unit)}`;
55
+ return `${formatWhole(result.strips)} strips at ${cut}, ${formatLength(result.requiredBindingCm, unit)} binding required.`;
56
+ }
@@ -0,0 +1,29 @@
1
+ import type { TextilesToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { QuiltBindingCalculatorUI } from './ui';
3
+
4
+ export type QuiltBindingCalculatorLocaleContent = ToolLocaleContent<QuiltBindingCalculatorUI>;
5
+
6
+ export const quiltBindingCalculator: TextilesToolEntry<QuiltBindingCalculatorUI> = {
7
+ id: 'quilt-binding-length-calculator',
8
+ icons: {
9
+ bg: 'mdi:scissors-cutting',
10
+ fg: 'mdi:map-marker-distance',
11
+ },
12
+ i18n: {
13
+ de: () => import('./i18n/de').then((module) => module.content),
14
+ en: () => import('./i18n/en').then((module) => module.content),
15
+ es: () => import('./i18n/es').then((module) => module.content),
16
+ fr: () => import('./i18n/fr').then((module) => module.content),
17
+ id: () => import('./i18n/id').then((module) => module.content),
18
+ it: () => import('./i18n/it').then((module) => module.content),
19
+ ja: () => import('./i18n/ja').then((module) => module.content),
20
+ ko: () => import('./i18n/ko').then((module) => module.content),
21
+ nl: () => import('./i18n/nl').then((module) => module.content),
22
+ pl: () => import('./i18n/pl').then((module) => module.content),
23
+ pt: () => import('./i18n/pt').then((module) => module.content),
24
+ ru: () => import('./i18n/ru').then((module) => module.content),
25
+ sv: () => import('./i18n/sv').then((module) => module.content),
26
+ tr: () => import('./i18n/tr').then((module) => module.content),
27
+ zh: () => import('./i18n/zh').then((module) => module.content),
28
+ },
29
+ };
@@ -0,0 +1,13 @@
1
+ import type { BindingResult } from './logic';
2
+
3
+ export interface BindingEvaluation {
4
+ status: 'ready' | 'review';
5
+ warning: 'large-waste' | 'small-surplus' | null;
6
+ }
7
+
8
+ export function evaluateBinding(result: BindingResult): BindingEvaluation {
9
+ const wasteRatio = result.wasteReserveCm / result.requiredBindingCm;
10
+ if (wasteRatio > 0.13) return { status: 'review', warning: 'large-waste' };
11
+ if (result.surplusCm < result.joinLossCm) return { status: 'review', warning: 'small-surplus' };
12
+ return { status: 'ready', warning: null };
13
+ }
@@ -0,0 +1,3 @@
1
+ import { createContent } from './shared';
2
+
3
+ export const content = createContent('de');