@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
@@ -0,0 +1,3 @@
1
+ import { createContent } from './shared';
2
+
3
+ export const content = createContent('sv');
@@ -0,0 +1,3 @@
1
+ import { createContent } from './shared';
2
+
3
+ export const content = createContent('tr');
@@ -0,0 +1,3 @@
1
+ import { createContent } from './shared';
2
+
3
+ export const content = createContent('zh');
@@ -0,0 +1,11 @@
1
+ import type { ToolDefinition } from '../../types';
2
+ import { quiltBindingCalculator } from './entry';
3
+
4
+ export * from './entry';
5
+
6
+ export const QUILT_BINDING_CALCULATOR_TOOL: ToolDefinition = {
7
+ entry: quiltBindingCalculator,
8
+ Component: () => import('./component.astro'),
9
+ SEOComponent: () => import('./seo.astro'),
10
+ BibliographyComponent: () => import('./bibliography.astro'),
11
+ };
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { renderBindingScene } from './dom-views';
3
+ import { calculateBinding, convertLengthFromCm, convertLengthToCm, type BindingInputs } from './logic';
4
+
5
+ const example: BindingInputs = {
6
+ quiltWidthCm: 90,
7
+ quiltLengthCm: 120,
8
+ cornerCount: 4,
9
+ stripWidthCm: 6.5,
10
+ fabricWidthCm: 110,
11
+ seamAllowanceCm: 0.6,
12
+ joinMethod: 'diagonal',
13
+ safetyPercent: 0.1,
14
+ };
15
+
16
+ describe('quilt binding calculation', () => {
17
+ it('calculates perimeter, reserves, strips, and surplus', () => {
18
+ const result = calculateBinding(example);
19
+ expect(result).toMatchObject({
20
+ valid: true,
21
+ perimeterCm: 420,
22
+ cornerReserveCm: 4.8,
23
+ joiningReserveCm: 7.7,
24
+ requiredBindingCm: 475.75,
25
+ strips: 5,
26
+ joinCount: 4,
27
+ joinedLengthCm: 524,
28
+ });
29
+ if (result.valid) expect(result.surplusCm).toBeCloseTo(48.25, 4);
30
+ });
31
+
32
+ it('uses the smaller loss for straight joins', () => {
33
+ const result = calculateBinding({ ...example, joinMethod: 'straight' });
34
+ expect(result.valid).toBe(true);
35
+ if (result.valid) {
36
+ expect(result.joinLossCm).toBe(1.2);
37
+ expect(result.joiningReserveCm).toBe(1.2);
38
+ }
39
+ });
40
+
41
+ it('rejects a fabric width that cannot hold the strip', () => {
42
+ const result = calculateBinding({ ...example, fabricWidthCm: 6 });
43
+ expect(result).toEqual({ valid: false, errors: ['fabricWidth'] });
44
+ });
45
+
46
+ it('rejects negative dimensions and invalid corners', () => {
47
+ const result = calculateBinding({ ...example, quiltWidthCm: -1, cornerCount: 2.5 });
48
+ expect(result).toEqual({ valid: false, errors: ['lengths', 'corners'] });
49
+ });
50
+
51
+ it('converts inches without changing the physical length', () => {
52
+ const inches = convertLengthFromCm(2.54, 'imperial');
53
+ expect(inches).toBe(1);
54
+ expect(convertLengthToCm(inches, 'imperial')).toBe(2.54);
55
+ });
56
+
57
+ it('keeps scene labels clear and preserves quilt proportions', () => {
58
+ const result = calculateBinding(example);
59
+ expect(result.valid).toBe(true);
60
+ if (result.valid) {
61
+ const scene = renderBindingScene(example, result, 'metric');
62
+ const quilt = scene.match(/class="qb-quilt"[^>]*width="([^"]+)" height="([^"]+)"/);
63
+ const label = scene.match(/class="qb-scene-measure" x="150" y="([^"]+)"/);
64
+ const marks = scene.match(/class="qb-strip-mark" x="38" y="([^"]+)"/);
65
+ expect(quilt).not.toBeNull();
66
+ expect(Number(quilt?.[1]) / Number(quilt?.[2])).toBeCloseTo(0.75, 4);
67
+ expect(Number(label?.[1])).toBeLessThan(Number(marks?.[1]));
68
+ }
69
+ });
70
+ });
@@ -0,0 +1,93 @@
1
+ export const INCH_TO_CM = 2.54;
2
+
3
+ export type UnitSystem = 'metric' | 'imperial';
4
+ export type JoinMethod = 'diagonal' | 'straight';
5
+ export type SafetyPercent = 0.05 | 0.1 | 0.15;
6
+
7
+ export interface BindingInputs {
8
+ quiltWidthCm: number;
9
+ quiltLengthCm: number;
10
+ cornerCount: number;
11
+ stripWidthCm: number;
12
+ fabricWidthCm: number;
13
+ seamAllowanceCm: number;
14
+ joinMethod: JoinMethod;
15
+ safetyPercent: SafetyPercent;
16
+ }
17
+
18
+ export interface BindingResult {
19
+ valid: true;
20
+ perimeterCm: number;
21
+ cornerReserveCm: number;
22
+ joiningReserveCm: number;
23
+ wasteReserveCm: number;
24
+ requiredBindingCm: number;
25
+ strips: number;
26
+ joinCount: number;
27
+ joinedLengthCm: number;
28
+ surplusCm: number;
29
+ fabricCutLengthCm: number;
30
+ joinLossCm: number;
31
+ }
32
+
33
+ export interface InvalidBindingResult {
34
+ valid: false;
35
+ errors: string[];
36
+ }
37
+
38
+ export type CalculationResult = BindingResult | InvalidBindingResult;
39
+
40
+ export function convertLengthToCm(value: number, unit: UnitSystem): number {
41
+ return unit === 'imperial' ? value * INCH_TO_CM : value;
42
+ }
43
+
44
+ export function convertLengthFromCm(value: number, unit: UnitSystem): number {
45
+ return unit === 'imperial' ? value / INCH_TO_CM : value;
46
+ }
47
+
48
+ function validateInputs(inputs: BindingInputs): string[] {
49
+ const errors: string[] = [];
50
+ const lengths = [inputs.quiltWidthCm, inputs.quiltLengthCm, inputs.stripWidthCm, inputs.fabricWidthCm, inputs.seamAllowanceCm];
51
+ if (lengths.some((value) => !Number.isFinite(value) || value <= 0)) errors.push('lengths');
52
+ if (!Number.isInteger(inputs.cornerCount) || inputs.cornerCount < 0 || inputs.cornerCount > 100) errors.push('corners');
53
+ if (inputs.fabricWidthCm <= inputs.stripWidthCm) errors.push('fabricWidth');
54
+ if (inputs.safetyPercent < 0.05 || inputs.safetyPercent > 0.15) errors.push('safety');
55
+ return errors;
56
+ }
57
+
58
+ function countStrips(requiredCm: number, fabricWidthCm: number, joinLossCm: number): number {
59
+ const usefulLength = fabricWidthCm - joinLossCm;
60
+ return Math.max(1, Math.ceil((requiredCm - joinLossCm) / usefulLength));
61
+ }
62
+
63
+ export function calculateBinding(inputs: BindingInputs): CalculationResult {
64
+ const errors = validateInputs(inputs);
65
+ if (errors.length > 0) return { valid: false, errors };
66
+
67
+ const perimeterCm = 2 * (inputs.quiltWidthCm + inputs.quiltLengthCm);
68
+ const cornerReserveCm = inputs.cornerCount * inputs.seamAllowanceCm * 2;
69
+ const joiningReserveCm = inputs.joinMethod === 'diagonal'
70
+ ? inputs.stripWidthCm + inputs.seamAllowanceCm * 2
71
+ : inputs.seamAllowanceCm * 2;
72
+ const baseLengthCm = perimeterCm + cornerReserveCm + joiningReserveCm;
73
+ const wasteReserveCm = baseLengthCm * inputs.safetyPercent;
74
+ const requiredBindingCm = baseLengthCm + wasteReserveCm;
75
+ const joinLossCm = inputs.joinMethod === 'diagonal' ? inputs.stripWidthCm : inputs.seamAllowanceCm * 2;
76
+ const strips = countStrips(requiredBindingCm, inputs.fabricWidthCm, joinLossCm);
77
+ const joinedLengthCm = strips * inputs.fabricWidthCm - (strips - 1) * joinLossCm;
78
+
79
+ return {
80
+ valid: true,
81
+ perimeterCm,
82
+ cornerReserveCm,
83
+ joiningReserveCm,
84
+ wasteReserveCm,
85
+ requiredBindingCm,
86
+ strips,
87
+ joinCount: Math.max(0, strips - 1),
88
+ joinedLengthCm,
89
+ surplusCm: joinedLengthCm - requiredBindingCm,
90
+ fabricCutLengthCm: strips * inputs.stripWidthCm,
91
+ joinLossCm,
92
+ };
93
+ }
@@ -0,0 +1,474 @@
1
+ :root {
2
+ --n-paper: #f7f1e6;
3
+ --n-surface: #fffdf8;
4
+ --n-ink: #2c2930;
5
+ --n-muted: #746c70;
6
+ --n-line: #d8c9b5;
7
+ --n-accent: #9b4d3a;
8
+ --n-accent-strong: #71352b;
9
+ --n-accent-soft: #f0d8c8;
10
+ --n-ribbon: #d28b65;
11
+ --n-quilt: #b7c5b5;
12
+ --n-success: #3c6b4b;
13
+ --n-warning: #9a5b22;
14
+ --n-error: #9b3c3c;
15
+ }
16
+
17
+ .qb-tool {
18
+ --n-card: var(--n-surface);
19
+ --n-card-ink: var(--n-ink);
20
+ --n-card-muted: var(--n-muted);
21
+ --n-card-line: var(--n-line);
22
+ --n-card-accent: var(--n-accent);
23
+
24
+ box-sizing: border-box;
25
+ width: min(100%, calc(100vw - 4rem));
26
+ max-width: min(100%, calc(100vw - 4rem));
27
+ color: var(--n-card-ink);
28
+ background: var(--n-paper);
29
+ border: 1px solid var(--n-card-line);
30
+ border-radius: 1.5rem;
31
+ overflow: visible;
32
+ }
33
+
34
+ main:has(.qb-tool) {
35
+ min-width: 0;
36
+ }
37
+
38
+ .tool-page.tool-page:has(.qb-tool) {
39
+ box-sizing: border-box;
40
+ width: min(100%, calc(100vw - 4rem));
41
+ max-width: min(1200px, calc(100vw - 4rem));
42
+ min-width: 0;
43
+ }
44
+
45
+ .section-tool.section-tool:has(.qb-tool) {
46
+ box-sizing: border-box;
47
+ width: min(100%, calc(100vw - 4rem));
48
+ max-width: min(1200px, calc(100vw - 4rem));
49
+ }
50
+
51
+ .theme-dark .qb-tool {
52
+ --n-paper: #17191b;
53
+ --n-surface: #24282b;
54
+ --n-ink: #f5eee3;
55
+ --n-muted: #c4b8ad;
56
+ --n-line: #4c4b48;
57
+ --n-accent: #e4a07a;
58
+ --n-accent-strong: #ffd0ae;
59
+ --n-accent-soft: #49352d;
60
+ --n-ribbon: #bd785d;
61
+ --n-quilt: #718879;
62
+ --n-success: #a9d5ad;
63
+ --n-warning: #f0bb78;
64
+ --n-error: #ffaaa0;
65
+ }
66
+
67
+ .qb-layout {
68
+ display: grid;
69
+ min-width: 0;
70
+ grid-template-columns: minmax(15rem, 0.9fr) minmax(20rem, 1.1fr);
71
+ gap: 0;
72
+ }
73
+
74
+ .qb-stage,
75
+ .qb-workbench,
76
+ .qb-result,
77
+ .qb-notes {
78
+ min-width: 0;
79
+ background: var(--n-card);
80
+ }
81
+
82
+ .qb-stage {
83
+ padding: 1.25rem;
84
+ border-radius: 1.5rem 0 0;
85
+ border-right: 1px solid var(--n-card-line);
86
+ }
87
+
88
+ .qb-stage-head,
89
+ .qb-workbench-head,
90
+ .qb-result-head,
91
+ .qb-result-foot {
92
+ display: flex;
93
+ align-items: center;
94
+ justify-content: space-between;
95
+ gap: 1rem;
96
+ }
97
+
98
+ .qb-stage-label,
99
+ .qb-choice-label,
100
+ .qb-plan span,
101
+ .qb-metrics span {
102
+ color: var(--n-card-muted);
103
+ font-size: 0.75rem;
104
+ letter-spacing: 0.08em;
105
+ text-transform: uppercase;
106
+ }
107
+
108
+ .qb-unit-readout {
109
+ color: var(--n-card-accent);
110
+ font-size: 0.8rem;
111
+ font-weight: 700;
112
+ }
113
+
114
+ .qb-scene {
115
+ margin: 1rem 0 0.5rem;
116
+ }
117
+
118
+ .qb-scene svg {
119
+ display: block;
120
+ width: 100%;
121
+ height: auto;
122
+ }
123
+
124
+ .qb-scene-note {
125
+ margin: 0;
126
+ color: var(--n-card-muted);
127
+ font-size: 0.78rem;
128
+ line-height: 1.4;
129
+ }
130
+
131
+ .qb-scene-paper {
132
+ fill: var(--n-paper);
133
+ stroke: var(--n-card-line);
134
+ stroke-width: 1;
135
+ }
136
+
137
+ .qb-ribbon,
138
+ .qb-ribbon-idle {
139
+ fill: none;
140
+ stroke: var(--n-ribbon);
141
+ stroke-width: 12;
142
+ stroke-linejoin: round;
143
+ }
144
+
145
+ .qb-ribbon-idle { opacity: 0.5; }
146
+
147
+ .qb-quilt {
148
+ fill: var(--n-quilt);
149
+ stroke: var(--n-card-ink);
150
+ stroke-width: 2;
151
+ }
152
+
153
+ .qb-quilt-idle {
154
+ fill: var(--n-quilt);
155
+ opacity: 0.35;
156
+ }
157
+
158
+ .qb-corner-dot {
159
+ fill: var(--n-card-accent);
160
+ stroke: var(--n-card);
161
+ stroke-width: 2;
162
+ }
163
+
164
+ .qb-scene-measure,
165
+ .qb-scene-count,
166
+ .qb-strip-extra {
167
+ fill: var(--n-card-ink);
168
+ text-anchor: middle;
169
+ font-size: 9px;
170
+ font-weight: 700;
171
+ }
172
+
173
+ .qb-scene-count {
174
+ fill: var(--n-card-muted);
175
+ font-size: 7px;
176
+ font-weight: 500;
177
+ }
178
+
179
+ .qb-strip-mark {
180
+ fill: var(--n-ribbon);
181
+ stroke: var(--n-card-ink);
182
+ stroke-width: 0.5;
183
+ }
184
+
185
+ .qb-workbench {
186
+ padding: 1.25rem;
187
+ border-radius: 0 1.5rem 0 0;
188
+ }
189
+
190
+ .qb-workbench h2,
191
+ .qb-result h2 {
192
+ margin: 0;
193
+ font-size: 1.05rem;
194
+ }
195
+
196
+ .qb-unit-toggle {
197
+ display: inline-flex;
198
+ padding: 0.2rem;
199
+ border: 1px solid var(--n-card-line);
200
+ border-radius: 999px;
201
+ }
202
+
203
+ .qb-unit-button,
204
+ .qb-primary,
205
+ .qb-secondary,
206
+ .qb-choice > button,
207
+ .qb-options button {
208
+ border: 0;
209
+ color: var(--n-card-ink);
210
+ background: transparent;
211
+ font: inherit;
212
+ cursor: pointer;
213
+ }
214
+
215
+ .qb-unit-button {
216
+ padding: 0.45rem 0.7rem;
217
+ border-radius: 999px;
218
+ color: var(--n-card-muted);
219
+ font-size: 0.75rem;
220
+ }
221
+
222
+ .qb-unit-button.is-active {
223
+ color: var(--n-surface);
224
+ background: var(--n-card-accent);
225
+ }
226
+
227
+ .qb-fieldset {
228
+ min-width: 0;
229
+ margin: 1.25rem 0 0;
230
+ padding: 0;
231
+ border: 0;
232
+ }
233
+
234
+ .qb-fieldset legend {
235
+ margin-bottom: 0.65rem;
236
+ color: var(--n-card-accent);
237
+ font-size: 0.8rem;
238
+ font-weight: 800;
239
+ }
240
+
241
+ .qb-fields {
242
+ display: grid;
243
+ grid-template-columns: repeat(3, minmax(0, 1fr));
244
+ gap: 0.65rem;
245
+ }
246
+
247
+ .qb-field {
248
+ display: grid;
249
+ gap: 0.35rem;
250
+ color: var(--n-card-ink);
251
+ font-size: 0.78rem;
252
+ }
253
+
254
+ .qb-field input {
255
+ box-sizing: border-box;
256
+ width: 100%;
257
+ min-height: 2.7rem;
258
+ padding: 0.55rem 0.65rem;
259
+ border: 1px solid var(--n-card-line);
260
+ border-radius: 0.6rem;
261
+ color: var(--n-card-ink);
262
+ background: var(--n-paper);
263
+ font: inherit;
264
+ }
265
+
266
+ .qb-field input:focus,
267
+ .qb-choice > button:focus,
268
+ .qb-unit-button:focus,
269
+ .qb-primary:focus,
270
+ .qb-secondary:focus,
271
+ .qb-options button:focus {
272
+ outline: 3px solid var(--n-accent-soft);
273
+ outline-offset: 2px;
274
+ }
275
+
276
+ .qb-field small {
277
+ color: var(--n-card-muted);
278
+ font-size: 0.68rem;
279
+ line-height: 1.25;
280
+ }
281
+
282
+ .qb-choice-row {
283
+ display: grid;
284
+ grid-template-columns: 1fr 1fr;
285
+ gap: 0.65rem;
286
+ margin-top: 1.25rem;
287
+ }
288
+
289
+ .qb-choice {
290
+ position: relative;
291
+ display: grid;
292
+ gap: 0.35rem;
293
+ }
294
+
295
+ .qb-choice > button {
296
+ min-height: 2.7rem;
297
+ padding: 0.55rem 2rem 0.55rem 0.7rem;
298
+ border: 1px solid var(--n-card-line);
299
+ border-radius: 0.6rem;
300
+ background: var(--n-paper);
301
+ text-align: left;
302
+ }
303
+
304
+ .qb-choice > button::after {
305
+ content: '';
306
+ position: absolute;
307
+ right: 0.8rem;
308
+ bottom: 0.95rem;
309
+ width: 0.45rem;
310
+ height: 0.45rem;
311
+ border-right: 2px solid var(--n-card-accent);
312
+ border-bottom: 2px solid var(--n-card-accent);
313
+ transform: rotate(45deg);
314
+ }
315
+
316
+ .qb-options {
317
+ position: absolute;
318
+ z-index: 3;
319
+ top: 4rem;
320
+ right: 0;
321
+ left: 0;
322
+ padding: 0.3rem;
323
+ border: 1px solid var(--n-card-line);
324
+ border-radius: 0.6rem;
325
+ background: var(--n-card);
326
+ box-shadow: 0 0.8rem 1.4rem #0002;
327
+ }
328
+
329
+ .qb-options[hidden] { display: none; }
330
+
331
+ .qb-options button {
332
+ display: block;
333
+ width: 100%;
334
+ padding: 0.6rem;
335
+ border-radius: 0.35rem;
336
+ text-align: left;
337
+ }
338
+
339
+ .qb-options button:hover,
340
+ .qb-options button.is-active {
341
+ color: var(--n-card-accent);
342
+ background: var(--n-accent-soft);
343
+ }
344
+
345
+ .qb-result {
346
+ padding: 1.25rem;
347
+ border-top: 1px solid var(--n-card-line);
348
+ border-radius: 0 0 1.5rem;
349
+ }
350
+
351
+ .qb-status {
352
+ padding: 0.35rem 0.6rem;
353
+ border-radius: 999px;
354
+ color: var(--n-card-muted);
355
+ background: var(--n-accent-soft);
356
+ font-size: 0.72rem;
357
+ font-weight: 700;
358
+ }
359
+ .qb-status.is-ready { color: var(--n-success); }
360
+ .qb-status.is-review { color: var(--n-warning); }
361
+ .qb-status.is-error { color: var(--n-error); }
362
+ .qb-result-body { margin-top: 1rem; }
363
+
364
+ .qb-result-body.is-empty .qb-metrics,
365
+ .qb-result-body.is-empty .qb-plan,
366
+ .qb-result-body.is-empty .qb-warning { display: none; }
367
+
368
+ .qb-empty {
369
+ margin: 0;
370
+ color: var(--n-card-muted);
371
+ }
372
+
373
+ .qb-result-body:not(.is-empty) .qb-empty { display: none; }
374
+
375
+ .qb-metrics {
376
+ display: grid;
377
+ grid-template-columns: repeat(4, minmax(0, 1fr));
378
+ gap: 0.7rem;
379
+ }
380
+
381
+ .qb-metrics div {
382
+ display: grid;
383
+ gap: 0.25rem;
384
+ min-width: 0;
385
+ }
386
+
387
+ .qb-metrics strong {
388
+ overflow-wrap: anywhere;
389
+ font-size: 1rem;
390
+ }
391
+
392
+ .qb-plan {
393
+ display: grid;
394
+ gap: 0.3rem;
395
+ margin: 1rem 0;
396
+ padding-top: 0.9rem;
397
+ border-top: 1px solid var(--n-card-line);
398
+ }
399
+
400
+ .qb-plan strong { font-size: 0.95rem; }
401
+
402
+ .qb-warning {
403
+ margin: 0.8rem 0 0;
404
+ color: var(--n-warning);
405
+ font-size: 0.82rem;
406
+ }
407
+
408
+ .qb-result-foot { margin-top: 1rem; }
409
+
410
+ .qb-feedback {
411
+ min-height: 1.2rem;
412
+ margin: 0;
413
+ color: var(--n-success);
414
+ font-size: 0.78rem;
415
+ }
416
+
417
+ .qb-actions {
418
+ display: flex;
419
+ gap: 0.5rem;
420
+ }
421
+
422
+ .qb-primary,
423
+ .qb-secondary {
424
+ padding: 0.55rem 0.8rem;
425
+ border-radius: 0.55rem;
426
+ font-size: 0.78rem;
427
+ font-weight: 700;
428
+ }
429
+
430
+ .qb-primary {
431
+ color: var(--n-surface);
432
+ background: var(--n-card-accent);
433
+ }
434
+
435
+ .qb-secondary { border: 1px solid var(--n-card-line); }
436
+
437
+ .qb-notes {
438
+ padding: 1rem 1.25rem;
439
+ border-top: 1px solid var(--n-card-line);
440
+ color: var(--n-card-muted);
441
+ font-size: 0.82rem;
442
+ line-height: 1.5;
443
+ }
444
+
445
+ .qb-notes summary {
446
+ color: var(--n-card-ink);
447
+ cursor: pointer;
448
+ font-weight: 700;
449
+ }
450
+
451
+ .qb-notes p { margin: 0.8rem 0 0; }
452
+
453
+ @media (max-width: 48rem) {
454
+ .qb-layout { grid-template-columns: 1fr; }
455
+ .qb-stage {
456
+ border-right: 0;
457
+ border-bottom: 1px solid var(--n-card-line);
458
+ border-radius: 1.5rem 1.5rem 0 0;
459
+ }
460
+ .qb-workbench { border-radius: 0; }
461
+ .qb-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
462
+ }
463
+
464
+ @media (max-width: 30rem) {
465
+ .qb-fields, .qb-choice-row { grid-template-columns: 1fr 1fr; }
466
+ .qb-fields .qb-field:last-child { grid-column: 1 / -1; }
467
+ .qb-result-head,
468
+ .qb-result-foot {
469
+ align-items: flex-start;
470
+ flex-direction: column;
471
+ }
472
+ .qb-actions { width: 100%; }
473
+ .qb-actions button { flex: 1; }
474
+ }
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { quiltBindingCalculator } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props;
11
+ const content = await quiltBindingCalculator.i18n[locale]?.();
12
+ if (!content) return null;
13
+ ---
14
+
15
+ {content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -0,0 +1,26 @@
1
+ import type { BindingInputs, UnitSystem } from './logic';
2
+
3
+ const STORAGE_KEY = 'jjlmoya-quilt-binding-calculator-v1';
4
+
5
+ export interface SavedBindingDraft {
6
+ unit: UnitSystem;
7
+ inputs: BindingInputs;
8
+ }
9
+
10
+ export function loadBindingDraft(): SavedBindingDraft | null {
11
+ try {
12
+ const raw = window.localStorage.getItem(STORAGE_KEY);
13
+ if (!raw) return null;
14
+ return JSON.parse(raw) as SavedBindingDraft;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ export function saveBindingDraft(draft: SavedBindingDraft): void {
21
+ try {
22
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(draft));
23
+ } catch {
24
+ return;
25
+ }
26
+ }