-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.js
More file actions
1548 lines (1456 loc) · 75.9 KB
/
Copy pathapp.js
File metadata and controls
1548 lines (1456 loc) · 75.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// roughlogic application entry.
// Vanilla ES module. No dependencies. No innerHTML. No eval. No Function constructor.
// Calculator modules and their support libs are loaded on demand. Per
// spec section 11.1, the home view stays well under 100 KB by importing
// only what the home view needs (this module + integrity.js). Calculator
// renderers, hash-state, data-stamp, and pure-math come in dynamically
// when the user opens a tool.
import { verifyManifestIntegrity, verifyShard } from "./integrity.js";
import { parseHashRoute } from "./routing.js";
import { leadSentence, restOfDescription } from "./text-lead.js";
// Recents (utility 120) was removed in v11; see specs/spec-v11.md.
// The tile-id -> renderer-module registry lives in its own lazy-loaded
// shard (spec-v10 SS H.1 / H.2). It is 24.4 KB gzipped and the home view
// never reads it, so it is imported on the first tile open instead of at
// boot. loadRenderer() was already async, so nothing else had to change.
let toolModulesPromise = null;
function loadToolModules() {
if (!toolModulesPromise) toolModulesPromise = import("./tool-modules.js").then((m) => m.TOOL_MODULES);
return toolModulesPromise;
}
const moduleCache = new Map();
async function loadRenderer(toolId) {
const meta = (await loadToolModules())[toolId];
if (!meta) return null;
let promise = moduleCache.get(meta.path);
if (!promise) {
promise = import(meta.path);
moduleCache.set(meta.path, promise);
}
const mod = await promise;
const set = mod[meta.exportName];
return set ? set[toolId] || null : null;
}
let supportLibsPromise = null;
function loadSupportLibs() {
if (!supportLibsPromise) {
supportLibsPromise = Promise.all([
import("./hash-state.js"),
import("./data-stamp.js"),
import("./clipboard.js"),
import("./ui-validity.js"),
]).then(([hs, ds, cb, uv]) => ({ ...hs, ...ds, ...cb, ...uv }));
}
return supportLibsPromise;
}
// Reference-style tools that should display "Source: <dataset>, version X,
// fetched Y." per spec section 11.7. First-principles calculators cite
// physics inline and are not listed here.
const TOOL_DATA_SOURCES = {
"motor-fla": { folder: "electrical", shard: "motor-fla.json", label: "Motor full-load amps (manufacturer-attributed)" },
"conduit-fill": { folder: "electrical", shard: "conduit-fill-tables.json", label: "Conductor cross-sectional areas" },
"egc-sizing": { folder: "electrical", shard: "ampacity-physics.json", label: "EGC reference (impedance considerations)" },
"pipe-sizing": { folder: "plumbing", shard: "fixture-units.json", label: "Hunter's Curve fixture units" },
"gas-pipe-sizing": { folder: "plumbing", shard: "gas-pipe-capacity.json", label: "Gas pipe capacity (Spitzglass)" },
"refrigerant-pt": { folder: "hvac", shard: "refrigerants.json", label: "Refrigerant P-T tables" },
"superheat-subcool": { folder: "hvac", shard: "refrigerants.json", label: "Refrigerant P-T tables" },
"manual-j-cooling": { folder: "hvac", shard: "climate-data.json", label: "NOAA climate design temperatures" },
"manual-j-heating": { folder: "hvac", shard: "climate-data.json", label: "NOAA climate design temperatures" },
"water-classes": { folder: "restoration", shard: "water-classes.json", label: "Water classes and categories (original summaries)" },
"drying-times": { folder: "restoration", shard: "drying-times.json", label: "Material drying times (original notes)" },
"mold": { folder: "restoration", shard: "mold-conditions.json", label: "Mold growth conditions" },
"ppe": { folder: "restoration", shard: "water-classes.json", label: "PPE selection (OSHA / IICRC referenced)" },
"lumber-spans": { folder: "construction", shard: "lumber-properties.json", label: "Lumber material properties" },
"fastener-pullout": { folder: "construction", shard: "lumber-properties.json", label: "Wood specific gravity" },
"fire-friction": { folder: "fire", shard: "hose-friction.json", label: "Fire hose friction (NFA)" },
"required-fire-flow": { folder: "fire", shard: "fire-flow-formulas.json", label: "ISO needed-fire-flow formulas" },
"sales-tax": { folder: "crosswalks", shard: "state-tax-rates.json", label: "State sales tax rates" },
"unit-converter": { folder: "crosswalks", shard: "unit-conversions.json", label: "NIST SP 811 unit factors" },
"backflow": { folder: "summaries", shard: "summaries.json", label: "Backflow scenarios (original summaries)" },
"smoke-reading": { folder: "summaries", shard: "summaries.json", label: "Smoke reading reference (original summaries)" },
// v2
"gfci-afci-reference": { folder: "summaries", shard: "v2-references.json", label: "GFCI/AFCI requirements (original summaries; NEC by section only)" },
"lighting-density": { folder: "electrical", shard: "lighting-density.json", label: "Lighting power density benchmarks" },
"water-hammer-arrestor": { folder: "summaries", shard: "v2-references.json", label: "Water hammer arrestor sizing (PDI WH-201 method)" },
"trap-arm": { folder: "summaries", shard: "v2-references.json", label: "Trap arm length (engineering practice)" },
"gas-leak-rate": { folder: "plumbing", shard: "gas-pipe-capacity.json", label: "Gas properties for orifice leak estimation" },
"compare-refrigerants": { folder: "hvac", shard: "refrigerants.json", label: "Refrigerant P-T tables (manufacturer-attributed)" },
"refrigerant-charge": { folder: "hvac", shard: "charge-per-foot.json", label: "Refrigerant charge per foot (manufacturer-attributed)" },
"equivalent-length": { folder: "hvac", shard: "equivalent-lengths.json", label: "Fitting equivalent lengths" },
"insulation-thickness": { folder: "hvac", shard: "insulation.json", label: "Insulation conductivity references" },
"thermal-delta-t": { folder: "summaries", shard: "v2-references.json", label: "Thermal imager delta-T reference (original summaries)" },
"footing-area": { folder: "construction", shard: "soil-bearing.json", label: "Soil bearing capacities (USGS-derived)" },
"wind-pressure": { folder: "construction", shard: "wind-snow-zones.json", label: "Wind / snow design data (NOAA / public ASCE 7 formula)" },
"snow-load": { folder: "construction", shard: "wind-snow-zones.json", label: "Wind / snow design data (NOAA / public ASCE 7 formula)" },
"mileage-cost": { folder: "crosswalks", shard: "irs-mileage.json", label: "IRS standard mileage rate" },
"per-diem": { folder: "crosswalks", shard: "gsa-perdiem.json", label: "GSA per-diem rates (per-state approximation of standard CONUS, not a locality rate)" },
"color-codes": { folder: "summaries", shard: "v2-references.json", label: "Color codes reference (original summaries)" },
"knot-reference": { folder: "summaries", shard: "v2-references.json", label: "Knot reference (original summaries; NFA training)" },
"inspection-checklist": { folder: "summaries", shard: "v2-references.json", label: "Inspection prep checklist (original summaries)" },
"emergency-contacts": { folder: "summaries", shard: "v2-references.json", label: "Utility locator and emergency contacts (US)" },
"tool-maintenance": { folder: "summaries", shard: "v2-references.json", label: "Tool maintenance intervals (original summaries)" },
// v3
"cable-bend-radius": { folder: "electrical", shard: "cable-bend-radius.json", label: "Cable bend radius (manufacturer-attributed)" },
"poe-budget": { folder: "electrical", shard: "poe-classes.json", label: "PoE class budgets (IEEE 802.3, manufacturer cable resistance)" },
"stormwater-rational": { folder: "plumbing", shard: "runoff-coefficients.json", label: "Runoff coefficients (public engineering practice)" },
"manning-slope": { folder: "plumbing", shard: "manning-roughness.json", label: "Manning roughness coefficients" },
"glycol-mix": { folder: "plumbing", shard: "glycol-curves.json", label: "Glycol freeze-point curves (manufacturer-attributed)" },
"backflow-loss": { folder: "plumbing", shard: "backflow-curves.json", label: "Backflow preventer pressure-loss curves (manufacturer-attributed)" },
"geothermal-loop": { folder: "hvac", shard: "geothermal-soil.json", label: "Geothermal loop BTU per linear foot (DOE technical reports)" },
"baseboard-output": { folder: "hvac", shard: "baseboard-output.json", label: "Hydronic baseboard BTU/ft (manufacturer-attributed)" },
"concrete-mix-design": { folder: "construction", shard: "aci-211-curves.json", label: "ACI 211 mix-design curve points (cited by name only)" },
"bolt-torque": { folder: "construction", shard: "bolt-grades.json", label: "Bolt grade proof loads (ASTM/SAE benchmarks; cited by name only)" },
"speeds-feeds": { folder: "construction", shard: "sfm-table.json", label: "SFM and chipload table (engineering practice)" },
"weld-usage": { folder: "construction", shard: "aws-deposition.json", label: "AWS deposition efficiencies (cited by name only)" },
"trench-slope": { folder: "crosswalks", shard: "osha-trench.json", label: "OSHA trench sloping (29 CFR 1926 Subpart P)" },
"niosh-lifting": { folder: "crosswalks", shard: "niosh-coupling.json", label: "NIOSH 1991 Lifting Equation" },
"heat-stress": { folder: "crosswalks", shard: "heat-cold-stress.json", label: "Heat / cold stress (NWS / OSHA)" },
"wind-chill": { folder: "crosswalks", shard: "heat-cold-stress.json", label: "Heat / cold stress (NWS / OSHA)" },
// v4 trucking
"dim-weight": { folder: "trucking", shard: "dim-divisors.json", label: "Carrier DIM divisors (cited by carrier name only)" },
"reefer-burn": { folder: "trucking", shard: "reefer-burn.json", label: "Reefer GPH benchmarks (manufacturer-attributed)" },
// v3 references (data-driven from v3-references shard)
"hand-signals": { folder: "summaries", shard: "v3-references.json", label: "Hand signal reference (original summaries)" },
"osha-top10": { folder: "summaries", shard: "v3-references.json", label: "OSHA top-10 most-cited standards" },
"loto-steps": { folder: "summaries", shard: "v3-references.json", label: "Lockout/tagout procedure (original summaries; 29 CFR 1910.147 by section)" },
"defensible-space": { folder: "summaries", shard: "v3-references.json", label: "Defensible space reference (CALFIRE/NFPA by name only)" },
"storm-shelter": { folder: "summaries", shard: "v3-references.json", label: "FEMA P-320 storm shelter reference (by name only)" },
"triage-quickread": { folder: "summaries", shard: "v3-references.json", label: "Field first aid triage quick-read (original summaries)" },
// v7 Group A extensions (utilities 234-237).
"short-circuit-pp": { folder: "electrical", shard: "conductor-c-values.json", label: "Conductor C-values for the Bussmann point-to-point method" },
"generator-motor-starting": { folder: "electrical", shard: "nema-mg1-code-letters.json", label: "NEMA MG-1 starting kVA per HP by code letter" },
"service-load-standard": { folder: "electrical", shard: "dwelling-demand.json", label: "Dwelling demand-factor parameters (NEC 220.42 / 220.53 / 220.54 / 220.55)" },
// v7 Group B extensions (utilities 238-241).
"water-hammer-surge": { folder: "plumbing", shard: "pipe-elastic-properties.json", label: "Pipe elastic properties for the Joukowsky water-hammer formula" },
"pump-operating-point": { folder: "plumbing", shard: "pump-curves.json", label: "Pump curves (engineering-practice composites, not a manufacturer curve)" },
"pipe-expansion-loop": { folder: "plumbing", shard: "thermal-expansion-coefficients.json", label: "Pipe thermal-expansion coefficients and guided-cantilever stress allowables" },
// v7 Group C extensions (utilities 242-245).
"duct-friction-static": { folder: "hvac", shard: "duct-roughness.json", label: "Duct absolute roughness and fitting C_o values (ASHRAE Fundamentals duct-design)" },
"refrigerant-charging": { folder: "hvac", shard: "refrigerant-pt-tables.json", label: "Manufacturer-attributed P-T tables (R-410A / R-32 / R-454B / R-22 / R-134a)" },
"insulation-heat-loss": { folder: "hvac", shard: "insulation-k-values.json", label: "Insulation thermal conductivity (manufacturer-attributed)" },
// v7 Group E extensions (utilities 246-251).
"rebar-schedule": { folder: "construction", shard: "rebar-unit-weights.json", label: "Rebar unit weights and bar diameters (ACI/CRSI by name only)" },
"plywood-span": { folder: "construction", shard: "apa-span-ratings.json", label: "APA span-rating tables (cited by APA name only)" },
"helical-pile": { folder: "construction", shard: "helical-pile-kt.json", label: "Helical-pile Kt benchmarks (manufacturer-attributed)" },
// v7 Group F + G extensions (utilities 252-253).
"iso-nff": { folder: "fire", shard: "iso-nff.json", label: "ISO Public Protection Classification F factors and Oi multipliers" },
"fall-protection-clearance": { folder: "crosswalks", shard: "fall-protection-benchmarks.json", label: "Connector free-fall and decel benchmarks (manufacturer-attributed)" },
// v4 Group Q: historical pricing context. The runtime-loaded per-commodity
// shard rewrites this label with the commodity-specific source line; the
// manifest folder reference stamps the bundled-on date for the dataset as
// a whole.
"historical-pricing": { folder: "historical", shard: "manifest.json", label: "Historical commodity pricing (BLS PPI / EIA / USDA NASS / FRED)" },
// v12 Group X: real-estate data shards (FHFA / HUD / VA, FY2026 cycle).
"loan-limits": { folder: "realestate", shard: "loan-limits.json", label: "FHFA + HUD FHA loan limits, 2026 cycle (federal-published)" },
"hud-fmr": { folder: "realestate", shard: "hud-fmr.json", label: "HUD Fair Market Rents, FY2026 (federal-published)" },
};
const TRADES = ["electrical", "plumbing", "hvac", "restoration", "carpentry", "fire", "trucking", "mechanic", "agriculture", "water", "stage", "kitchen", "field", "reference", "accounting", "small-business", "tax", "legal", "lab", "compliance"];
// spec-v107: groups S (Legal), U (Veterinary), V (EMS), W (Aviation) retired.
// Gaps in the letter sequence are expected and allowed (spec-v106 §5).
const GROUPS = ["A", "B", "C", "D", "E", "F", "G", "H", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "T", "X", "Y", "Z"];
// Display names for each group used as section headers on the home page.
const GROUP_NAMES = {
A: "Electrical",
B: "Plumbing and Gas",
C: "HVAC",
D: "Water Damage and Mold Restoration",
E: "Carpentry and Construction",
F: "Fire-Ground Engineering",
G: "Cross-Trade Utilities",
H: "Knowledge References",
J: "Trucking and Logistics",
K: "Mechanic - Auto, Marine, Aviation",
L: "Agriculture and Forestry",
M: "Water and Wastewater Operations",
N: "Stage and Live Production",
O: "Kitchen and Food Service",
P: "Field, Backcountry, and SAR",
Q: "Historical Reference Data",
R: "Accounting, Tax, and Small-Business",
T: "Bench Science and Laboratory Math",
X: "Real Estate",
Y: "Educators and K-12",
Z: "Rigging and Heavy Lift",
};
// Tool registry. Order matches spec.md section 12.
// Each entry: id (kebab-case route), name, group, trades, desc.
// spec-v17 §H.2: the TOOLS metadata registry (~30 KB gzipped) lives in
// tools-data.js and is lazy-loaded so the bare home view excludes it.
// The home #tools view is static HTML; TOOLS is needed only to route a
// tile hash, render a tool view, or power search -- all on interaction
// or a deep-link, never at home first paint. ensureTools() mirrors the
// ensureAliases() lazy pattern used by the search dropdown.
let TOOLS = null;
let _toolsPromise = null;
function ensureTools() {
if (TOOLS) return Promise.resolve(TOOLS);
if (!_toolsPromise) {
_toolsPromise = import("./tools-data.js").then((m) => { TOOLS = m.TOOLS; return TOOLS; });
}
return _toolsPromise;
}
const EMPTY_IDS = [];
const FIRE_GROUND_TRADE = "fire";
// Trades a tile carries when it is for everybody, not for a fireground. The
// notice says who is in charge of the answer, so "incident command governs the
// fireground" on a sales-tax or unit-conversion tile names the wrong authority
// -- and those tiles reached it only because `fire` is one of the six trades
// they list to mean "every trade". A tile tagged for electrical AND plumbing
// AND HVAC is a general-purpose tool; the AHJ default is the honest notice.
const GENERAL_PURPOSE_TRADES = ["electrical", "plumbing", "hvac"];
function isFireGroundTile(tool) {
if (tool.group === "F") return true;
if (!tool.trades.includes(FIRE_GROUND_TRADE)) return false;
return !GENERAL_PURPOSE_TRADES.every((t) => tool.trades.includes(t));
}
// Inline notices. One short line each: who governs the real decision.
// The static shells already carry this as a quiet footer line
// (build-shells.mjs shellFooter), so the live view matches that weight --
// the notice sits above the inputs but must never outrank the calculator.
const NOTICE_DEFAULT = "Math aid only. Local code, manufacturer specs, and the AHJ govern the work.";
const NOTICE_FIRE = "Math aid only. Department SOPs and incident command govern the fireground.";
const NOTICE_HISTORICAL = "Reference only. Prices change; ask your supplier for a current quote.";
const NOTICE_TAX_LAW = "Estimate only. Confirm with the current IRS publication or a CPA before filing.";
const NOTICE_LEGAL = "Legal information, not legal advice. Verify with current state code and an attorney.";
const NOTICE_LAB = "Check your lab's SOP before pipetting. A bad dilution ruins the run.";
const NOTICE_REAL_ESTATE = "Estimate only. The lender governs underwriting; the appraiser governs value.";
const NOTICE_EDUCATION = "Estimate only. The classroom teacher governs placement and assessment calls.";
// Leader-key shortcut targets.
const SHORTCUTS = {
h: { type: "home" },
s: { type: "focus", target: "#search-input" },
u: { type: "route", id: "unit-converter" },
o: { type: "route", id: "ohms-law" },
w: { type: "route", id: "wire-ampacity" },
v: { type: "route", id: "voltage-drop" },
f: { type: "route", id: "friction-loss" },
d: { type: "route", id: "duct-sizing" },
r: { type: "route", id: "refrigerant-pt" },
l: { type: "route", id: "lumber-spans" },
c: { type: "route", id: "concrete" },
t: { type: "route", id: "static-pressure-hvac" },
};
// State.
//
// The home view is a static hero (elevator pitch + one search combobox);
// there is no live-filtered grid, so the only state is the route.
const state = {
route: { view: "home", id: null, params: {} },
};
// spec-v1341: which fields the reader's own words filled, for the one
// navigation that is about to happen.
//
// This is deliberately NOT read off the hash. A deep link someone was sent and
// a query someone just typed produce an identical hash, so reading provenance
// from the URL would make a shared link claim its recipient typed it. It lives
// in memory, is consumed by the very next renderToolView, and is cleared
// there whether it was used or not.
let pendingProvenance = null;
// The words the reader typed, for the one navigation that is about to happen.
let pendingQuery = null;
const PROVENANCE_TEXT = "from your question";
// Boot.
document.addEventListener("DOMContentLoaded", boot);
// A printed page has no disclosure to click, so expand the proof block
// before the print snapshot is taken. The @media print rules cover the
// browsers that print without firing this event.
window.addEventListener("beforeprint", () => {
for (const d of document.querySelectorAll("details.proof")) d.open = true;
});
function boot() {
bindSearch();
bindShortcuts();
bindBrand();
window.addEventListener("hashchange", route);
route();
registerServiceWorker();
verifyManifestIntegrity();
}
// The brand link in the header has `href="#"` so right-click + "Open in
// new tab" still works. The default click behavior (scroll to top, then
// route home via hashchange) is not what the user wants; intercept and
// route home directly.
function bindBrand() {
const brand = document.querySelector(".brand");
if (!brand) return;
brand.addEventListener("click", (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
navigateTo("");
});
}
function registerServiceWorker() {
if (!("serviceWorker" in navigator)) return;
const proto = window.location.protocol;
const host = window.location.hostname;
const ok = proto === "https:" || host === "localhost" || host === "127.0.0.1";
if (!ok) return;
window.addEventListener("load", () => {
navigator.serviceWorker.register("./sw.js").catch(() => {});
});
}
// --- Routing ---
// Single routing entry for boot, hashchange, and navigateTo. A home /
// empty / legacy-bundle hash routes synchronously without the TOOLS list
// (the #tools view is static HTML). A tile hash lazy-loads TOOLS first,
// then parses and applies the route so renderToolView / updateHeadForTool
// have the registry available.
function route() {
const hash = window.location.hash || "";
const raw = hash.replace(/^#/, "");
if (!raw || raw === "home" || raw.startsWith("b=")) {
state.route = parseHashRoute(hash, EMPTY_IDS).route;
applyRoute();
return;
}
ensureTools().then(() => {
state.route = parseHashRoute(hash, TOOLS.map((t) => t.id)).route;
applyRoute();
});
}
function applyRoute() {
// A navigation dismisses the keyboard-shortcut modal. The overlay is a
// role="dialog" aria-modal="true" with a focus trap; it must not persist
// over a view it no longer matches. Every navigation path (G-leader
// shortcut, hashchange/back-forward, navigateTo) funnels through here, so
// this is the single point that covers them all. No-op when none is open.
closeShortcutOverlay(false);
const home = document.getElementById("tools");
const view = document.getElementById("view-region");
if (state.route.view === "tool") {
home.hidden = true;
view.hidden = false;
// Reserve the page's height BEFORE the tile's content lands. A tile view
// builds in two passes -- title and lead synchronously, then the fields and
// answer once the renderer module resolves -- so on a deep link the footer
// sits high, then gets shoved down when the calculator arrives. Measured on
// slow-3G that was a CLS of 0.173 to 0.247 depending on the tile, against a
// 0.05 budget and Core Web Vitals' 0.25 "poor" line. Holding main at full
// viewport height for the duration of a tool route puts the footer where it
// will end up, so the later content fills reserved space instead of pushing.
document.documentElement.setAttribute("data-route", "tool");
renderToolView(state.route.id, state.route.params);
updateHeadForTool(state.route.id);
} else {
home.hidden = false;
view.hidden = true;
document.documentElement.removeAttribute("data-route");
clearChildren(view);
updateHeadForHome();
}
}
// spec-v13 §5.5: SPA sets <title>, meta description, and
// <link rel="canonical"> to match the per-tile shell at /tools/<id>/
// when a tile opens; reverts to home values on return. Both surfaces build those
// strings from shell-meta.js, and spa-head-parity.test.js compares them in a
// browser.
// The same sentence index.html carries in its <meta name="description">, and
// the same one the page opens with. It has to be repeated here because the SPA
// rewrites the description on every route change, so the home route wrote
// "Rough Logic" straight back over the real one -- correct in the file a
// crawler reads, wrong on the page a person is looking at. check-readme-counts
// asserts the two strings are identical, which also keeps the count in this
// one honest.
const HOME_DESC =
"2,183 free calculators for the trades. Type the job the way you'd say it, and you get the number, the inputs, and the source.";
const HOME_TITLE = "Rough Logic";
// Production origin for the canonical link. The SPA must emit an ABSOLUTE
// canonical (matching the prerendered /tools/<id>/ and /groups/<slug>/
// shells) or Lighthouse SEO flags it ("Is not an absolute URL"); a relative
// "/tools/<id>/" scored the home + SPA tile URLs at SEO 0.92.
const SITE_ORIGIN = "https://roughlogic.com";
function setHeadLink(rel, href) {
let el = document.querySelector('link[rel="' + rel + '"]');
if (!el) { el = document.createElement("link"); el.setAttribute("rel", rel); document.head.appendChild(el); }
el.setAttribute("href", href);
}
function setHeadMeta(name, content) {
let el = document.querySelector('meta[name="' + name + '"]');
if (!el) { el = document.createElement("meta"); el.setAttribute("name", name); document.head.appendChild(el); }
el.setAttribute("content", content);
}
// The title and description this route's OWN shell at /tools/<id>/ carries.
// Both used to be built here and diverged from the shell on most of the
// catalog; shell-meta.js is now the only implementation, and carries the
// measurement. Imported on demand so the home view never pays for it, and the
// route is re-checked after the await so a fast second navigation does not get
// the first one's head.
function updateHeadForTool(id) {
const tool = TOOLS.find((t) => t.id === id);
if (!tool) return updateHeadForHome();
setHeadLink("canonical", SITE_ORIGIN + "/tools/" + id + "/");
import("./shell-meta.js").then((meta) => {
if (state.route.view !== "tool" || state.route.id !== id) return;
const head = meta.headForTool(tool);
document.title = head.title;
setHeadMeta("description", head.description);
});
}
function updateHeadForHome() {
document.title = HOME_TITLE;
setHeadMeta("description", HOME_DESC);
setHeadLink("canonical", SITE_ORIGIN + "/");
}
function navigateTo(hash) {
if (window.location.hash !== "#" + hash) {
window.history.replaceState(null, "", "#" + hash);
route();
}
}
// --- Tool view shell ---
// spec-v1338: hide the answer card until the tile actually has an answer.
//
// `:empty` in CSS is not enough. Most renderers build their output ROWS at
// mount and leave the value spans blank, so the region has children from the
// first paint -- ohms-law opens as "V: Copy I: Copy R: Copy P: Copy". Below
// the inputs that was merely untidy; hoisted above them it is the first thing
// on the page. Since every tile now opens blank, this is the common case, not
// an edge one.
//
// A region whose structure this cannot read (no .out-value spans, no <dd>) is
// left visible. Hiding something we do not understand is worse than showing it.
function syncAnswerVisibility(outputRegion) {
if (!outputRegion) return;
// Rich output -- a schedule table, a list, a chart -- IS the answer for
// tiles like loan-amortization and macrs-depreciation, and their summary
// spans can stay empty while the table carries everything. Reading only the
// value spans hid a fully populated region, which took the schedule table,
// the CSV export button and Copy-all down with it.
if (outputRegion.querySelector("table, ul, ol, canvas, svg, img")) {
outputRegion.classList.remove("output-blank");
return;
}
const cells = outputRegion.querySelectorAll(".out-value, dd");
if (!cells.length) return;
let hasValue = false;
for (const cell of cells) {
if (String(cell.textContent || "").trim() !== "") { hasValue = true; break; }
}
outputRegion.classList.toggle("output-blank", !hasValue);
}
// spec-v1341/v1342: the tile-side prefill, provenance captions and ask card
// live in tile-prefill.js, lazily imported. None of it is reachable from the
// home view, and app.js is loaded there against a hard 49 KB JS sub-budget
// (spec-v10 section H.2), so code that only runs on a tile does not belong in
// the file the home page pays for.
function applyQueryPrefill(region, id, params) {
const pending = pendingQuery;
pendingQuery = null;
if (!pending || pending.id !== id || !region) return;
const tool = TOOLS.find((t) => t.id === id);
if (!tool) return;
import("./tile-prefill.js").then((mod) => mod.applyQueryPrefill({
region, tool, params: params || {}, query: pending.text, provenanceText: PROVENANCE_TEXT,
})).catch(() => { /* prefill is an enhancement; the form is always there */ });
}
// A "Note" that reads the same for every set of inputs is not part of the
// answer -- it is reference prose that happens to come back alongside it.
// Left in the answer region it renders as a second disclosure, a median 565
// characters of explanation wedged between the number the reader came for and
// the one block that holds the formula and the sources. On 976 tiles.
//
// So move it into that block, right under the scope prose, and leave the
// answer region holding only the answer. The renderer keeps its reference to
// the span and keeps writing to it; only the span's place on the page changes.
//
// `run_calculator` still returns the note untouched. An agent reading a result
// has no disclosure to open, and the note is the only prose it gets.
//
// scripts/extract-constant-notes.mjs measures which tiles these are by running
// each compute twice on different inputs; the list is lazy-loaded, so a tile
// with no note row never pays for it.
function relocateConstantNote(id, outputRegion, proof, citation) {
const row = outputRegion.querySelector("details.note-row");
if (!row) return;
// Never empty the answer region. A handful of tiles answer ONLY in prose,
// and for those the note IS the result -- moving it would leave the reader
// looking at a blank card and would trip the "example paints something"
// gate. Those keep their note where it is.
if (!outputRegion.querySelector(".out-value:not(.note-value)")) return;
import("./constant-notes.js").then((mod) => {
if (!mod.CONSTANT_NOTE_TILES.has(id) || !row.isConnected) return;
const value = row.querySelector(".note-value");
if (!value) return;
const para = document.createElement("p");
para.className = "view-detail view-detail-note";
// Move the live element, do not copy its text: the renderer holds this
// node and writes the note into it on every recompute.
para.appendChild(value);
row.remove();
// Under the scope prose, above the formula -- prose first, then receipts.
proof.insertBefore(para, citation);
}).catch(() => { /* placement is cosmetic; never break a calculator for it */ });
}
function renderToolView(id, params) {
const tool = TOOLS.find((t) => t.id === id);
const view = document.getElementById("view-region");
clearChildren(view);
if (!tool) {
const p = document.createElement("p");
p.textContent = "Tool not found.";
view.appendChild(p);
return;
}
const headerRow = document.createElement("div");
headerRow.className = "view-header-row";
const back = document.createElement("a");
back.className = "back-link";
back.href = "#";
back.textContent = "Back to tools";
// Prevent the default browser scroll-to-top jump that `href="#"`
// triggers before the hashchange handler routes home.
back.addEventListener("click", (e) => {
e.preventDefault();
navigateTo("");
});
headerRow.appendChild(back);
view.appendChild(headerRow);
const h1 = document.createElement("h1");
h1.className = "view-title";
h1.textContent = tool.name;
view.appendChild(h1);
// Lead with the opening sentence only, the same one the static shell prints.
// Descriptions run to 1,457 characters; rendering one whole above the fields
// buries the calculator under prose the reader has not asked for yet. The
// remainder goes below the answer, as `detail`.
const lead = document.createElement("p");
lead.className = "view-desc";
lead.textContent = leadSentence(tool.desc);
view.appendChild(lead);
const notice = document.createElement("div");
notice.className = "view-notice";
notice.setAttribute("role", "note");
// v5 Step 61 per-id overrides for Group H references that span trades.
if (tool.id === "sales-tax-nexus") notice.textContent = NOTICE_LEGAL;
else if (tool.id === "irs-form-index") notice.textContent = NOTICE_TAX_LAW;
else if (tool.group === "Q") notice.textContent = NOTICE_HISTORICAL;
else if (tool.group === "R") notice.textContent = NOTICE_TAX_LAW;
else if (tool.group === "T") notice.textContent = NOTICE_LAB;
else if (tool.group === "X") notice.textContent = NOTICE_REAL_ESTATE;
else if (tool.group === "Y") notice.textContent = NOTICE_EDUCATION;
else if (isFireGroundTile(tool)) notice.textContent = NOTICE_FIRE;
else notice.textContent = NOTICE_DEFAULT;
// Appended below the answer, not above the fields. It is standing boilerplate
// -- what governs, not what to do -- and it reads the same on every tile.
// The inline citation is written by every renderer. It is long-form
// reference prose, so it lives inside the collapsed proof block below the
// answer rather than above the inputs, where it used to push the
// calculator off the first screen.
const citation = document.createElement("p");
citation.className = "citation";
const inputRegion = document.createElement("section");
inputRegion.className = "input-region";
inputRegion.setAttribute("aria-label", "Inputs");
const outputRegion = document.createElement("section");
outputRegion.className = "output-region";
outputRegion.setAttribute("aria-live", "polite");
outputRegion.setAttribute("aria-label", "Output");
// spec-v1348: one shared report control covers the whole catalog and every
// future tile that enters through renderToolView. The reporting client and
// Turnstile are loaded only after an intentional click, so normal calculator
// use stays local, offline-capable, and free of reporting network work.
const report = document.createElement("button");
report.type = "button";
report.className = "report-trigger";
report.textContent = "Report a problem";
report.addEventListener("click", () => {
report.disabled = true;
import("./report-feedback.js").then((mod) => {
if (!report.isConnected) return;
report.disabled = false;
return mod.openReportDialog({ tool, inputRegion, outputRegion, trigger: report, host: view });
}).catch(() => {
if (!report.isConnected) return;
report.disabled = false;
report.textContent = "Reporting unavailable";
});
});
headerRow.appendChild(report);
// spec-v1338: the answer goes ABOVE the inputs.
//
// The order the reader needs is the reverse of the order the page is built
// in. The number is what they came for; the inputs are what let them check
// it in one glance, and that glance is the whole reason a card beats a chat
// bubble. On a 16-field tile the answer used to be off the bottom of the
// screen, and after spec-v1341 a reader arriving with their own values
// already filled in landed on a form whose answer they had to scroll for.
//
// Appended in this order rather than moved afterwards, deliberately.
// `.output-region` is aria-live, and relocating a POPULATED live region
// re-announces its contents; at creation time it is empty, so there is
// nothing to re-announce and no timing to get wrong. The `:empty` rule in
// styles.css keeps it invisible until the tile actually has something to
// say -- which, since every tile now opens blank, is the common case.
view.appendChild(outputRegion);
view.appendChild(inputRegion);
view.appendChild(notice);
// ONE collapsed block holds everything behind the answer: the scope prose
// (everything after the tile's opening sentence -- caveats, limits, what the
// calculator does not cover), the inline citation, the structured reference
// rows, and the data-source stamp. Closed by default so the page reads as
// question -> answer; one click shows the receipts.
//
// This used to be TWO adjacent disclosures -- "More about this calculator"
// and the proof -- so the same reference material sat behind two clicks in
// two places and the reader had to guess which one held their sentence.
// Same treatment, same order, as the static shell.
const proof = document.createElement("details");
proof.className = "proof";
const proofSummary = document.createElement("summary");
proofSummary.textContent = "Details, formula, and sources";
proof.appendChild(proofSummary);
const detailText = restOfDescription(tool.desc);
if (detailText) {
const detailBody = document.createElement("p");
detailBody.className = "view-detail";
detailBody.textContent = detailText;
proof.appendChild(detailBody);
}
proof.appendChild(citation);
const sources = document.createElement("section");
sources.className = "sources-region";
sources.setAttribute("aria-label", "Sources");
proof.appendChild(sources);
view.appendChild(proof);
// v6 §3 / §7: lazy-load the structured citation map. When the tile id has
// a structured CITATIONS entry, mount the six-line reference block under
// the sources region and add a "Copy answer with full reference block"
// button that emits the §3 plain-text format. Tiles not yet audited
// continue to render the legacy inline citation only.
import("./citations.js").then((cit) => {
const block = cit.renderCitationBlock(sources, id);
if (block) {
// The structured block states the formula, the edition, the free-access
// pointer and what governs, in six labelled rows. The renderer's own
// one-line `Citation: ...` says the same thing again, a few lines above
// it, inside the same disclosure -- so voltage-drop printed its formula
// twice, once as "V_drop = 2*K*I*D / cmils" and once as "VD = 2 * I * R
// * L", and a reader had to work out that those are one equation.
//
// The comment below has always described the inline line as the
// fallback for "tiles not yet audited"; showing BOTH was the gap
// between that intent and the code. The static shell has never printed
// it -- check-shells has passed for 1,709 pages without it -- so this
// makes the live view agree with the page it mirrors.
//
// Hidden rather than removed: every renderer is handed this element and
// writes into it, and `hidden` takes it out of the a11y tree and the
// printed page while keeping that contract intact.
citation.hidden = true;
const copyBtn = document.createElement("button");
copyBtn.type = "button";
copyBtn.className = "view-copy-reference";
copyBtn.textContent = "Copy answer with full reference block";
copyBtn.addEventListener("click", async () => {
try {
const cb = await import("./clipboard.js");
// Build the answer from the structured (label, value) rows -- the
// same extraction the "Copy all" button uses -- NOT
// outputRegion.textContent, which fuses each value with its per-line
// "Copy" button label ("Needed final score: 88CopyMax / min ...").
const answerSummary = cb.collectOutputs(outputRegion)
.map((r) => r.label + ": " + r.value).join("\n");
const text = cit.buildAnswerWithReference(tool.name, answerSummary, id);
cb.copyText(text, copyBtn);
} catch {
// Fallback: leave the text on the page in a focusable element.
}
});
block.appendChild(copyBtn);
}
}).catch(() => { /* citation block is opt-in per tile; missing map is fine */ });
const ds = TOOL_DATA_SOURCES[id];
// Lazy-load the calculator module + support libs.
loadRenderer(id).then(async (renderer) => {
if (!renderer) {
const placeholder = document.createElement("p");
placeholder.textContent = "This calculator is not available.";
inputRegion.appendChild(placeholder);
return;
}
const libs = await loadSupportLibs();
// Crash-Safe Resume (v3 utility 187). Wrap each renderer body in a
// try/catch boundary. On uncaught error, log the tool id + input
// snapshot and render a small recovery panel without clearing the URL
// hash so the user can reload or paste the URL into a new tab.
try {
renderer(inputRegion, outputRegion, citation, params || {});
relocateConstantNote(id, outputRegion, proof, citation);
libs.applyHashState(inputRegion, params || {});
libs.wireHashState(inputRegion, id);
// spec-v1341: caption the fields the reader's own words filled. Consumed
// once, and cleared whether or not it was used, so the next navigation
// starts clean.
applyQueryPrefill(inputRegion, id, params || {});
// spec-v1338: the answer sits above the inputs now, so it must not show
// a column of empty labels before anything is typed. Renderers compute
// on a debounce, so re-check after their timer rather than inline.
syncAnswerVisibility(outputRegion);
// Watch the OUTPUT, not the input events. "Test with example" fills the
// fields through the renderer's own update path without necessarily
// dispatching an input event the region would hear, so listening on the
// inputs left a populated answer hidden -- which is how this hid the
// loan-amortization schedule, its CSV export button, and Copy-all.
// A MutationObserver sees the answer change however it was produced.
try {
const watcher = new MutationObserver(() => syncAnswerVisibility(outputRegion));
watcher.observe(outputRegion, { childList: true, subtree: true, characterData: true });
} catch { /* visibility is cosmetic; never block the calculator for it */ }
if (params && params.example === "1") {
const exBtn = inputRegion.querySelector("button");
if (exBtn && /example/i.test(exBtn.textContent || "")) exBtn.click();
}
if (ds) libs.stampDataSource(sources, ds);
libs.addCopyAllButton(outputRegion, { title: tool.name });
libs.wireValidity(inputRegion, outputRegion);
} catch (err) {
console.error("[crash-safe] calculator threw", { tool: id, params, error: err });
mountCrashPanel(view, id);
}
}).catch((err) => {
const placeholder = document.createElement("p");
placeholder.textContent = "Failed to load calculator: " + (err && err.message ? err.message : "unknown error");
inputRegion.appendChild(placeholder);
});
// Apply any preloaded params (visible to calculator implementations later).
view.dataset.params = JSON.stringify(params || {});
// Move focus for screen reader and keyboard users.
h1.tabIndex = -1;
h1.focus({ preventScroll: false });
}
// --- Home search (combobox) ---
//
// One search bar. Type free text to filter the catalog, or focus the empty
// field to browse every tool. Matches render in a results dropdown that
// routes to the tile on click / Enter / arrow-select. Industry-term and
// question aliases (the per-group data/search/aliases-<letter>.json shards,
// spec-v590 split) lazy-load on first focus so a free-text term resolves to
// its target tile; the SW pre-caches the shards so the fetch is local after
// first install.
function bindSearch() {
const input = document.getElementById("search-input");
const list = document.getElementById("search-results");
if (!input || !list) return;
// The TOOLS registry is lazy-loaded; these indexes are built on first
// interaction (focus / keystroke), behind ensureTools(), so the bare
// home view never pulls tools-data.js.
let nameToId = new Map();
let ALL = [];
let searchReady = false;
function initSearchData() {
if (searchReady) return;
nameToId = new Map(TOOLS.map((t) => [t.name.toLowerCase(), t.id]));
ALL = TOOLS.slice().sort((a, b) => a.name.localeCompare(b.name));
searchReady = true;
}
let matches = [];
let activeIndex = -1;
// spec-v1343: did the READER choose this row, or did the list merely
// highlight the first one for them? render() calls setActive(0), so
// activeIndex is 0 the moment anything is typed and every Enter looks like a
// deliberate pick. Without this flag the ambiguity check below never runs and
// the whole feature ships silently dead -- which is exactly what happened on
// sophiewell before its own v756 was debugged.
let userPicked = false;
// Alias terms map a free-text phrase to a tile id; loaded lazily.
// Row shape matches the shard ({ term, target }) so the rows feed
// rankTools directly. Reassigned (not mutated) on load so the ranker's
// per-array caches never go stale. The corpus is split per tile group
// (data/search/aliases-<letter>.json, spec-v590 remediation, generated
// by scripts/build-alias-shards.mjs); all group shards fetch in
// parallel and each folds in as it arrives, so alias search becomes
// usable progressively instead of waiting on one monolithic shard.
let aliasRows = [];
let aliasLoaded = false;
async function ensureAliases() {
if (aliasLoaded) return;
aliasLoaded = true;
const merged = [];
const groups = [...new Set(TOOLS.map((t) => t.group))];
await Promise.all(groups.map(async (g) => {
try {
const file = "aliases-" + String(g).toLowerCase() + ".json";
const r = await fetch("data/search/" + file, { credentials: "omit" });
if (!r.ok) return;
const text = await r.text();
await verifyShard("search", file, text);
const json = JSON.parse(text);
if (!json || !Array.isArray(json.aliases)) return;
const rows = [];
for (const row of json.aliases) {
if (!row || typeof row.term !== "string" || typeof row.target !== "string") continue;
if (!nameToId.has(row.target) && !TOOLS.some((t) => t.id === row.target)) continue;
rows.push({ term: row.term.toLowerCase(), target: row.target });
}
merged.push(...rows);
aliasRows = merged.slice();
// Refresh the open dropdown so just-loaded aliases become searchable.
// The condition is "results are ON SCREEN", not "the input has focus":
// a reader who pastes a query and clicks away, or a browser that moves
// focus while a shard is in flight, would otherwise be left looking at
// the pre-alias results with no keystroke coming to correct them. It
// still never opens a CLOSED dropdown, which is what the focus check
// was really protecting.
if (document.activeElement === input || !list.hidden) render(input.value, true);
} catch { /* one group failing leaves the rest searchable */ }
}));
// A transient failure must not cost the session its aliases. Without them
// the ranking is visibly worse -- "asphalt tonnage 2400 sq ft" leads with a
// carpet takeoff -- and the only recovery was a full reload, because
// `aliasLoaded` was already latched. `ensureDiscovery` has always released
// its flag on failure so the next keystroke retries; this does the same
// when NOTHING loaded. Partial success keeps what arrived rather than
// re-fetching every group on each keystroke.
if (!aliasRows.length) aliasLoaded = false;
}
// The spec-v589 pure ranking layer (normalizeQuery / rankTools) loads
// lazily alongside ensureTools so the bare home view never pulls it.
let discovery = null;
let discoveryLoading = false;
function ensureDiscovery() {
if (discovery || discoveryLoading) return;
discoveryLoading = true;
import("./search-discovery.js").then((mod) => {
discovery = mod;
if (document.activeElement === input || !list.hidden) render(input.value, true);
}).catch(() => { discoveryLoading = false; });
}
// spec-v591 slot tables: tile id -> { slots: [{ param, units }] }.
// Lazy-loaded with the aliases on first search interaction; failure is
// a no-op (picks navigate to the bare tile hash, exactly as before).
let slotsByTile = null;
let slotsLoading = false;
function ensureSlots() {
if (slotsByTile || slotsLoading) return;
slotsLoading = true;
fetch("data/search/slots.json", { credentials: "omit" })
.then((r) => (r.ok ? r.text() : null))
.then(async (t) => (t === null ? null : (await verifyShard("search", "slots.json", t), JSON.parse(t))))
.then((json) => {
if (!json || !Array.isArray(json.tiles)) return;
slotsByTile = new Map();
for (const row of json.tiles) {
if (row && typeof row.tile === "string" && Array.isArray(row.slots)) {
slotsByTile.set(row.tile, row);
}
}
// Landing late must not cost the reader the feature. schedulePreview()
// needs discovery AND slots AND the preview map, and it bails silently
// when one is missing, so a dependency that arrives after the last
// render leaves the dropdown permanently without its answer -- nothing
// re-runs until the next keystroke, and a reader who has finished
// typing never sends one. ensureDiscovery() has always re-rendered for
// exactly this reason; slots and the preview map now do the same.
if (document.activeElement === input || !list.hidden) render(input.value, true);
})
// Release the latch so the next keystroke retries, as ensureDiscovery
// does; a blip on the first search otherwise disables prefill until reload.
.catch(() => { slotsLoading = false; });
}
// Prefill hash for a picked tile: numbers-with-units in the typed query
// map onto the tile's hash-state params (spec-v591). Keys come only
// from the static shard; values are parser-canonical decimal strings.
function prefillHash(tool, typed) {
if (!discovery || !slotsByTile || !typed) return tool.id;
const row = slotsByTile.get(tool.id);
if (!row) return tool.id;
const params = discovery.mapSlots(discovery.extractQuantities(typed), row);
if (!params) return tool.id;
return tool.id + "?v=1&" + new URLSearchParams(params).toString();
}
// spec-v592 live answer preview. The map is a lazy shard; the compute
// is the same lazily-imported module export the tile itself calls. Any
// failure renders nothing: the preview only ever adds to a result row.
let previewMap = null;
let previewLoading = false;
function ensurePreview() {
if (previewMap || previewLoading) return;
previewLoading = true;
fetch("data/search/preview-map.json", { credentials: "omit" })
.then((r) => (r.ok ? r.text() : null))
.then(async (t) => (t === null ? null : (await verifyShard("search", "preview-map.json", t), JSON.parse(t))))
.then((json) => {
if (!json || !json.tiles) return;
previewMap = json.tiles;
// Same late-arrival re-render as ensureSlots(); see the note there.
if (document.activeElement === input || !list.hidden) render(input.value, true);
})
// Same latch release: a blip otherwise costs the session its answer
// previews entirely, with no retry but a reload.
.catch(() => { previewLoading = false; });
}
let previewTimer = 0;
let previewSeq = 0;
function schedulePreview(topTool, typed, rowEl) {
if (!previewMap || !discovery || !slotsByTile) return;
const entry = previewMap[topTool.id];
const slotRow = slotsByTile.get(topTool.id);
if (!entry || !slotRow) return;
const mapped = discovery.mapSlots(discovery.extractQuantities(typed), slotRow);
if (!mapped) return;
const seq = ++previewSeq;
clearTimeout(previewTimer);
previewTimer = setTimeout(() => {
import(entry.module).then((mod) => {
if (seq !== previewSeq || !rowEl.isConnected) return;
const fn = mod[entry.fn];
if (typeof fn !== "function") return;
const args = { ...entry.defaults };
for (const [param, argName] of Object.entries(entry.args)) {
if (param in mapped) args[argName] = Number(mapped[param]);
}
let result;
try { result = fn(args); } catch { return; }
if (!result || typeof result !== "object" || result.error) return;
const parts = [];
for (const h of entry.headline) {
const v = Number(result[h.key]);
if (!Number.isFinite(v)) return;
parts.push(h.label + " " + v.toFixed(h.decimals) + (h.unit ? " " + h.unit : ""));
}
const span = document.createElement("span");
span.className = "sr-preview";
span.textContent = parts.join(" / ");
rowEl.appendChild(span);
}).catch(() => { /* preview only ever adds */ });
}, 150);
}
// Rank tiles for a query. Preferred path (spec-v589): stopword-stripped
// token ranking via search-discovery.js rankTools. Fallback (module not
// yet loaded, or the query normalizes to nothing, e.g. a bare "how"):
// the original substring pass over name, then description, then alias
// terms. Empty query lists the catalog A-Z.
// Ranked-result metadata for the most recent searchTools call that went
// through rankTools; null when the substring fallback answered. Feeds
// the spec-v592 did-you-mean row and the answer preview.
let lastRanked = null;
// An exact address -- a tile's own id, or a phrase curated against it --
// moves to the front; everything else keeps its order. See promoteExactMatch
// in search-discovery.js, which both doors call. `lastRanked.rows` is
// reordered with the list because the did-you-mean row and the answer
// preview read row 0.
let toolIdSet = null;
function promoteExact(q, ranked) {
if (!discovery.promoteExactMatch) return ranked;
return discovery.promoteExactMatch(q, ranked, TOOLS, aliasRows, {
limit: 12,
ids: (toolIdSet ||= new Set(TOOLS.map((t) => t.id))),
});