Compare commits

..

109 Commits

Author SHA1 Message Date
Ben
63bafc2feb chore: Remove title, description, and viewport meta tags from app.html. 2026-03-11 11:26:22 -07:00
Ben
d23e910aa7 feat: enhance value formatting by adding scientific notation for extremely small numbers. 2026-03-10 14:48:00 -07:00
Ben
ae95a66668 fix: deduplicate SEO tags in app.html 2026-03-09 13:44:39 -07:00
Ben
0d099e34cd feat: add title and meta description, and relocate SvelteKit head tags to the top of the head. 2026-03-09 13:40:23 -07:00
Codex Agent
b44e9e5702 Normalize conversion factor formatting 2026-03-09 19:50:26 +00:00
Codex Agent
c20f2ebc60 Ensure calculator factors are explicit 2026-03-09 19:22:48 +00:00
Codex Agent
56873816bb Adjust calc grid max columns 2026-03-09 19:17:59 +00:00
Codex Agent
07a299275b Gause to orsted 2026-03-09 18:58:58 +00:00
Codex Agent
17ed319fe7 Increase category tooltip opacity 2026-03-09 18:49:04 +00:00
Codex Agent
de799c3a7b Fix category tooltip layering and placement 2026-03-09 18:46:58 +00:00
Codex Agent
b1bf3f40d8 Keep tooltip above cursor 2026-03-09 18:43:25 +00:00
Codex Agent
e4987d6764 Track tooltip vertically 2026-03-09 18:41:48 +00:00
Codex Agent
6af3b23987 Make category tooltips track cursor 2026-03-09 18:40:22 +00:00
Codex Agent
66d02c7f14 Add conversion-rate tooltips to category calculator cards 2026-03-09 18:36:47 +00:00
Codex Agent
5651ecb6d4 Fix calculator loader URL for SSR 2026-03-09 07:58:28 +00:00
Codex Agent
3e26376584 Fix slug lookup and add regression test 2026-03-09 07:55:17 +00:00
Ben
c006971cbe feat: Prerender the homepage, update HTML cache control, and inline critical CSS for improved performance. 2026-03-08 20:01:47 -07:00
Ben
de4fa5ba85 feat: add color palette definitions and data for various themes. 2026-03-08 19:51:39 -07:00
Ben
379bb60722 feat: Update total calculator count to include all calculators by modifying the migration script. 2026-03-08 19:34:07 -07:00
Ben
3ae77e02a0 refactor: Optimize sidebar data loading, update font to ExtraBold, and adjust blur effects for category cards and header. 2026-03-08 19:20:53 -07:00
Ben
1093208324 refactor: Centralize calculator statistics and categories into a new module, lazy load search functionality, and remove unused font preloads. 2026-03-08 19:15:42 -07:00
Ben
0114e00618 style: Reduce site logo gap and split 'How Do You' text into individual spans. 2026-03-08 14:01:36 -07:00
Ben
2794835590 feat: Add Python consistency tests for calculator definitions and refactor QuickConversionTable to explicitly pass calculator configuration to its row builder. 2026-03-08 13:41:50 -07:00
Ben
193affca27 fix: Embed JSON-LD scripts using {@html} for correct rendering. 2026-03-08 13:16:17 -07:00
Codex Agent
4c989ef1b3 Fix sidebar unit navigation to include reverse conversion pairs 2026-03-08 20:06:46 +00:00
Codex Agent
a1758a9074 Remove src param compatibility from shared links 2026-03-08 19:53:18 +00:00
Codex Agent
afe72b9ee1 Use minimal share params and source-aware hydration 2026-03-08 19:49:08 +00:00
Codex Agent
cf1114e8d8 Fix share-link serialization and tooltip positioning 2026-03-08 19:44:30 +00:00
Codex Agent
02b9c2411f Fix calculator link copy icon and tooltips 2026-03-08 19:40:30 +00:00
Codex Agent
2dca3654a8 Fix duplicate hasInputs declaration in Calculator 2026-03-08 19:31:29 +00:00
Codex Agent
51a26ad120 Update sidebar unit grouping and apply layout/page updates 2026-03-08 19:29:26 +00:00
Codex Agent
6a0347fd22 Sidebar updates 2026-03-08 19:27:44 +00:00
Codex Agent
edb08e3e5c Simplify share button 2026-03-08 18:58:55 +00:00
Codex Agent
700953194a Add share link button 2026-03-08 18:55:31 +00:00
Codex Agent
f315ff1dc1 Revert "Improve scientific notation formatting"
This reverts commit ca7632bf25.
2026-03-08 18:41:14 +00:00
Codex Agent
cf0b72269c Revert "Adjust exponent alignment"
This reverts commit 723f932356.
2026-03-08 18:41:13 +00:00
Codex Agent
723f932356 Adjust exponent alignment 2026-03-08 10:48:33 +00:00
Codex Agent
ca7632bf25 Improve scientific notation formatting 2026-03-08 10:46:36 +00:00
Codex Agent
7996f31f32 Raise exponent notation in conversion chart 2026-03-08 10:22:07 +00:00
Codex Agent
0e33835ad6 Scientific notation 2026-03-08 09:39:58 +00:00
Codex Agent
60e462e987 Fix rounding to 0 2026-03-08 09:35:47 +00:00
Codex Agent
de3fc810ba Mobile layout adjustment 2026-03-08 09:01:10 +00:00
Codex Agent
6f4fa72a03 Add time, inch, and energy conversions 2026-03-08 08:54:50 +00:00
Codex Agent
f5a62783ff Add more inch & speed conversions 2026-03-08 08:51:14 +00:00
Codex Agent
acd5a0e2af Add extended binary data conversions 2026-03-08 08:47:48 +00:00
Codex Agent
41e8735720 Add tola-based mass conversions 2026-03-08 08:39:03 +00:00
Codex Agent
fa899e5b58 Add US gallon and pint conversions 2026-03-08 08:35:36 +00:00
Codex Agent
9430abfd32 Add yard, year, yoctogram conversions 2026-03-08 08:31:44 +00:00
Codex Agent
a97dec15c1 Add weeks and word conversions 2026-03-08 08:20:09 +00:00
Codex Agent
f6cacd29ff Add speed and energy conversions 2026-03-08 08:14:33 +00:00
Codex Agent
b6f5ebe0e6 Add inches-of-water and kilopascals conversions 2026-03-08 08:09:35 +00:00
Codex Agent
3f9b710555 Add imperial pint and inches-of-mercury conversions 2026-03-08 08:03:53 +00:00
Codex Agent
d2d482768c Add imperial gallons and jiggers conversions 2026-03-08 07:57:07 +00:00
Codex Agent
b7e0a52e9a Add imperial volume conversions 2026-03-08 07:53:59 +00:00
Codex Agent
ba6b791362 Add long hundredweight conversions 2026-03-08 07:48:01 +00:00
Codex Agent
f09b59c3d3 Area icon update 2026-03-08 07:38:51 +00:00
Codex Agent
5bfd6530f0 Add hundredweight, hour, and hertz conversions 2026-03-08 07:07:02 +00:00
Codex Agent
ce41c39df5 Add Hartree, Hectares, hours, hogshead calculators 2026-03-08 06:54:23 +00:00
Codex Agent
af9bd1ae44 Add hectopascals and hogshead backlog calculators 2026-03-08 06:46:13 +00:00
Codex Agent
2f53ee3997 Add Gray and Hands backlog calculators 2026-03-08 06:40:34 +00:00
Codex Agent
2dc14a37b7 Add grams & gray backlog calculators 2026-03-08 06:09:40 +00:00
Codex Agent
3863f33e23 Add grain-heavy backlog conversions 2026-03-08 06:03:56 +00:00
Codex Agent
01cf8ad11b Add next backlog calculators 2026-03-08 05:53:34 +00:00
Codex Agent
edfc04b3c2 Add next 40 backlog calculators 2026-03-08 05:41:34 +00:00
Codex Agent
21e39e9268 Add next 40 backlog calculators 2026-03-08 05:40:04 +00:00
Codex Agent
3aecadd755 Add next 40 backlog calculators (Fermi + fluid ounces) 2026-03-08 05:38:39 +00:00
Codex Agent
e0585422f2 Add next 40 backlog calculators 2026-03-08 05:37:09 +00:00
Codex Agent
252f1ecb27 Add next 40 backlog calculators 2026-03-08 05:34:11 +00:00
Codex Agent
ccd6d51bf3 Add dunam and dyne/cm² converters from backlog 2026-03-08 05:28:21 +00:00
Codex Agent
328b0ece6a Normalize converter labels and fix card grid sizing 2026-03-08 05:14:22 +00:00
Codex Agent
cf74f06de0 Add gauss, Gbps, and gibibyte/gigabit converters 2026-03-08 05:12:36 +00:00
Codex Agent
5befd30eb8 Add gallons and gamma mass converters 2026-03-08 05:07:24 +00:00
Codex Agent
cbd4871780 Add fluid-ounce, foot-pound, fortnight, and feet conversions 2026-03-08 04:57:20 +00:00
Codex Agent
cc91d89b8c Add feet and femtogram converters 2026-03-08 04:52:38 +00:00
Codex Agent
5f5441c52c Add fathom, seawater, and exabyte converters 2026-03-08 04:46:16 +00:00
Codex Agent
01964bf1f7 Add earth-mass and dynes conversions 2026-03-08 04:41:13 +00:00
Codex Agent
8cab02a627 Add dram converters and mark backlog items 2026-03-08 04:37:30 +00:00
Codex Agent
c470d41ce3 Add disintegration and fluid dram converters 2026-03-08 04:32:30 +00:00
Codex Agent
53606f9373 Add Dalton and time converters 2026-03-08 04:25:31 +00:00
Codex Agent
1656bfb617 Add cups and remaining cubit conversions 2026-03-08 04:20:32 +00:00
Codex Agent
cc8c3a7579 Add cubic-meter and cubit converters 2026-03-08 04:18:22 +00:00
Codex Agent
251e33e8ac Add cubic-inch and cubic-meter volume converters 2026-03-08 04:15:53 +00:00
Codex Agent
986b67226f Add cubic-centimeter and cubic-foot volume converters 2026-03-08 04:13:54 +00:00
Codex Agent
ca164dd259 Add cmHg, cm/s, and remaining clove converters 2026-03-08 03:45:50 +00:00
Codex Agent
d7bf6e8135 Add circular mil and clove converters 2026-03-08 03:38:39 +00:00
Codex Agent
48d255dbe9 Add next 40 backlog calculators (century, cfs, chain, circular mil) 2026-03-08 03:35:02 +00:00
Codex Agent
35c8f721ed Add next 40 backlog calculators 2026-03-08 03:30:16 +00:00
Codex Agent
97df622661 Add next 40 backlog calculators (centigram mass & centiliter volumes) 2026-03-08 03:23:39 +00:00
Codex Agent
c0deb9a6ea Add next 40 backlog calculators (carat & centigram mass conversions) 2026-03-08 03:21:44 +00:00
Codex Agent
c68ad9704e Fix 3-col calculator labelling and add lint 2026-03-08 03:20:08 +00:00
Codex Agent
c7dda1f142 Add next 40 backlog calculators (calorie power, carat masses, cable/illuminance) 2026-03-08 03:20:01 +00:00
Codex Agent
10f05d75d2 Add next 40 backlog calculators (bushels US measures, bytes, cable conversions) 2026-03-08 03:16:49 +00:00
Codex Agent
58a207222c Add next 40 backlog calculators (BTU, bushels volumes, data already covered) 2026-03-08 03:06:45 +00:00
Codex Agent
cb915afb9f Add next 40 backlog calculators (barye, becquerel, bits, BTU set) 2026-03-08 03:03:59 +00:00
Codex Agent
1ab2c1bc06 Add next 40 backlog calculators (barns, barrels, barye conversions) 2026-03-08 03:00:17 +00:00
Codex Agent
25a671535a Add next 40 backlog calculators (banana dose, bar/barn, awg-swg) 2026-03-08 02:58:17 +00:00
Codex Agent
962b6d090d Add next 40 backlog calculators (attogram mass + atomic time variants) 2026-03-08 02:55:22 +00:00
Codex Agent
82f8cbbfe7 Add next 40 backlog calculators (AMU mass set + atomic time conversions) 2026-03-08 02:54:20 +00:00
Codex Agent
1c3476aa05 Add next 40 backlog calculators (AU lengths, atmosphere variants, AMU conversions) 2026-03-08 02:53:14 +00:00
Codex Agent
e2fa126c2a Add next 40 backlog calculators (apothecary oz, ares, AU conversions) 2026-03-08 02:51:41 +00:00
Codex Agent
2fdd433efb Add next 40 backlog calculators (angstrom conversions + apothecary ounce mass) 2026-03-08 02:37:42 +00:00
Codex Agent
489b7a1a04 Add next 40 backlog calculators (amu conversions + angstrom lengths) 2026-03-08 02:35:22 +00:00
Codex Agent
0b14e802b5 Mark next 40 calculators complete 2026-03-08 02:22:52 +00:00
Codex Agent
1e341f051f Add next calculator batch incl molarity and SWG 2026-03-08 02:19:18 +00:00
Codex Agent
5e974ea9c7 Add next calculators set with flow, magnetism, and EV conversions 2026-03-08 01:55:19 +00:00
Codex Agent
e9e5adce42 Add next batch of calculators and AWG support 2026-03-08 01:49:50 +00:00
Codex
68bc636af8 Add yocto/zepto mass converters 2026-03-08 01:02:40 +00:00
Codex
bae0d70199 Add thermal property converters 2026-03-08 00:55:40 +00:00
Codex
964254005d Add electrical category conversions 2026-03-08 00:39:47 +00:00
40 changed files with 61158 additions and 3330 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,8 @@
"start": "node build",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint:3col": "node scripts/lint-3col.mjs"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.5.4",

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env node
import { readFileSync } from 'fs';
import path from 'path';
const file = path.resolve('src/lib/data/calculators.ts');
const text = readFileSync(file, 'utf8');
const start = text.indexOf('[');
const end = text.indexOf('];', start);
if (start === -1 || end === -1) {
console.error('Unable to locate calculators array in calculators.ts');
process.exit(1);
}
let arr = text.slice(start, end + 1).trim();
arr = arr.replace(/,\s*\]$/, ']');
let calculators;
try {
calculators = JSON.parse(arr);
} catch (err) {
console.error('Failed to parse calculators.ts as JSON:', err.message);
process.exit(1);
}
const offenders = calculators.filter(c =>
['3col', '3col-mul'].includes(c.type) &&
c.name.toLowerCase().includes(' to ') &&
(!c.labels?.in3 || c.labels.in3.toLowerCase() === 'result')
);
if (offenders.length) {
console.error(`3-col calculators with vague or missing output labels: ${offenders.length}`);
offenders.slice(0, 20).forEach(c => console.error(`- ${c.slug}`));
if (offenders.length > 20) console.error(`…and ${offenders.length - 20} more`);
process.exit(1);
}
console.log('3-col label lint passed.');

15730
hdyc-svelte/sitemap.xml Normal file

File diff suppressed because it is too large Load Diff

116
hdyc-svelte/sitemap.xsd Normal file
View File

@@ -0,0 +1,116 @@
<?xml version="1.0" encoding="UTF-8"?>
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema"
targetNamespace="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
elementFormDefault="qualified">
<xsd:annotation>
<xsd:documentation>
XML Schema for Sitemap files.
Last Modifed 2008-03-26
</xsd:documentation>
</xsd:annotation>
<xsd:element name="urlset">
<xsd:annotation>
<xsd:documentation>
Container for a set of up to 50,000 document elements.
This is the root element of the XML file.
</xsd:documentation>
</xsd:annotation>
<xsd:complexType>
<xsd:sequence>
<xsd:any namespace="##other" minOccurs="0" maxOccurs="unbounded" processContents="strict"/>
<xsd:element name="url" type="tUrl" maxOccurs="unbounded"/>
</xsd:sequence>
</xsd:complexType>
</xsd:element>
<xsd:complexType name="tUrl">
<xsd:annotation>
<xsd:documentation>
Container for the data needed to describe a document to crawl.
</xsd:documentation>
</xsd:annotation>
<xsd:sequence>
<xsd:element name="loc" type="tLoc"/>
<xsd:element name="lastmod" type="tLastmod" minOccurs="0"/>
<xsd:element name="changefreq" type="tChangeFreq" minOccurs="0"/>
<xsd:element name="priority" type="tPriority" minOccurs="0"/>
<xsd:any namespace="##other" minOccurs="0" maxOccurs="unbounded" processContents="strict"/>
</xsd:sequence>
</xsd:complexType>
<xsd:simpleType name="tLoc">
<xsd:annotation>
<xsd:documentation>
REQUIRED: The location URI of a document.
The URI must conform to RFC 2396 (http://www.ietf.org/rfc/rfc2396.txt).
</xsd:documentation>
</xsd:annotation>
<xsd:restriction base="xsd:anyURI">
<xsd:minLength value="12"/>
<xsd:maxLength value="2048"/>
</xsd:restriction>
</xsd:simpleType>
<xsd:simpleType name="tLastmod">
<xsd:annotation>
<xsd:documentation>
OPTIONAL: The date the document was last modified. The date must conform
to the W3C DATETIME format (http://www.w3.org/TR/NOTE-datetime).
Example: 2005-05-10
Lastmod may also contain a timestamp.
Example: 2005-05-10T17:33:30+08:00
</xsd:documentation>
</xsd:annotation>
<xsd:union>
<xsd:simpleType>
<xsd:restriction base="xsd:date"/>
</xsd:simpleType>
<xsd:simpleType>
<xsd:restriction base="xsd:dateTime"/>
</xsd:simpleType>
</xsd:union>
</xsd:simpleType>
<xsd:simpleType name="tChangeFreq">
<xsd:annotation>
<xsd:documentation>
OPTIONAL: Indicates how frequently the content at a particular URL is
likely to change. The value "always" should be used to describe
documents that change each time they are accessed. The value "never"
should be used to describe archived URLs. Please note that web
crawlers may not necessarily crawl pages marked "always" more often.
Consider this element as a friendly suggestion and not a command.
</xsd:documentation>
</xsd:annotation>
<xsd:restriction base="xsd:string">
<xsd:enumeration value="always"/>
<xsd:enumeration value="hourly"/>
<xsd:enumeration value="daily"/>
<xsd:enumeration value="weekly"/>
<xsd:enumeration value="monthly"/>
<xsd:enumeration value="yearly"/>
<xsd:enumeration value="never"/>
</xsd:restriction>
</xsd:simpleType>
<xsd:simpleType name="tPriority">
<xsd:annotation>
<xsd:documentation>
OPTIONAL: The priority of a particular URL relative to other pages
on the same site. The value for this element is a number between
0.0 and 1.0 where 0.0 identifies the lowest priority page(s).
The default priority of a page is 0.5. Priority is used to select
between pages on your site. Setting a priority of 1.0 for all URLs
will not help you, as the relative priority of pages on your site
is what will be considered.
</xsd:documentation>
</xsd:annotation>
<xsd:restriction base="xsd:decimal">
<xsd:minInclusive value="0.0"/>
<xsd:maxInclusive value="1.0"/>
</xsd:restriction>
</xsd:simpleType>
</xsd:schema>

View File

@@ -10,7 +10,7 @@
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
font-display: optional;
src: url('/fonts/inter/Inter-Medium.woff2') format('woff2');
}
@@ -18,7 +18,7 @@
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
font-display: optional;
src: url('/fonts/inter/Inter-SemiBold.woff2') format('woff2');
}
@@ -26,7 +26,7 @@
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
font-display: optional;
src: url('/fonts/inter/Inter-Bold.woff2') format('woff2');
}
@@ -42,7 +42,7 @@
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
font-display: optional;
src: url('/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2') format('woff2');
}
@@ -50,7 +50,7 @@
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
font-display: optional;
src: url('/fonts/jetbrains-mono/JetBrainsMono-Medium.woff2') format('woff2');
}
@@ -58,7 +58,7 @@
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
font-display: optional;
src: url('/fonts/jetbrains-mono/JetBrainsMono-SemiBold.woff2') format('woff2');
}
@@ -343,11 +343,16 @@ a:focus-visible {
justify-content: space-between;
padding: 0 1.5rem;
background: var(--header-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid var(--border);
}
@media (min-width: 1025px) {
.site-header {
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
}
.header-left {
display: flex;
align-items: center;
@@ -357,7 +362,7 @@ a:focus-visible {
.site-logo {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.3rem;
text-decoration: none;
color: var(--text);
font-weight: 800;
@@ -640,10 +645,12 @@ a:focus-visible {
margin: 0 auto;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: minmax(132px, auto);
gap: clamp(0.75rem, 1.3vw, 1.25rem);
}
.category-grid .category-card {
width: 100%;
height: 100%;
}
@media (min-width: 640px) {
@@ -662,11 +669,17 @@ a:focus-visible {
.calc-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
width: 100%;
max-width: calc(4 * 360px + 3 * 0.75rem);
margin: 0 auto;
gap: 0.75rem;
}
.calc-list-item {
display: block;
position: relative;
--calc-tooltip-left: 50%;
--calc-tooltip-translate: -0.35rem;
padding: 1rem 1.25rem;
background: var(--card-bg);
border: 1px solid var(--border);
@@ -682,11 +695,40 @@ a:focus-visible {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
color: var(--accent);
z-index: 20;
}
.calc-list-item:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
z-index: 20;
}
.calc-list-tooltip {
position: absolute;
bottom: var(--calc-tooltip-bottom, calc(100% + 0.4rem));
top: var(--calc-tooltip-top, auto);
left: var(--calc-tooltip-left, 50%);
background: color-mix(in srgb, var(--bg-elevated) 92%, black 8%);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.35rem 0.55rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.02em;
pointer-events: none;
opacity: 0;
visibility: hidden;
transform: translate(-50%, calc(var(--calc-tooltip-translate, -0.35rem) + 0.15rem));
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease;
white-space: nowrap;
z-index: 10;
}
.calc-list-item:hover .calc-list-tooltip,
.calc-list-item:focus-visible .calc-list-tooltip {
opacity: 1;
visibility: visible;
transform: translate(-50%, var(--calc-tooltip-translate, -0.35rem));
}
/* ─── Related Converters ─────────────────────────────────── */
@@ -856,14 +898,24 @@ a:focus-visible {
}
.category-section__inner {
gap: 1rem;
width: 100%;
}
.category-section .section-heading {
margin-bottom: 0.75rem;
text-align: center;
}
.category-grid {
width: min(960px, 100%);
width: 100%;
max-width: 540px;
margin-inline: auto;
grid-template-columns: repeat(2, minmax(140px, 1fr));
gap: clamp(0.7rem, 3vw, 1rem);
justify-content: center;
}
.category-grid .category-card {
flex: 0 1 clamp(150px, 40vw, 200px);
min-width: 150px;
max-width: 200px;
flex: none;
min-width: 0;
max-width: none;
}
.stats-row {
gap: 1.5rem;

View File

@@ -2,7 +2,9 @@
<html lang="en" data-theme="dark" data-palette="classic">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
<link
rel="preload"
href="/fonts/inter/Inter-Regular.woff2"
@@ -10,27 +12,6 @@
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/fonts/inter/Inter-Medium.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/fonts/inter/Inter-SemiBold.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/fonts/inter/Inter-Bold.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/fonts/inter/Inter-ExtraBold.woff2"
@@ -38,27 +19,25 @@
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/fonts/jetbrains-mono/JetBrainsMono-Medium.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/fonts/jetbrains-mono/JetBrainsMono-SemiBold.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<style>
/* Critical CSS inlined to eliminate render-blocking stylesheet for FCP/LCP */
:root{--bg:#0c0f14;--bg-elevated:#12161e;--card-bg:rgba(18,22,30,.85);--border:rgba(255,255,255,.08);--text:#e8ecf4;--text-muted:#7b8498;--accent:#10b981;--accent-dark:#059669;--accent-glow:rgba(16,185,129,.15);--accent-gradient:linear-gradient(135deg,#10b981,#06b6d4);--header-bg:rgba(12,15,20,.85);--header-h:64px;--font-body:'Inter',-apple-system,BlinkMacSystemFont,sans-serif}
:root[data-theme='light']{--bg:#f8fafc;--bg-elevated:#fff;--card-bg:#fff;--border:rgba(15,23,42,.12);--text:#0f172a;--text-muted:#475569;--accent:#047857;--accent-dark:#065f46;--accent-glow:rgba(16,185,129,.15);--accent-gradient:linear-gradient(135deg,#10b981,#06b6d4);--header-bg:rgba(255,255,255,.95)}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{height:100%;-webkit-text-size-adjust:100%}
body{min-height:100vh;font-family:var(--font-body);background:var(--bg);color:var(--text);line-height:1.6}
.site-header{position:sticky;top:0;z-index:50;height:var(--header-h);display:flex;align-items:center;justify-content:space-between;padding:0 1.5rem;background:var(--header-bg);border-bottom:1px solid var(--border)}
.header-left{display:flex;align-items:center;gap:.75rem}
.site-logo{display:flex;align-items:center;gap:.3rem;text-decoration:none;color:var(--text);font-weight:800;font-size:1.15rem;letter-spacing:-.02em;white-space:nowrap}
.site-logo .logo-accent{color:var(--accent)}.site-logo .logo-domain{color:var(--text-muted);font-weight:500}
.hero{text-align:center;padding:3rem 1rem 2rem;margin-bottom:1rem}
.hero h1{font-size:2.5rem;font-weight:800;letter-spacing:-.03em;line-height:1.15;margin-bottom:.75rem;background:var(--accent-gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
.hero p{color:var(--text-muted);font-size:1.1rem;max-width:500px;margin:0 auto 1.5rem}
.stats-row{display:flex;justify-content:center;gap:2.5rem;margin-bottom:2.5rem;padding:1rem 0}
.stat-num{font-size:1.8rem;font-weight:800;color:var(--accent)}
.stat-label{font-size:.78rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em}
.main-content{flex:1;width:100%;max-width:900px;margin:0 auto;padding:2rem 1.25rem 3rem}
</style>
<script>
(function () {
const doc = document.documentElement;
@@ -77,8 +56,9 @@
}
})();
</script>
%sveltekit.head%
<!-- SvelteKit head tags moved to top of <head> -->
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>

View File

@@ -13,7 +13,7 @@ const MIME_TYPES: Record<string, string> = {
'.otf': 'font/otf'
};
const HTML_CACHE_CONTROL = 'public, max-age=0, must-revalidate';
const HTML_CACHE_CONTROL = 'public, max-age=0, s-maxage=3600, stale-while-revalidate=86400';
const IMMUTABLE_ASSET_CACHE_CONTROL = 'public, max-age=31536000, immutable';
const ASSET_404_CACHE_CONTROL = 'no-store';
const LONG_CACHE_EXTENSIONS = new Set([

View File

@@ -2,7 +2,9 @@
import { solve } from '$lib/engine';
import type { CalculatorDef } from '$lib/data/calculators';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import { browser } from '$app/environment';
import { getConversionRateText } from '$lib/utils/conversionRate';
import QuickDefinitionCard from '$lib/components/QuickDefinitionCard.svelte';
import QuickConversionExample from '$lib/components/QuickConversionExample.svelte';
import QuickConversionTable from '$lib/components/QuickConversionTable.svelte';
@@ -14,18 +16,34 @@
let val2 = '';
let val3 = '';
let activeField: 1 | 2 | 3 = 1;
let swapState: { originalField: 1 | 2; originalValue: string } | null = null;
let swapState: { originalField: 1 | 2; originalValue: string | number | null } | null = null;
let copyStatus: 'idle' | 'copied' | 'failed' = 'idle';
let statusTimeout: ReturnType<typeof setTimeout> | null = null;
let tooltipFadeTimeout: ReturnType<typeof setTimeout> | null = null;
let tooltipHideTimeout: ReturnType<typeof setTimeout> | null = null;
let showCopyTooltip = false;
let isTooltipFading = false;
let showHoverTooltip = false;
let footerControlsEl: HTMLDivElement | null = null;
let tooltipX = 20;
let copyStatusMessage = '';
let initializedSlug: string | null = null;
let conversionRateText: string | null = null;
$: has3 = ['3col', '3col-mul'].includes(config.type) || !!config.labels.in3;
$: isTextInput = ['base', 'text-bin', 'bin-text', 'dec-frac', 'dms-dd', 'dd-dms'].includes(config.type);
$: conversionRateText = getConversionRateText(config);
// Clear inputs on config (route) change
$: if (config) {
if (!paramsInitializing) clear();
// Clear inputs only when navigating to a different calculator slug.
$: if (config?.slug) {
if (initializedSlug === null) {
initializedSlug = config.slug;
} else if (initializedSlug !== config.slug) {
initializedSlug = config.slug;
clear();
}
}
let paramsInitializing = true;
function handleInput(source: 1 | 2 | 3, options?: { preserveSwap?: boolean }) {
if (!options?.preserveSwap) {
swapState = null;
@@ -64,12 +82,147 @@
swapState = null;
}
function buildShareUrl() {
const params = new URLSearchParams();
const v1 = toQueryValue(val1);
const v2 = toQueryValue(val2);
const v3 = toQueryValue(val3);
const source: 1 | 2 | 3 = has3 ? activeField : (activeField === 2 ? 2 : 1);
if (!has3) {
const sourceValue = source === 1 ? v1 : v2;
if (sourceValue !== null) {
params.set(source === 1 ? 'v1' : 'v2', sourceValue);
}
} else if (source === 3) {
if (v2 !== null) params.set('v2', v2);
if (v3 !== null) params.set('v3', v3);
} else {
if (v1 !== null) params.set('v1', v1);
if (v2 !== null) params.set('v2', v2);
}
const shareUrl = new URL($page.url);
shareUrl.search = params.toString();
return shareUrl.toString();
}
function toQueryValue(value: unknown): string | null {
if (value === null || value === undefined) {
return null;
}
const stringValue = String(value);
return stringValue.trim() ? stringValue : null;
}
async function copyText(text: string) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.setAttribute('readonly', '');
textArea.style.position = 'absolute';
textArea.style.left = '-9999px';
document.body.appendChild(textArea);
textArea.select();
const copied = document.execCommand('copy');
document.body.removeChild(textArea);
if (!copied) {
throw new Error('execCommand copy failed');
}
}
function triggerCopyTooltip() {
if (tooltipFadeTimeout) clearTimeout(tooltipFadeTimeout);
if (tooltipHideTimeout) clearTimeout(tooltipHideTimeout);
showCopyTooltip = true;
isTooltipFading = false;
tooltipFadeTimeout = setTimeout(() => {
isTooltipFading = true;
}, 900);
tooltipHideTimeout = setTimeout(() => {
showCopyTooltip = false;
isTooltipFading = false;
}, 1300);
}
function updateTooltipPosition(event: MouseEvent) {
if (!footerControlsEl) return;
const rect = footerControlsEl.getBoundingClientRect();
const x = event.clientX - rect.left;
tooltipX = Math.max(12, Math.min(rect.width - 12, x));
}
function positionTooltipFromButton(button: HTMLButtonElement) {
if (!footerControlsEl) return;
const controlsRect = footerControlsEl.getBoundingClientRect();
const buttonRect = button.getBoundingClientRect();
const centerX = buttonRect.left - controlsRect.left + buttonRect.width / 2;
tooltipX = Math.max(12, Math.min(controlsRect.width - 12, centerX));
}
async function copyLink() {
if (!browser) return;
const url = buildShareUrl();
try {
await copyText(url);
copyStatus = 'copied';
triggerCopyTooltip();
} catch (error) {
console.error('Failed to copy link', error);
copyStatus = 'failed';
} finally {
if (statusTimeout) {
clearTimeout(statusTimeout);
}
statusTimeout = setTimeout(() => {
copyStatus = 'idle';
}, 2000);
}
}
$: copyStatusMessage =
copyStatus === 'copied'
? 'Link copied to clipboard'
: copyStatus === 'failed'
? 'Failed to copy link'
: '';
onMount(() => {
const params = new URLSearchParams($page.url.search);
if (params.has('v1')) { val1 = params.get('v1')!; handleInput(1); }
else if (params.has('v2')) { val2 = params.get('v2')!; handleInput(2); }
else if (params.has('v3') && has3) { val3 = params.get('v3')!; handleInput(3); }
setTimeout(() => { paramsInitializing = false; }, 0);
const hasV1 = params.has('v1');
const hasV2 = params.has('v2');
const hasV3 = has3 && params.has('v3');
if (has3 && hasV2 && hasV3) {
val2 = params.get('v2') ?? '';
val3 = params.get('v3') ?? '';
handleInput(3);
} else if (has3 && hasV1 && hasV2) {
val1 = params.get('v1') ?? '';
val2 = params.get('v2') ?? '';
handleInput(1);
} else if (hasV1) {
val1 = params.get('v1') ?? '';
handleInput(1);
} else if (hasV2) {
val2 = params.get('v2') ?? '';
handleInput(2);
} else if (hasV3) {
val3 = params.get('v3') ?? '';
handleInput(3);
}
});
onDestroy(() => {
if (statusTimeout) clearTimeout(statusTimeout);
if (tooltipFadeTimeout) clearTimeout(tooltipFadeTimeout);
if (tooltipHideTimeout) clearTimeout(tooltipHideTimeout);
});
</script>
@@ -142,12 +295,54 @@
</div>
<div class="calc-footer">
<button type="button" class="clear-btn" on:click={clear} aria-label="Clear calculator inputs">
Clear
</button>
{#if config.factor && config.type === 'standard'}
<div class="footer-controls" bind:this={footerControlsEl}>
<button type="button" class="clear-btn" on:click={clear} aria-label="Clear calculator inputs">
Clear
</button>
<button
type="button"
class="icon-btn"
on:click={(event) => {
positionTooltipFromButton(event.currentTarget as HTMLButtonElement);
copyLink();
}}
on:mouseenter={(event) => {
showHoverTooltip = true;
updateTooltipPosition(event);
}}
on:mousemove={updateTooltipPosition}
on:mouseleave={() => (showHoverTooltip = false)}
on:focus={(event) => {
showHoverTooltip = true;
positionTooltipFromButton(event.currentTarget as HTMLButtonElement);
}}
on:blur={() => (showHoverTooltip = false)}
aria-label="Copy calculator link"
>
<svg viewBox="0 0 24 24" role="presentation" aria-hidden="true">
<path
d="M13.5 6.5l1.5-1.5a4.243 4.243 0 0 1 6 6L19.5 12.5M10.5 17.5L9 19a4.243 4.243 0 1 1-6-6L4.5 11.5M8 16l8-8"
fill="none"
stroke="currentColor"
stroke-width="1.9"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{#if showHoverTooltip && !showCopyTooltip}
<span class="copy-tooltip hover" style={`left: ${tooltipX}px;`}>Copy link</span>
{/if}
{#if showCopyTooltip && copyStatus === 'copied'}
<span class="copy-tooltip" class:fading={isTooltipFading} style={`left: ${tooltipX}px;`}>Link copied!</span>
{/if}
<span class="sr-only" aria-live="polite">
{copyStatusMessage}
</span>
</div>
{#if conversionRateText}
<span class="formula-hint">
1 {config.labels.in1} = {config.factor}{config.offset ? ` + ${config.offset}` : ''} {config.labels.in2}
{conversionRateText}
</span>
{/if}
</div>
@@ -284,6 +479,13 @@
padding: 1rem 2rem 1.25rem;
border-top: 1px solid var(--border);
}
.footer-controls {
display: flex;
align-items: center;
gap: 0.5rem;
position: relative;
}
.clear-btn {
padding: 0.5rem 1.25rem;
border: 1px solid var(--border);
@@ -303,6 +505,69 @@
outline: none;
box-shadow: 0 0 0 3px var(--accent-glow);
}
.icon-btn {
width: 40px;
height: 40px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--section-bg);
color: var(--text);
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.icon-btn svg {
width: 1.1rem;
height: 1.1rem;
}
.icon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.icon-btn:not(:disabled):hover {
border-color: var(--accent);
background: var(--surface-hover);
}
.copy-tooltip {
position: absolute;
bottom: calc(100% + 0.4rem);
background: color-mix(in srgb, var(--accent) 90%, black 10%);
color: #fff;
border-radius: 6px;
padding: 0.35rem 0.55rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.02em;
pointer-events: none;
opacity: 1;
transform: translate(-50%, 0);
transition: opacity 0.35s ease, transform 0.35s ease;
white-space: nowrap;
z-index: 2;
}
.copy-tooltip.hover {
background: var(--section-bg);
color: var(--text);
border: 1px solid var(--border);
}
.copy-tooltip.fading {
opacity: 0;
transform: translate(-50%, -0.2rem);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.formula-hint {
font-size: 0.78rem;
color: var(--text-muted);

View File

@@ -14,7 +14,9 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.6rem;
min-height: 132px;
padding: 1.5rem 1rem;
background: var(--card-bg);
border: 1px solid var(--border);
@@ -22,8 +24,6 @@
text-decoration: none;
color: var(--text);
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
cursor: pointer;
}
.category-card:hover {

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { solve } from '$lib/engine';
import type { CalculatorDef } from '$lib/data/calculators';
import { formatConversionValue } from '$lib/utils/formatConversionValue';
export let config: CalculatorDef;
@@ -17,40 +18,29 @@
? solve(config, 1, exampleInput.toString(), '', '')
: null;
$: offset = config.offset ?? 0;
$: formulaExpression = supportsExample
? `${exampleInput} × ${config.factor}${offset ? ` + ${offset}` : ''}`
$: hasOffset = Boolean(offset);
$: formattedFactorValue = supportsExample
? formatConversionValue(config.factor)
: '';
$: formattedOffsetValue = hasOffset
? formatConversionValue(offset)
: '';
$: formulaExpression = supportsExample
? `${exampleInput} × ${formattedFactorValue}${hasOffset ? ` + ${formattedOffsetValue}` : ''}`
: '';
const formatExampleValue = (value: number | null): string => {
if (value === null || Number.isNaN(value)) {
return '—';
}
if (!Number.isFinite(value)) {
return value.toString();
}
if (value === 0) {
return '0';
}
const rounded = parseFloat(value.toFixed(6));
if (rounded !== 0) {
return rounded.toString();
}
const precise = value.toFixed(12).replace(/\.?0+$/, '');
return precise || '0';
};
$: reverseExampleValue =
supportsExample && config.factor !== 0
? (1 - offset) / config.factor
: null;
$: formattedReverseValue = formatExampleValue(reverseExampleValue);
$: formattedReverseValue = formatConversionValue(reverseExampleValue);
</script>
{#if supportsExample && result}
<section class="example-card">
<h3>How to convert {config.labels.in1} to {config.labels.in2}</h3>
<p class="example-note">
1 {config.labels.in1} = {config.factor}{config.offset ? ` + ${config.offset}` : ''} {config.labels.in2}
1 {config.labels.in1} = {formattedFactorValue}{hasOffset ? ` + ${formattedOffsetValue}` : ''} {config.labels.in2}
</p>
<p class="example-note">
1 {config.labels.in2} = {formattedReverseValue} {config.labels.in1}

View File

@@ -8,11 +8,11 @@
type Row = { input: number; output: string };
const buildRow = (value: number): Row => {
const formatted = solve(config, 1, value.toString(), '', '');
const buildRow = (value: number, c: CalculatorDef): Row => {
const formatted = solve(c, 1, value.toString(), '', '');
return {
input: value,
output: formatted.val2 || '—'
output: formatted.val2 || '—',
};
};
@@ -22,8 +22,8 @@
let outputLabel = 'target units';
$: supportsTable = ['standard', 'inverse'].includes(config.type);
$: rows = supportsTable
? numericSamples.map(buildRow)
$: rows = (config && supportsTable)
? numericSamples.map(v => buildRow(v, config))
: [];
$: inputLabel = config.labels?.in1 ?? 'source units';
$: outputLabel = config.labels?.in2 ?? 'target units';

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { getDefinition } from '$lib/data/unitDefinitions';
import type { CalculatorDef } from '$lib/data/calculators';
import type { CalculatorDef } from '$lib/data/calculatorLoader';
export let config: CalculatorDef;
@@ -11,8 +11,10 @@
$: label1 = config.labels.in1 || 'Unit 1';
$: label2 = config.labels.in2 || 'Unit 2';
$: def1 = getDefinition(label1, config.category);
$: def2 = getDefinition(label2, config.category);
$: {
getDefinition(label1, config.category).then(d => { def1 = d; });
getDefinition(label2, config.category).then(d => { def2 = d; });
}
</script>
<section class="definition-card">

View File

@@ -1,15 +1,19 @@
<script lang="ts">
import { searchCalculators } from '$lib/data/calculators';
import { goto } from '$app/navigation';
import { loadCalculators, searchCalculators, type CalculatorDef } from '$lib/data/calculatorLoader';
export let idPrefix = 'search';
let query = '';
let focused = false;
let selectedIndex = -1;
let lastQuery = '';
let allCalcs: CalculatorDef[] = [];
$: results = query.length >= 2 ? searchCalculators(query).slice(0, 8) : [];
$: if (query.length >= 1 && allCalcs.length === 0) {
loadCalculators().then(data => { allCalcs = data; });
}
$: results = (query.length >= 2 && allCalcs.length > 0) ? searchCalculators(allCalcs, query).slice(0, 8) : [];
$: listboxId = `${idPrefix}-listbox`;
$: inputId = `${idPrefix}-input`;
$: isOpen = focused && results.length > 0;

View File

@@ -2,7 +2,18 @@
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { categories, getCalculatorsByCategory, type CalculatorDef } from '$lib/data/calculators';
import { categories } from '$lib/data/stats';
import { loadCalculators, type CalculatorDef } from '$lib/data/calculatorLoader';
let allCalculators: CalculatorDef[] = [];
let isLoaded = false;
async function loadData() {
if (isLoaded || !browser) return;
const data = await loadCalculators();
allCalculators = data;
isLoaded = true;
}
let expandedCategory = '';
let expandedUnits: Record<string, string> = {};
@@ -23,50 +34,85 @@
type UnitGroup = {
label: string;
conversions: CalculatorDef[];
conversions: UnitConversionLink[];
};
type UnitBucket = {
label: string;
conversions: CalculatorDef[];
conversions: UnitConversionLink[];
};
const sortConversionsForUnit = (conversions: CalculatorDef[], unitLabel: string) => {
const normalizedUnit = unitLabel.toLowerCase();
return conversions.slice().sort((a, b) => {
const aIsSource = a.labels.in1?.toLowerCase() === normalizedUnit;
const bIsSource = b.labels.in1?.toLowerCase() === normalizedUnit;
if (aIsSource !== bIsSource) {
return aIsSource ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
type UnitConversionLink = {
name: string;
slug: string;
sortKey: string;
};
const sortConversionsForUnit = (conversions: UnitConversionLink[]) =>
conversions.slice().sort((a, b) => a.name.localeCompare(b.name));
const toPairKey = (unitA: string, unitB: string) =>
[unitA.toLowerCase(), unitB.toLowerCase()].sort().join('::');
const toDirectionKey = (fromUnit: string, toUnit: string) =>
`${fromUnit.toLowerCase()}::${toUnit.toLowerCase()}`;
function addConversion(
buckets: Map<string, UnitBucket>,
fromUnit: string,
toUnit: string,
slug: string
) {
const bucketKey = fromUnit.toLowerCase();
const directionKey = toDirectionKey(fromUnit, toUnit);
const conversion: UnitConversionLink = {
name: `${fromUnit} to ${toUnit}`,
slug,
sortKey: directionKey,
};
const existing = buckets.get(bucketKey);
if (existing) {
if (!existing.conversions.some(link => link.sortKey === directionKey)) {
existing.conversions.push(conversion);
}
return;
}
buckets.set(bucketKey, {
label: fromUnit,
conversions: [conversion],
});
}
$: categoryUnitGroups = Object.entries(categories).map(([key, meta]) => {
const buckets = new Map<string, UnitBucket>();
const calcs = getCalculatorsByCategory(key);
if (!isLoaded) {
return { key, meta, units: [] };
}
const calcs = allCalculators.filter(c => c.category === key && !c.hidden);
const canonicalByPair = new Map<string, CalculatorDef>();
calcs.forEach(calc => {
[calc.labels.in1, calc.labels.in2].forEach(unit => {
const key = unit.toLowerCase();
const existing = buckets.get(key);
if (existing) {
existing.conversions.push(calc);
} else {
buckets.set(key, {
label: unit,
conversions: [calc],
});
}
});
const pairKey = toPairKey(calc.labels.in1, calc.labels.in2);
const existing = canonicalByPair.get(pairKey);
if (!existing || calc.slug.localeCompare(existing.slug) < 0) {
canonicalByPair.set(pairKey, calc);
}
});
canonicalByPair.forEach(calc => {
addConversion(buckets, calc.labels.in1, calc.labels.in2, calc.slug);
addConversion(buckets, calc.labels.in2, calc.labels.in1, calc.slug);
});
const units = [...buckets.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([, bucket]) => ({
label: bucket.label,
conversions: sortConversionsForUnit(bucket.conversions, bucket.label),
conversions: sortConversionsForUnit(bucket.conversions),
}));
return { key, meta, units };
@@ -131,6 +177,11 @@
}
export let open = false;
$: if (browser && (isDesktop || open)) {
loadData();
}
$: isSidebarHidden = !isDesktop && !open;
function closeSidebar() {
@@ -188,14 +239,14 @@
</button>
{#if expandedUnits[group.key] === unit.label}
<ul class="unit-list">
{#each unit.conversions as calc}
{#each unit.conversions as conversion}
<li>
<a
href="/{calc.slug}"
class:current={currentPath === `/${calc.slug}`}
aria-current={currentPath === `/${calc.slug}` ? 'page' : undefined}
href="/{conversion.slug}"
class:current={currentPath === `/${conversion.slug}`}
aria-current={currentPath === `/${conversion.slug}` ? 'page' : undefined}
>
{calc.name}
{conversion.name}
</a>
</li>
{/each}

View File

@@ -0,0 +1,53 @@
// Shared lazy loader fetches /data/calculators.json exactly once.
// Because this is a plain fetch (not a JS dynamic import), Vite will NOT
// emit a modulepreload for it, keeping the homepage bundle small.
export interface CalculatorDef {
slug: string;
name: string;
category: string;
type: string;
teaser: string;
labels: { in1: string; in2: string };
factor?: number;
offset?: number;
hidden?: boolean;
}
let cache: CalculatorDef[] | null = null;
let pending: Promise<CalculatorDef[]> | null = null;
const runtimeHost =
import.meta.env.PUBLIC_SITE_URL ??
(import.meta.env.DEV ? 'http://localhost:5173' : 'https://howdoyouconvert.com');
const getCalculatorsUrl = (): string =>
import.meta.env.SSR
? new URL('/data/calculators.json', runtimeHost).toString()
: '/data/calculators.json';
export async function loadCalculators(): Promise<CalculatorDef[]> {
if (cache) return cache;
if (pending) return pending;
const url = getCalculatorsUrl();
pending = fetch(url)
.then(r => r.json())
.then((data: CalculatorDef[]) => {
cache = data;
pending = null;
return data;
});
return pending;
}
export function searchCalculators(calcs: CalculatorDef[], query: string): CalculatorDef[] {
const q = query.toLowerCase();
return calcs.filter(c =>
(c.name.toLowerCase().includes(q) ||
c.slug.includes(q) ||
c.labels.in1.toLowerCase().includes(q) ||
c.labels.in2.toLowerCase().includes(q)) && !c.hidden
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
// THIS FILE IS AUTO-GENERATED BY migrate.py
export const categories: Record<string, { label: string; icon: string }> = {
"length": {
"label": "Length / Distance",
"icon": "📏"
},
"weight": {
"label": "Weight / Mass",
"icon": "⚖️"
},
"temperature": {
"label": "Temperature",
"icon": "🌡️"
},
"volume": {
"label": "Volume",
"icon": "🧪"
},
"fluids": {
"label": "Fluids",
"icon": "💧"
},
"area": {
"label": "Area",
"icon": "🔳"
},
"speed": {
"label": "Speed / Velocity",
"icon": "💨"
},
"pressure": {
"label": "Pressure",
"icon": "🔽"
},
"energy": {
"label": "Energy",
"icon": "⚡"
},
"magnetism": {
"label": "Magnetism",
"icon": "🧲"
},
"power": {
"label": "Power",
"icon": "🔌"
},
"data": {
"label": "Data Storage",
"icon": "💾"
},
"time": {
"label": "Time",
"icon": "⏱️"
},
"angle": {
"label": "Angle",
"icon": "📐"
},
"number-systems": {
"label": "Number Systems",
"icon": "🔢"
},
"radiation": {
"label": "Radiation",
"icon": "☢️"
},
"electrical": {
"label": "Electrical",
"icon": "🔋"
},
"force": {
"label": "Force / Torque",
"icon": "💪"
},
"light": {
"label": "Light",
"icon": "💡"
},
"other": {
"label": "Other",
"icon": "🔄"
}
};
export const totalCalculators = 3124;

View File

@@ -1,4 +1,4 @@
import { calculators } from './calculators';
import { loadCalculators, type CalculatorDef } from './calculatorLoader';
const domainDefinitions: Record<string, { summary: string; context: string }> = {
length: {
@@ -97,7 +97,6 @@ const normalizeLabel = (label?: string): string | undefined => {
return alias ?? trimmed;
};
const definitions: Record<string, Record<string, string>> = {};
const categoryPriority = [...Object.keys(domainDefinitions)];
const buildDefinition = (label: string, categoryKey: string): string => {
@@ -107,17 +106,36 @@ const buildDefinition = (label: string, categoryKey: string): string => {
return `${label} ${description}`;
};
calculators.forEach(calc => {
const { category, labels } = calc;
Object.values(labels).forEach(label => {
const normalized = normalizeLabel(label);
if (!normalized) return;
const bucket = definitions[normalized] || {};
const text = buildDefinition(normalized, category);
bucket[category] = text;
definitions[normalized] = bucket;
// Lazily built definitions cache
let definitions: Record<string, Record<string, string>> | null = null;
let buildPromise: Promise<void> | null = null;
async function ensureBuilt(): Promise<Record<string, Record<string, string>>> {
if (definitions) return definitions;
if (buildPromise) {
await buildPromise;
return definitions!;
}
buildPromise = loadCalculators().then(calculators => {
const defs: Record<string, Record<string, string>> = {};
calculators.forEach(calc => {
const { category, labels } = calc;
Object.values(labels).forEach(label => {
const normalized = normalizeLabel(label);
if (!normalized) return;
const bucket = defs[normalized] || {};
const text = buildDefinition(normalized, category);
bucket[category] = text;
defs[normalized] = bucket;
});
});
definitions = defs;
});
});
await buildPromise;
return definitions!;
}
const findByPriority = (entries: Record<string, string>, preferred?: string): string | undefined => {
if (!entries) return undefined;
@@ -129,11 +147,10 @@ const findByPriority = (entries: Record<string, string>, preferred?: string): st
return fallback.length ? fallback[0] : undefined;
};
export function getDefinition(label: string, category?: string): string | undefined {
export async function getDefinition(label: string, category?: string): Promise<string | undefined> {
const normalized = normalizeLabel(label);
if (!normalized) return undefined;
const entries = definitions[normalized];
const defs = await ensureBuilt();
const entries = defs[normalized];
return findByPriority(entries, category);
}
export const unitDefinitions = definitions;

View File

@@ -10,7 +10,13 @@ export interface SolveResult {
}
function fmt(n: number): string {
return parseFloat(n.toFixed(6)).toString();
if (!Number.isFinite(n)) return n.toString();
if (n === 0) return '0';
if (Math.abs(n) < 1e-6) {
return n.toExponential(6);
}
const rounded = parseFloat(n.toFixed(6));
return rounded.toString();
}
function gcd(a: number, b: number): number {
@@ -129,26 +135,47 @@ export function solve(
}
break;
case 'dec-frac':
if (source === 1) {
if (!isNaN(v1)) {
const parts = v1.toString().split('.');
const len = parts[1] ? parts[1].length : 0;
const den = Math.pow(10, len);
const num = v1 * den;
const div = gcd(num, den);
out.val2 = `${num / div}/${den / div}`;
} else { out.val2 = ''; }
} else {
const parts = rawVal2.split('/');
case 'dec-frac': {
// Two calculators share this type:
// - decimal -> fraction
// - fraction -> decimal
// Detect which direction the left field represents via its label.
const fractionFirst = calc.labels.in1.toLowerCase().includes('fraction');
const decimalToFraction = (n: number) => {
if (isNaN(n)) return '';
const parts = n.toString().split('.');
const len = parts[1] ? parts[1].length : 0;
const den = Math.pow(10, len);
const num = n * den;
const div = gcd(num, den);
return `${num / div}/${den / div}`;
};
const fractionToDecimal = (raw: string) => {
const parts = raw.split('/');
if (parts.length === 2 && !isNaN(Number(parts[0])) && !isNaN(Number(parts[1])) && Number(parts[1]) !== 0) {
out.val1 = fmt(Number(parts[0]) / Number(parts[1]));
return fmt(Number(parts[0]) / Number(parts[1]));
}
const f = parseFloat(parts[0]);
return !isNaN(f) ? f.toString() : '';
};
if (fractionFirst) {
if (source === 1) {
out.val2 = fractionToDecimal(rawVal1);
} else {
const f = parseFloat(parts[0]);
out.val1 = !isNaN(f) ? f.toString() : '';
out.val1 = decimalToFraction(v2);
}
} else {
if (source === 1) {
out.val2 = decimalToFraction(v1);
} else {
out.val1 = fractionToDecimal(rawVal2);
}
}
break;
}
case 'db-int':
if (source === 1) {
@@ -181,6 +208,296 @@ export function solve(
out.val1 = (!isNaN(v2) && v2 > 0) ? fmt(10 * Math.log10(v2)) : '';
}
break;
case 'awg': {
const log92 = Math.log(92);
const awgToDiameterMm = (g: number) => 0.127 * Math.pow(92, (36 - g) / 39);
const diameterMmToAwg = (d: number) => 36 - 39 * Math.log(d / 0.127) / log92;
const awgToCircularMils = (g: number) => 1000 * Math.pow(92, (36 - g) / 19.5);
const circularMilsToAwg = (a: number) => 36 - 19.5 * Math.log(a / 1000) / log92;
const awgToAreaMm2 = (g: number) => {
const d = awgToDiameterMm(g);
return Math.PI * Math.pow(d, 2) / 4;
};
const areaMm2ToAwg = (a: number) => {
const d = Math.sqrt((4 * a) / Math.PI);
return diameterMmToAwg(d);
};
const slug = calc.slug;
const formatAwg = (g: number) => isFinite(g) ? fmt(g) : '';
const awgIsInput = calc.labels.in1.toLowerCase().includes('awg');
const isCircular = slug.includes('circular-mils');
const isArea = slug.includes('square-millimeters');
if (isCircular) {
if (awgIsInput) {
if (source === 1) out.val2 = !isNaN(v1) ? fmt(awgToCircularMils(v1)) : '';
else out.val1 = (!isNaN(v2) && v2 > 0) ? formatAwg(circularMilsToAwg(v2)) : '';
} else {
if (source === 1) out.val2 = (!isNaN(v1) && v1 > 0) ? formatAwg(circularMilsToAwg(v1)) : '';
else out.val1 = !isNaN(v2) ? fmt(awgToCircularMils(v2)) : '';
}
} else if (isArea) {
if (awgIsInput) {
if (source === 1) out.val2 = !isNaN(v1) ? fmt(awgToAreaMm2(v1)) : '';
else out.val1 = (!isNaN(v2) && v2 > 0) ? formatAwg(areaMm2ToAwg(v2)) : '';
} else {
if (source === 1) out.val2 = (!isNaN(v1) && v1 > 0) ? formatAwg(areaMm2ToAwg(v1)) : '';
else out.val1 = !isNaN(v2) ? fmt(awgToAreaMm2(v2)) : '';
}
} else {
// diameter in millimeters
if (awgIsInput) {
if (source === 1) out.val2 = !isNaN(v1) ? fmt(awgToDiameterMm(v1)) : '';
else out.val1 = (!isNaN(v2) && v2 > 0) ? formatAwg(diameterMmToAwg(v2)) : '';
} else {
if (source === 1) out.val2 = (!isNaN(v1) && v1 > 0) ? formatAwg(diameterMmToAwg(v1)) : '';
else out.val1 = !isNaN(v2) ? fmt(awgToDiameterMm(v2)) : '';
}
}
break;
}
case 'awg-swg': {
const log92 = Math.log(92);
const awgToDiameterMm = (g: number) => 0.127 * Math.pow(92, (36 - g) / 39);
const diameterMmToAwg = (d: number) => 36 - 39 * Math.log(d / 0.127) / log92;
const swgTable: Record<number, number> = {
0: 8.23, 1: 7.62, 2: 7.01, 3: 6.4, 4: 5.89, 5: 5.385, 6: 4.877,
7: 4.47, 8: 4.064, 9: 3.658, 10: 3.251, 11: 2.946, 12: 2.642, 13: 2.337,
14: 2.032, 15: 1.829, 16: 1.626, 17: 1.422, 18: 1.219, 19: 1.016,
20: 0.914, 21: 0.813, 22: 0.711, 23: 0.61, 24: 0.559, 25: 0.508,
26: 0.457, 27: 0.417, 28: 0.376, 29: 0.345, 30: 0.315, 31: 0.294,
32: 0.274, 33: 0.254, 34: 0.234, 35: 0.213, 36: 0.193,
37: 0.173, 38: 0.152, 39: 0.132, 40: 0.122, 41: 0.112, 42: 0.102,
43: 0.091, 44: 0.081, 45: 0.071, 46: 0.061, 47: 0.051, 48: 0.04,
49: 0.03, 50: 0.025
};
const nearestSwg = (diamMm: number) => {
let bestGauge = 0;
let bestDiff = Number.POSITIVE_INFINITY;
for (const [gStr, d] of Object.entries(swgTable)) {
const diff = Math.abs(diamMm - d);
if (diff < bestDiff) { bestDiff = diff; bestGauge = Number(gStr); }
}
return bestGauge;
};
if (source === 1) {
// AWG -> SWG
if (!isNaN(v1)) {
const diam = awgToDiameterMm(v1);
out.val2 = fmt(nearestSwg(diam));
} else { out.val2 = ''; }
} else {
// SWG -> AWG
if (!isNaN(v2)) {
const diam = swgTable[Math.round(v2)];
if (diam) out.val1 = fmt(diameterMmToAwg(diam));
else out.val1 = '';
} else { out.val1 = ''; }
}
break;
}
case 'ev-lux': {
const isEvFirst = calc.labels.in1.toLowerCase().includes('ev');
const toLux = (ev: number) => 2.5 * Math.pow(2, ev);
const toEv = (lux: number) => lux > 0 ? Math.log(lux / 2.5) / Math.log(2) : NaN;
if (isEvFirst) {
if (source === 1) out.val2 = !isNaN(v1) ? fmt(toLux(v1)) : '';
else out.val1 = (!isNaN(v2) && v2 > 0) ? fmt(toEv(v2)) : '';
} else {
if (source === 1) out.val2 = (!isNaN(v1) && v1 > 0) ? fmt(toEv(v1)) : '';
else out.val1 = !isNaN(v2) ? fmt(toLux(v2)) : '';
}
break;
}
case 'aov': {
const sensorWidth = 36; // mm, full-frame horizontal
const isFocalFirst = calc.labels.in1.toLowerCase().includes('focal');
const toAov = (f: number) => f > 0 ? (2 * Math.atan(sensorWidth / (2 * f)) * 180 / Math.PI) : NaN;
const toFocal = (angle: number) => {
const radians = angle * Math.PI / 180;
return Math.tan(radians / 2) !== 0 ? sensorWidth / (2 * Math.tan(radians / 2)) : NaN;
};
if (isFocalFirst) {
if (source === 1) out.val2 = (!isNaN(v1) && v1 !== 0) ? fmt(toAov(v1)) : '';
else out.val1 = (!isNaN(v2) && v2 !== 0) ? fmt(toFocal(v2)) : '';
} else {
if (source === 1) out.val2 = (!isNaN(v1) && v1 !== 0) ? fmt(toFocal(v1)) : '';
else out.val1 = (!isNaN(v2) && v2 !== 0) ? fmt(toAov(v2)) : '';
}
break;
}
case 'brinell-rockwell': {
// Approximate correlation for steels:
// BHN = (1520000 - 4500 * HRC) / (100 - HRC)^2
if (source === 1) {
// Brinell to Rockwell C
if (!isNaN(v1) && v1 > 0) {
const a = v1;
const disc = 4500 ** 2 + 4 * a * 1070000;
const y = (4500 + Math.sqrt(disc)) / (2 * a);
const hrc = 100 - y;
out.val2 = fmt(hrc);
} else {
out.val2 = '';
}
} else {
// Rockwell C to Brinell
if (!isNaN(v2) && v2 < 100) {
const h = v2;
const bhn = (1520000 - 4500 * h) / Math.pow(100 - h, 2);
out.val1 = fmt(bhn);
} else {
out.val1 = '';
}
}
break;
}
case 'molarity': {
const m = v1; // mol/L
const gpl = v2; // grams/L
const molarMass = v3; // g/mol
if (source === 1) {
out.val2 = (!isNaN(m) && !isNaN(molarMass)) ? fmt(m * molarMass) : '';
} else if (source === 2) {
out.val1 = (!isNaN(gpl) && !isNaN(molarMass) && molarMass !== 0) ? fmt(gpl / molarMass) : '';
} else {
if (!isNaN(m) && !isNaN(molarMass)) out.val2 = fmt(m * molarMass);
else if (!isNaN(gpl) && !isNaN(molarMass) && molarMass !== 0) out.val1 = fmt(gpl / molarMass);
}
break;
}
case 'rockwell-vickers': {
const hrcToBhn = (h: number) => (1520000 - 4500 * h) / Math.pow(100 - h, 2);
const bhnToHrc = (b: number) => {
const disc = 4500 ** 2 + 4 * b * 1070000;
const y = (4500 + Math.sqrt(disc)) / (2 * b);
return 100 - y;
};
const bhnToHv = (b: number) => b * 0.95;
const hvToBhn = (hv: number) => hv / 0.95;
if (source === 1) {
const hrc = v1;
if (!isNaN(hrc) && hrc < 100) {
const hv = bhnToHv(hrcToBhn(hrc));
out.val2 = fmt(hv);
} else out.val2 = '';
} else {
const hv = v2;
if (!isNaN(hv) && hv > 0) {
const bhn = hvToBhn(hv);
out.val1 = fmt(bhnToHrc(bhn));
} else out.val1 = '';
}
break;
}
case 'sus-cst': {
const susToCst = (sus: number) => {
if (sus <= 0) return NaN;
if (sus < 100) return 0.226 * sus - 195 / sus;
return 0.22 * sus - 135 / sus;
};
const cstToSus = (cst: number) => {
if (cst <= 0) return NaN;
const low = (cst + Math.sqrt(cst * cst + 4 * 0.226 * 195)) / (2 * 0.226);
const high = (cst + Math.sqrt(cst * cst + 4 * 0.22 * 135)) / (2 * 0.22);
return low < 100 ? low : high;
};
if (source === 1) {
out.val2 = !isNaN(v1) ? fmt(susToCst(v1)) : '';
} else {
out.val1 = !isNaN(v2) ? fmt(cstToSus(v2)) : '';
}
break;
}
case 'swg': {
const swgTable: Record<number, number> = {
0: 8.23, 1: 7.62, 2: 7.01, 3: 6.4, 4: 5.89, 5: 5.38, 6: 4.88, 7: 4.47,
8: 4.06, 9: 3.66, 10: 3.25, 11: 2.95, 12: 2.64, 13: 2.34, 14: 2.03, 15: 1.83,
16: 1.63, 17: 1.42, 18: 1.22, 19: 1.02, 20: 0.91, 21: 0.81, 22: 0.71, 23: 0.61,
24: 0.56, 25: 0.51, 26: 0.46, 27: 0.42, 28: 0.38, 29: 0.35, 30: 0.32, 31: 0.29,
32: 0.27, 33: 0.25, 34: 0.23, 35: 0.21, 36: 0.19, 37: 0.17, 38: 0.15, 39: 0.14,
40: 0.12, 41: 0.11, 42: 0.1, 43: 0.09, 44: 0.08, 45: 0.07, 46: 0.064, 47: 0.058,
48: 0.051, 49: 0.045, 50: 0.04
};
const gaugeToMm = (g: number) => swgTable[Math.round(g)];
const mmToGauge = (mm: number) => {
let best = -1, bestDiff = Infinity;
for (const [gStr, diam] of Object.entries(swgTable)) {
const diff = Math.abs(mm - diam);
if (diff < bestDiff) { bestDiff = diff; best = parseInt(gStr, 10); }
}
return best;
};
if (calc.labels.in1.toLowerCase().includes('swg')) {
if (source === 1) out.val2 = !isNaN(v1) ? fmt(gaugeToMm(v1) ?? NaN) : '';
else out.val1 = (!isNaN(v2) && v2 > 0) ? fmt(mmToGauge(v2)) : '';
} else {
if (source === 1) out.val2 = (!isNaN(v1) && v1 > 0) ? fmt(mmToGauge(v1)) : '';
else out.val1 = !isNaN(v2) ? fmt(gaugeToMm(v2) ?? NaN) : '';
}
break;
}
case 'cmil-dia': {
const cmilToMm = (c: number) => 0.0254 * Math.sqrt(c);
const mmToCmil = (mm: number) => Math.pow(mm / 0.0254, 2);
if (source === 1) {
out.val2 = (!isNaN(v1) && v1 >= 0) ? fmt(cmilToMm(v1)) : '';
} else {
out.val1 = (!isNaN(v2) && v2 >= 0) ? fmt(mmToCmil(v2)) : '';
}
break;
}
case 'cmil-swg': {
const swgTable: Record<number, number> = {
0: 8.23, 1: 7.62, 2: 7.01, 3: 6.4, 4: 5.89, 5: 5.38, 6: 4.88, 7: 4.47,
8: 4.06, 9: 3.66, 10: 3.25, 11: 2.95, 12: 2.64, 13: 2.34, 14: 2.03, 15: 1.83,
16: 1.63, 17: 1.42, 18: 1.22, 19: 1.02, 20: 0.91, 21: 0.81, 22: 0.71, 23: 0.61,
24: 0.56, 25: 0.51, 26: 0.46, 27: 0.42, 28: 0.38, 29: 0.35, 30: 0.32, 31: 0.29,
32: 0.27, 33: 0.25, 34: 0.23, 35: 0.21, 36: 0.19, 37: 0.17, 38: 0.15, 39: 0.14,
40: 0.12, 41: 0.11, 42: 0.1, 43: 0.09, 44: 0.08, 45: 0.07, 46: 0.064, 47: 0.058,
48: 0.051, 49: 0.045, 50: 0.04
};
const mmToSwg = (mm: number) => {
let best = -1, bestDiff = Infinity;
for (const [gStr, diam] of Object.entries(swgTable)) {
const diff = Math.abs(mm - diam);
if (diff < bestDiff) { bestDiff = diff; best = parseInt(gStr, 10); }
}
return best;
};
const swgToMm = (g: number) => swgTable[Math.round(g)];
const cmilToMm = (c: number) => 0.0254 * Math.sqrt(c);
const mmToCmil = (mm: number) => Math.pow(mm / 0.0254, 2);
if (source === 1) {
const mm = (!isNaN(v1) && v1 >= 0) ? cmilToMm(v1) : NaN;
out.val2 = isFinite(mm) ? fmt(mmToSwg(mm)) : '';
} else {
const mm = (!isNaN(v2) && v2 >= 0) ? swgToMm(v2) : NaN;
out.val1 = isFinite(mm) ? fmt(mmToCmil(mm)) : '';
}
break;
}
}
return out;

View File

@@ -0,0 +1,244 @@
export type ThemeMode = 'light' | 'dark';
export type PaletteVar =
| 'bg'
| 'bg-elevated'
| 'sidebar-bg'
| 'card-bg'
| 'input-bg'
| 'hover-bg'
| 'border'
| 'text'
| 'text-muted'
| 'accent'
| 'accent-dark'
| 'accent-glow'
| 'accent-gradient'
| 'header-bg';
export type PaletteTheme = Record<PaletteVar, string>;
export type Palette = {
slug: string;
label: string;
light: PaletteTheme;
dark: PaletteTheme;
};
export const palettes: Palette[] = [
{
slug: 'classic',
label: 'Classic',
light: {
bg: '#f8fafc',
'bg-elevated': '#ffffff',
'sidebar-bg': '#ffffff',
'card-bg': '#ffffff',
'input-bg': 'rgba(15, 23, 42, 0.04)',
'hover-bg': 'rgba(15, 23, 42, 0.08)',
border: 'rgba(15, 23, 42, 0.12)',
text: '#0f172a',
'text-muted': '#475569',
accent: '#10b981',
'accent-dark': '#059669',
'accent-glow': 'rgba(16, 185, 129, 0.15)',
'accent-gradient': 'linear-gradient(135deg, #10b981, #06b6d4)',
'header-bg': 'rgba(255, 255, 255, 0.95)',
},
dark: {
bg: '#0c0f14',
'bg-elevated': '#12161e',
'sidebar-bg': '#10141b',
'card-bg': 'rgba(18, 22, 30, 0.85)',
'input-bg': 'rgba(255, 255, 255, 0.04)',
'hover-bg': 'rgba(255, 255, 255, 0.06)',
border: 'rgba(255, 255, 255, 0.08)',
text: '#e8ecf4',
'text-muted': '#7b8498',
accent: '#10b981',
'accent-dark': '#059669',
'accent-glow': 'rgba(16, 185, 129, 0.15)',
'accent-gradient': 'linear-gradient(135deg, #10b981, #06b6d4)',
'header-bg': 'rgba(12, 15, 20, 0.85)',
},
},
{
slug: 'emerald',
label: 'Emerald',
light: {
'bg': '#f6fbf9',
'bg-elevated': '#ffffff',
'sidebar-bg': '#ffffff',
'card-bg': '#ffffff',
'input-bg': '#ecf7f1',
'hover-bg': '#d5f0df',
'border': 'rgba(4, 120, 87, 0.25)',
'text': '#0b2c1f',
'text-muted': '#4a6b5c',
'accent': '#047857',
'accent-dark': '#065f46',
'accent-glow': 'rgba(4, 120, 87, 0.2)',
'accent-gradient': 'linear-gradient(135deg, #047857, #0ea5e9)',
'header-bg': 'rgba(255, 255, 255, 0.95)',
},
dark: {
'bg': '#0b1313',
'bg-elevated': 'rgba(4, 20, 15, 0.85)',
'sidebar-bg': '#08110f',
'card-bg': 'rgba(6, 19, 13, 0.75)',
'input-bg': 'rgba(16, 185, 129, 0.08)',
'hover-bg': 'rgba(16, 185, 129, 0.12)',
'border': 'rgba(16, 185, 129, 0.35)',
'text': '#e9fcea',
'text-muted': '#9fdac4',
'accent': '#10b981',
'accent-dark': '#059669',
'accent-glow': 'rgba(16, 185, 129, 0.25)',
'accent-gradient': 'linear-gradient(135deg, #10b981, #0ea5e9)',
'header-bg': 'rgba(12, 15, 20, 0.85)',
},
},
{
slug: 'sunset',
label: 'Sunset',
light: {
'bg': '#fff8f2',
'bg-elevated': '#ffffff',
'sidebar-bg': '#ffffff',
'card-bg': '#fff4ef',
'input-bg': '#ffe3d8',
'hover-bg': '#ffd3bf',
'border': 'rgba(249, 115, 22, 0.25)',
'text': '#3d1b0b',
'text-muted': '#7a4a37',
'accent': '#f97316',
'accent-dark': '#c2410c',
'accent-glow': 'rgba(249, 115, 22, 0.25)',
'accent-gradient': 'linear-gradient(135deg, #f97316, #ec4899)',
'header-bg': 'rgba(255, 255, 255, 0.96)',
},
dark: {
'bg': '#0f0505',
'bg-elevated': 'rgba(15, 5, 5, 0.85)',
'sidebar-bg': '#0c0404',
'card-bg': 'rgba(19, 6, 6, 0.7)',
'input-bg': 'rgba(251, 113, 133, 0.08)',
'hover-bg': 'rgba(251, 113, 133, 0.14)',
'border': 'rgba(251, 113, 133, 0.35)',
'text': '#ffe7e0',
'text-muted': '#f9a6aa',
'accent': '#fb7185',
'accent-dark': '#be123c',
'accent-glow': 'rgba(251, 113, 133, 0.25)',
'accent-gradient': 'linear-gradient(135deg, #fb7185, #f97316)',
'header-bg': 'rgba(12, 8, 6, 0.85)',
},
},
{
slug: 'ocean',
label: 'Ocean',
light: {
'bg': '#f4fbff',
'bg-elevated': '#ffffff',
'sidebar-bg': '#ffffff',
'card-bg': '#f0f7ff',
'input-bg': '#dcefff',
'hover-bg': '#cae8ff',
'border': 'rgba(14, 165, 233, 0.25)',
'text': '#06274e',
'text-muted': '#4d6993',
'accent': '#0ea5e9',
'accent-dark': '#0369a1',
'accent-glow': 'rgba(14, 165, 233, 0.25)',
'accent-gradient': 'linear-gradient(135deg, #0ea5e9, #4753ff)',
'header-bg': 'rgba(255, 255, 255, 0.95)',
},
dark: {
'bg': '#030b12',
'bg-elevated': 'rgba(2, 9, 20, 0.85)',
'sidebar-bg': '#050c16',
'card-bg': 'rgba(3, 13, 26, 0.75)',
'input-bg': 'rgba(14, 165, 233, 0.08)',
'hover-bg': 'rgba(14, 165, 233, 0.15)',
'border': 'rgba(14, 165, 233, 0.4)',
'text': '#e6f6ff',
'text-muted': '#a1c4e8',
'accent': '#38bdf8',
'accent-dark': '#0369a1',
'accent-glow': 'rgba(14, 165, 233, 0.35)',
'accent-gradient': 'linear-gradient(135deg, #38bdf8, #0f172a)',
'header-bg': 'rgba(6, 15, 30, 0.85)',
},
},
{
slug: 'orchid',
label: 'Orchid',
light: {
'bg': '#fdf6ff',
'bg-elevated': '#ffffff',
'sidebar-bg': '#ffffff',
'card-bg': '#fdf2ff',
'input-bg': '#f5e4ff',
'hover-bg': '#e9d4ff',
'border': 'rgba(168, 85, 247, 0.25)',
'text': '#2c0a3a',
'text-muted': '#6a5277',
'accent': '#a855f7',
'accent-dark': '#6d28d9',
'accent-glow': 'rgba(168, 85, 247, 0.25)',
'accent-gradient': 'linear-gradient(135deg, #c084fc, #a855f7)',
'header-bg': 'rgba(255, 255, 255, 0.97)',
},
dark: {
'bg': '#0c0215',
'bg-elevated': 'rgba(10, 3, 30, 0.85)',
'sidebar-bg': '#090118',
'card-bg': 'rgba(12, 2, 25, 0.75)',
'input-bg': 'rgba(168, 85, 247, 0.08)',
'hover-bg': 'rgba(168, 85, 247, 0.16)',
'border': 'rgba(168, 85, 247, 0.35)',
'text': '#f5e6ff',
'text-muted': '#c5a3e8',
'accent': '#d946ef',
'accent-dark': '#831843',
'accent-glow': 'rgba(217, 70, 239, 0.25)',
'accent-gradient': 'linear-gradient(135deg, #d946ef, #fb7185)',
'header-bg': 'rgba(13, 6, 23, 0.95)',
},
},
{
slug: 'citrus',
label: 'Citrus',
light: {
'bg': '#fffdf5',
'bg-elevated': '#ffffff',
'sidebar-bg': '#ffffff',
'card-bg': '#fffaf0',
'input-bg': '#fff4d8',
'hover-bg': '#ffeec1',
'border': 'rgba(250, 204, 21, 0.25)',
'text': '#1f1505',
'text-muted': '#5b4a1e',
'accent': '#fbbf24',
'accent-dark': '#c2410c',
'accent-glow': 'rgba(250, 204, 21, 0.3)',
'accent-gradient': 'linear-gradient(135deg, #fbbf24, #d97706)',
'header-bg': 'rgba(255, 255, 255, 0.98)',
},
dark: {
'bg': '#1a1203',
'bg-elevated': 'rgba(26, 18, 3, 0.9)',
'sidebar-bg': '#130e02',
'card-bg': 'rgba(26, 18, 3, 0.75)',
'input-bg': 'rgba(250, 204, 21, 0.08)',
'hover-bg': 'rgba(250, 204, 21, 0.14)',
'border': 'rgba(250, 204, 21, 0.35)',
'text': '#fff8e7',
'text-muted': '#f6dea1',
'accent': '#fbbf24',
'accent-dark': '#b45309',
'accent-glow': 'rgba(250, 204, 21, 0.25)',
'accent-gradient': 'linear-gradient(135deg, #fbbf24, #f97316)',
'header-bg': 'rgba(15, 9, 2, 0.9)',
},
},
];

View File

@@ -0,0 +1,21 @@
import type { CalculatorDef } from '$lib/data/calculators';
import { formatConversionValue } from '$lib/utils/formatConversionValue';
type RateConfig = Pick<CalculatorDef, 'type' | 'factor' | 'offset' | 'labels'>;
export const getConversionRateText = (config: RateConfig): string | null => {
if (config.type !== 'standard' || !config.factor) {
return null;
}
const { in1, in2 } = config.labels;
if (!in1 || !in2) {
return null;
}
const formattedFactor = formatConversionValue(config.factor);
const hasOffset = Boolean(config.offset);
const formattedOffset = formatConversionValue(config.offset ?? 0);
return `1 ${in1} = ${formattedFactor}${hasOffset ? ` + ${formattedOffset}` : ''} ${in2}`;
};

View File

@@ -0,0 +1,24 @@
function formatConversionValue(value: number | null | undefined): string {
if (value === null || value === undefined || Number.isNaN(value)) {
return '—';
}
if (!Number.isFinite(value)) {
return value.toString();
}
if (value === 0) {
return '0';
}
const rounded = parseFloat(value.toFixed(6));
if (rounded !== 0) {
return rounded.toString();
}
const precise = value.toFixed(12).replace(/\.?0+$/, '');
if (precise !== '0' && precise !== '') {
return precise;
}
// Fallback for extremely small values: use scientific notation but clean it up
const scientific = value.toExponential();
return scientific.replace(/\.?0+e/, 'e');
}
module.exports = { formatConversionValue };

View File

@@ -0,0 +1,23 @@
export function formatConversionValue(value: number | null | undefined): string {
if (value === null || value === undefined || Number.isNaN(value)) {
return '—';
}
if (!Number.isFinite(value)) {
return value.toString();
}
if (value === 0) {
return '0';
}
const rounded = parseFloat(value.toFixed(6));
if (rounded !== 0) {
return rounded.toString();
}
const precise = value.toFixed(12).replace(/\.?0+$/, '');
if (precise !== '0' && precise !== '') {
return precise;
}
// Fallback for extremely small values: use scientific notation but clean it up
const scientific = value.toExponential();
return scientific.replace(/\.?0+e/, 'e');
}

View File

@@ -0,0 +1,18 @@
export interface ScientificNotationParts {
base: string;
exponent: string;
}
const SCIENTIFIC_REGEX = /^([+-]?\d+(?:\.\d+)?)(?:[eE]([+-]?\d+))$/;
export function parseScientificNotation(value: string): ScientificNotationParts | null {
const match = SCIENTIFIC_REGEX.exec(value);
if (!match) {
return null;
}
const exponent = match[2].replace(/^\+/, '') || '0';
return {
base: match[1],
exponent,
};
}

View File

@@ -7,251 +7,8 @@
import '../app.css';
import Sidebar from '$lib/components/Sidebar.svelte';
import SearchBar from '$lib/components/SearchBar.svelte';
import { palettes, type ThemeMode, type Palette } from '$lib/palettes';
type ThemeMode = 'light' | 'dark';
type PaletteVar =
| 'bg'
| 'bg-elevated'
| 'sidebar-bg'
| 'card-bg'
| 'input-bg'
| 'hover-bg'
| 'border'
| 'text'
| 'text-muted'
| 'accent'
| 'accent-dark'
| 'accent-glow'
| 'accent-gradient'
| 'header-bg';
type PaletteTheme = Record<PaletteVar, string>;
type Palette = {
slug: string;
label: string;
light: PaletteTheme;
dark: PaletteTheme;
};
const palettes: Palette[] = [
{
slug: 'classic',
label: 'Classic',
light: {
bg: '#f8fafc',
'bg-elevated': '#ffffff',
'sidebar-bg': '#ffffff',
'card-bg': '#ffffff',
'input-bg': 'rgba(15, 23, 42, 0.04)',
'hover-bg': 'rgba(15, 23, 42, 0.08)',
border: 'rgba(15, 23, 42, 0.12)',
text: '#0f172a',
'text-muted': '#475569',
accent: '#10b981',
'accent-dark': '#059669',
'accent-glow': 'rgba(16, 185, 129, 0.15)',
'accent-gradient': 'linear-gradient(135deg, #10b981, #06b6d4)',
'header-bg': 'rgba(255, 255, 255, 0.95)',
},
dark: {
bg: '#0c0f14',
'bg-elevated': '#12161e',
'sidebar-bg': '#10141b',
'card-bg': 'rgba(18, 22, 30, 0.85)',
'input-bg': 'rgba(255, 255, 255, 0.04)',
'hover-bg': 'rgba(255, 255, 255, 0.06)',
border: 'rgba(255, 255, 255, 0.08)',
text: '#e8ecf4',
'text-muted': '#7b8498',
accent: '#10b981',
'accent-dark': '#059669',
'accent-glow': 'rgba(16, 185, 129, 0.15)',
'accent-gradient': 'linear-gradient(135deg, #10b981, #06b6d4)',
'header-bg': 'rgba(12, 15, 20, 0.85)',
},
},
{
slug: 'emerald',
label: 'Emerald',
light: {
'bg': '#f6fbf9',
'bg-elevated': '#ffffff',
'sidebar-bg': '#ffffff',
'card-bg': '#ffffff',
'input-bg': '#ecf7f1',
'hover-bg': '#d5f0df',
'border': 'rgba(4, 120, 87, 0.25)',
'text': '#0b2c1f',
'text-muted': '#4a6b5c',
'accent': '#047857',
'accent-dark': '#065f46',
'accent-glow': 'rgba(4, 120, 87, 0.2)',
'accent-gradient': 'linear-gradient(135deg, #047857, #0ea5e9)',
'header-bg': 'rgba(255, 255, 255, 0.95)',
},
dark: {
'bg': '#0b1313',
'bg-elevated': 'rgba(4, 20, 15, 0.85)',
'sidebar-bg': '#08110f',
'card-bg': 'rgba(6, 19, 13, 0.75)',
'input-bg': 'rgba(16, 185, 129, 0.08)',
'hover-bg': 'rgba(16, 185, 129, 0.12)',
'border': 'rgba(16, 185, 129, 0.35)',
'text': '#e9fcea',
'text-muted': '#9fdac4',
'accent': '#10b981',
'accent-dark': '#059669',
'accent-glow': 'rgba(16, 185, 129, 0.25)',
'accent-gradient': 'linear-gradient(135deg, #10b981, #0ea5e9)',
'header-bg': 'rgba(12, 15, 20, 0.85)',
},
},
{
slug: 'sunset',
label: 'Sunset',
light: {
'bg': '#fff8f2',
'bg-elevated': '#ffffff',
'sidebar-bg': '#ffffff',
'card-bg': '#fff4ef',
'input-bg': '#ffe3d8',
'hover-bg': '#ffd3bf',
'border': 'rgba(249, 115, 22, 0.25)',
'text': '#3d1b0b',
'text-muted': '#7a4a37',
'accent': '#f97316',
'accent-dark': '#c2410c',
'accent-glow': 'rgba(249, 115, 22, 0.25)',
'accent-gradient': 'linear-gradient(135deg, #f97316, #ec4899)',
'header-bg': 'rgba(255, 255, 255, 0.96)',
},
dark: {
'bg': '#0f0505',
'bg-elevated': 'rgba(15, 5, 5, 0.85)',
'sidebar-bg': '#0c0404',
'card-bg': 'rgba(19, 6, 6, 0.7)',
'input-bg': 'rgba(251, 113, 133, 0.08)',
'hover-bg': 'rgba(251, 113, 133, 0.14)',
'border': 'rgba(251, 113, 133, 0.35)',
'text': '#ffe7e0',
'text-muted': '#f9a6aa',
'accent': '#fb7185',
'accent-dark': '#be123c',
'accent-glow': 'rgba(251, 113, 133, 0.25)',
'accent-gradient': 'linear-gradient(135deg, #fb7185, #f97316)',
'header-bg': 'rgba(12, 8, 6, 0.85)',
},
},
{
slug: 'ocean',
label: 'Ocean',
light: {
'bg': '#f4fbff',
'bg-elevated': '#ffffff',
'sidebar-bg': '#ffffff',
'card-bg': '#f0f7ff',
'input-bg': '#dcefff',
'hover-bg': '#cae8ff',
'border': 'rgba(14, 165, 233, 0.25)',
'text': '#06274e',
'text-muted': '#4d6993',
'accent': '#0ea5e9',
'accent-dark': '#0369a1',
'accent-glow': 'rgba(14, 165, 233, 0.25)',
'accent-gradient': 'linear-gradient(135deg, #0ea5e9, #4753ff)',
'header-bg': 'rgba(255, 255, 255, 0.95)',
},
dark: {
'bg': '#030b12',
'bg-elevated': 'rgba(2, 9, 20, 0.85)',
'sidebar-bg': '#050c16',
'card-bg': 'rgba(3, 13, 26, 0.75)',
'input-bg': 'rgba(14, 165, 233, 0.08)',
'hover-bg': 'rgba(14, 165, 233, 0.15)',
'border': 'rgba(14, 165, 233, 0.4)',
'text': '#e6f6ff',
'text-muted': '#a1c4e8',
'accent': '#38bdf8',
'accent-dark': '#0369a1',
'accent-glow': 'rgba(14, 165, 233, 0.35)',
'accent-gradient': 'linear-gradient(135deg, #38bdf8, #0f172a)',
'header-bg': 'rgba(6, 15, 30, 0.85)',
},
},
{
slug: 'orchid',
label: 'Orchid',
light: {
'bg': '#fdf6ff',
'bg-elevated': '#ffffff',
'sidebar-bg': '#ffffff',
'card-bg': '#fdf2ff',
'input-bg': '#f5e4ff',
'hover-bg': '#e9d4ff',
'border': 'rgba(168, 85, 247, 0.25)',
'text': '#2c0a3a',
'text-muted': '#6a5277',
'accent': '#a855f7',
'accent-dark': '#6d28d9',
'accent-glow': 'rgba(168, 85, 247, 0.25)',
'accent-gradient': 'linear-gradient(135deg, #c084fc, #a855f7)',
'header-bg': 'rgba(255, 255, 255, 0.97)',
},
dark: {
'bg': '#0c0215',
'bg-elevated': 'rgba(10, 3, 30, 0.85)',
'sidebar-bg': '#090118',
'card-bg': 'rgba(12, 2, 25, 0.75)',
'input-bg': 'rgba(168, 85, 247, 0.08)',
'hover-bg': 'rgba(168, 85, 247, 0.16)',
'border': 'rgba(168, 85, 247, 0.35)',
'text': '#f5e6ff',
'text-muted': '#c5a3e8',
'accent': '#d946ef',
'accent-dark': '#831843',
'accent-glow': 'rgba(217, 70, 239, 0.25)',
'accent-gradient': 'linear-gradient(135deg, #d946ef, #fb7185)',
'header-bg': 'rgba(13, 6, 23, 0.95)',
},
},
{
slug: 'citrus',
label: 'Citrus',
light: {
'bg': '#fffdf5',
'bg-elevated': '#ffffff',
'sidebar-bg': '#ffffff',
'card-bg': '#fffaf0',
'input-bg': '#fff4d8',
'hover-bg': '#ffeec1',
'border': 'rgba(250, 204, 21, 0.25)',
'text': '#1f1505',
'text-muted': '#5b4a1e',
'accent': '#fbbf24',
'accent-dark': '#c2410c',
'accent-glow': 'rgba(250, 204, 21, 0.3)',
'accent-gradient': 'linear-gradient(135deg, #fbbf24, #d97706)',
'header-bg': 'rgba(255, 255, 255, 0.98)',
},
dark: {
'bg': '#1a1203',
'bg-elevated': 'rgba(26, 18, 3, 0.9)',
'sidebar-bg': '#130e02',
'card-bg': 'rgba(26, 18, 3, 0.75)',
'input-bg': 'rgba(250, 204, 21, 0.08)',
'hover-bg': 'rgba(250, 204, 21, 0.14)',
'border': 'rgba(250, 204, 21, 0.35)',
'text': '#fff8e7',
'text-muted': '#f6dea1',
'accent': '#fbbf24',
'accent-dark': '#b45309',
'accent-glow': 'rgba(250, 204, 21, 0.25)',
'accent-gradient': 'linear-gradient(135deg, #fbbf24, #f97316)',
'header-bg': 'rgba(15, 9, 2, 0.9)',
},
},
];
const matomoContainerSrc = 'https://matomo.howdoyouconvert.com/js/container_B3r877Kn.js';
type WindowWithAnalytics = Window & {
@@ -265,6 +22,7 @@
let isMobileHeader = false;
let theme: ThemeMode = 'dark';
let selectedPaletteIndex = 0;
let savedScrollRestoration: ScrollRestoration | null = null;
$: isHomepage = $page.url.pathname === '/';
$: if (isHomepage && (sidebarOpen || headerSearchOpen)) {
sidebarOpen = false;
@@ -326,14 +84,30 @@
document.head.appendChild(script);
};
afterNavigate(() => {
const scrollToTop = () => {
if (!browser) return;
window.scrollTo({ top: 0, behavior: 'auto' });
};
afterNavigate(({ from, to, type }) => {
sidebarOpen = false;
headerSearchOpen = false;
if (!browser) return;
if (type === 'popstate') return;
if (!from || !to) return;
if (from.url.pathname === to.url.pathname) return;
scrollToTop();
});
onMount(() => {
if (!browser) return;
const appWindow = window as WindowWithAnalytics;
if ('scrollRestoration' in window.history) {
savedScrollRestoration = window.history.scrollRestoration;
window.history.scrollRestoration = 'manual';
}
let idleCallbackId: number | null = null;
let fallbackTimeoutId: number | null = null;
@@ -411,6 +185,9 @@
window.clearTimeout(fallbackTimeoutId);
}
window.removeEventListener('keydown', handleEscape);
if (savedScrollRestoration !== null) {
window.history.scrollRestoration = savedScrollRestoration;
}
};
if ('addEventListener' in mediaQuery) {
@@ -461,7 +238,7 @@
</button>
{/if}
<a href="/" class="site-logo">
<span>How Do You</span><span class="logo-accent">Convert</span><span class="logo-domain">.com</span>
<span>How</span><span>Do</span><span>You</span><span class="logo-accent">Convert</span><span class="logo-domain">.com</span>
</a>
</div>
<div class="header-right">

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { categories, calculators } from '$lib/data/calculators';
import { categories, totalCalculators } from '$lib/data/stats';
import CategoryCard from '$lib/components/CategoryCard.svelte';
import SearchBar from '$lib/components/SearchBar.svelte';
import { buildSeoMeta, SITE_NAME, SITE_URL, toJsonLd } from '$lib/seo';
@@ -18,8 +18,8 @@
key,
...meta,
}));
const totalCalculators = calculators.length;
const totalCategories = Object.keys(homepageCategories).length;
const totalConversions = totalCalculators;
const totalCategoriesCount = Object.keys(homepageCategories).length;
const pageTitle = `${SITE_NAME} — Free Unit Conversion Calculators`;
const pageDescription = 'Convert between hundreds of units instantly. Free online calculators for length, weight, temperature, volume, area, speed, energy, power, data and more.';
const seo = buildSeoMeta({
@@ -49,12 +49,12 @@
<meta name="twitter:card" content={seo.twitter.card} />
<meta name="twitter:title" content={seo.twitter.title} />
<meta name="twitter:description" content={seo.twitter.description} />
<script type="application/ld+json">{websiteJsonLd}</script>
{@html `<script type="application/ld+json">${websiteJsonLd}</script>`}
</svelte:head>
<section class="hero">
<h1>How Do You Convert?</h1>
<p>Fast, bidirectional unit conversions with no ads.</p>
<p>Fast unit conversions with no ads.</p>
<div class="search-center">
<SearchBar idPrefix="home-search" />
</div>
@@ -62,11 +62,11 @@
<div class="stats-row">
<div class="stat">
<div class="stat-num">{totalCalculators}</div>
<div class="stat-num">{totalConversions}</div>
<div class="stat-label">Converters</div>
</div>
<div class="stat">
<div class="stat-num">{totalCategories}</div>
<div class="stat-num">{totalCategoriesCount}</div>
<div class="stat-label">Categories</div>
</div>
</div>

View File

@@ -0,0 +1,3 @@
// Prerender the homepage as static HTML at build time.
// adapter-node will serve this as a static file — no SSR round-trip.
export const prerender = true;

View File

@@ -1,8 +1,5 @@
<script lang="ts">
import Calculator from '$lib/components/Calculator.svelte';
import { afterNavigate } from '$app/navigation';
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import type { PageData } from './$types';
import { buildSeoMeta, canonicalUrl, SITE_NAME, SITE_URL, toJsonLd } from '$lib/seo';
@@ -11,7 +8,9 @@
$: calc = data.calculator;
$: related = data.related;
$: pageTitle = `${calc.name}${SITE_NAME}`;
$: pageDescription = `Convert ${calc.labels.in1} to ${calc.labels.in2} instantly with our free online calculator. Accurate bidirectional conversion with the exact formula shown.`;
$: pageDescription = ['3col', '3col-mul'].includes(calc.type)
? `Compute ${calc.labels.in3 ?? 'the derived value'} using ${calc.labels.in1} and ${calc.labels.in2}. Enter any two fields to solve the third.`
: `Convert ${calc.labels.in1} to ${calc.labels.in2} instantly with our free online calculator. Accurate two-way conversion with the exact formula shown.`;
$: seo = buildSeoMeta({
title: pageTitle,
description: pageDescription,
@@ -54,15 +53,6 @@
},
});
afterNavigate(() => {
if (!browser) return;
window.scrollTo({ top: 0 });
});
onMount(() => {
if (!browser) return;
window.scrollTo({ top: 0 });
});
</script>
<svelte:head>
@@ -78,8 +68,8 @@
<meta name="twitter:card" content={seo.twitter.card} />
<meta name="twitter:title" content={seo.twitter.title} />
<meta name="twitter:description" content={seo.twitter.description} />
<script type="application/ld+json">{breadcrumbJsonLd}</script>
<script type="application/ld+json">{webPageJsonLd}</script>
{@html `<script type="application/ld+json">${breadcrumbJsonLd}</script>`}
{@html `<script type="application/ld+json">${webPageJsonLd}</script>`}
</svelte:head>
<nav class="breadcrumbs" aria-label="Breadcrumb">
@@ -92,7 +82,9 @@
<h1 class="page-title calculator-page-title">{calc.name}</h1>
<Calculator config={calc} showTitle={false} />
{#key calc.slug}
<Calculator config={calc} showTitle={false} />
{/key}
<div class="seo-content">
{#if calc.descriptionHTML}

View File

@@ -1,6 +1,28 @@
<script lang="ts">
import type { PageData } from './$types';
import { buildSeoMeta, canonicalUrl, SITE_NAME, SITE_URL, toJsonLd } from '$lib/seo';
import { getConversionRateText } from '$lib/utils/conversionRate';
const handleCalcTooltipMousemove = (event: MouseEvent) => {
const card = event.currentTarget as HTMLElement | null;
if (!card) return;
const rect = card.getBoundingClientRect();
const x = Math.min(Math.max(event.clientX - rect.left, 0), rect.width);
const y = Math.min(Math.max(event.clientY - rect.top, 0), rect.height);
card.style.setProperty('--calc-tooltip-left', `${x}px`);
card.style.setProperty('--calc-tooltip-top', `${y}px`);
card.style.setProperty('--calc-tooltip-bottom', 'auto');
card.style.setProperty('--calc-tooltip-translate', 'calc(-100% - 0.55rem)');
};
const resetCalcTooltipPosition = (event: MouseEvent) => {
const card = event.currentTarget as HTMLElement | null;
if (!card) return;
card.style.removeProperty('--calc-tooltip-left');
card.style.removeProperty('--calc-tooltip-top');
card.style.removeProperty('--calc-tooltip-bottom');
card.style.removeProperty('--calc-tooltip-translate');
};
export let data: PageData;
@@ -66,8 +88,8 @@
<meta name="twitter:card" content={seo.twitter.card} />
<meta name="twitter:title" content={seo.twitter.title} />
<meta name="twitter:description" content={seo.twitter.description} />
<script type="application/ld+json">{breadcrumbJsonLd}</script>
<script type="application/ld+json">{collectionJsonLd}</script>
{@html `<script type="application/ld+json">${breadcrumbJsonLd}</script>`}
{@html `<script type="application/ld+json">${collectionJsonLd}</script>`}
</svelte:head>
<nav class="breadcrumbs" aria-label="Breadcrumb">
@@ -85,8 +107,18 @@
<div class="calc-list">
{#each data.calculators as calc}
<a href="/{calc.slug}" class="calc-list-item">
{@const conversionRateText = getConversionRateText(calc)}
<a
href="/{calc.slug}"
class="calc-list-item"
on:mousemove={handleCalcTooltipMousemove}
on:mouseleave={resetCalcTooltipPosition}
on:focus={resetCalcTooltipPosition}
>
{calc.name}
{#if conversionRateText}
<span class="calc-list-tooltip" role="tooltip">{conversionRateText}</span>
{/if}
</a>
{/each}
</div>

View File

@@ -3,8 +3,7 @@ import { calculators, categories } from '$lib/data/calculators';
export const GET: RequestHandler = async () => {
const calculatorUrls = calculators.map(
(calc) => `
<url>
(calc) => ` <url>
<loc>https://howdoyouconvert.com/${calc.slug}</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
@@ -12,8 +11,7 @@ export const GET: RequestHandler = async () => {
);
const categoryUrls = Object.keys(categories).map(
(category) => `
<url>
(category) => ` <url>
<loc>https://howdoyouconvert.com/category/${category}</loc>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
@@ -27,8 +25,8 @@ export const GET: RequestHandler = async () => {
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
${categoryUrls.join('')}
${calculatorUrls.join('')}
${categoryUrls.join('\n')}
${calculatorUrls.join('\n')}
</urlset>`;
return new Response(sitemap, {

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,13 @@
import json
import re
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
CALCLIST = BASE_DIR / 'calculators_list.md'
OUTPUT_FILE = BASE_DIR / 'hdyc-svelte/src/lib/data/calculators.ts'
STATS_FILE = BASE_DIR / 'hdyc-svelte/src/lib/data/stats.ts'
CALCULATORS_JSON = BASE_DIR / 'hdyc-svelte/static/data/calculators.json'
CATEGORY_KEYS = [
'length',
@@ -29,8 +32,87 @@ CATEGORY_KEYS = [
'other',
]
CATEGORIES = {
'length': {'label': 'Length / Distance', 'icon': '📏'},
'weight': {'label': 'Weight / Mass', 'icon': '⚖️'},
'temperature': {'label': 'Temperature', 'icon': '🌡️'},
'volume': {'label': 'Volume', 'icon': '🧪'},
'fluids': {'label': 'Fluids', 'icon': '💧'},
'area': {'label': 'Area', 'icon': '🔳'},
'speed': {'label': 'Speed / Velocity', 'icon': '💨'},
'pressure': {'label': 'Pressure', 'icon': '🔽'},
'energy': {'label': 'Energy', 'icon': ''},
'magnetism': {'label': 'Magnetism', 'icon': '🧲'},
'power': {'label': 'Power', 'icon': '🔌'},
'data': {'label': 'Data Storage', 'icon': '💾'},
'time': {'label': 'Time', 'icon': '⏱️'},
'angle': {'label': 'Angle', 'icon': '📐'},
'number-systems': {'label': 'Number Systems', 'icon': '🔢'},
'radiation': {'label': 'Radiation', 'icon': '☢️'},
'electrical': {'label': 'Electrical', 'icon': '🔋'},
'force': {'label': 'Force / Torque', 'icon': '💪'},
'light': {'label': 'Light', 'icon': '💡'},
'other': {'label': 'Other', 'icon': '🔄'},
}
CATEGORY_SET = set(CATEGORY_KEYS)
# Lightweight label normalization to catch duplicate/identity conversions
# that differ only by abbreviations (e.g., "cm" vs "centimeters").
TOKEN_MAP = {
'cm': 'centimeter',
'centimeter': 'centimeter',
'centimetre': 'centimeter',
'centimetres': 'centimeter',
'centimeters': 'centimeter',
'mm': 'millimeter',
'millimeter': 'millimeter',
'millimeters': 'millimeter',
'millimetre': 'millimeter',
'millimetres': 'millimeter',
'm': 'meter',
'meter': 'meter',
'meters': 'meter',
'metre': 'meter',
'metres': 'meter',
'km': 'kilometer',
'kilometer': 'kilometer',
'kilometers': 'kilometer',
'kilometre': 'kilometer',
'kilometres': 'kilometer',
'in': 'inch',
'inch': 'inch',
'inches': 'inch',
'ft': 'foot',
'foot': 'foot',
'feet': 'foot',
}
def normalize_label(label: str) -> str:
"""Canonicalize a unit label for duplicate detection.
- Lowercase
- Replace '/' with ' per ' to align fraction style with text style
- Strip punctuation into tokens
- Collapse common abbreviations/plurals via TOKEN_MAP and simple singularization
"""
cleaned = label.lower().replace('/', ' per ')
tokens = re.split(r'[^a-z0-9]+', cleaned)
normalized_tokens = []
for tok in tokens:
if not tok:
continue
base = tok
# Drop a trailing 's' for simple plurals, but avoid short abbreviations like 'cms'
if base.endswith('s') and len(base) > 3:
base = base[:-1]
base = TOKEN_MAP.get(base, base)
normalized_tokens.append(base)
return ' '.join(normalized_tokens)
def load_external_descriptions():
# Placeholder for future enrichment sources.
return {}
@@ -92,6 +174,7 @@ def process():
calculators_ts_entries = []
seen_slugs = set()
seen_norm_pairs = set()
for raw_name, slug, category_raw, factor_raw in active_rows:
if raw_name == 'Calculator Name' or not slug:
continue
@@ -104,7 +187,23 @@ def process():
in1, in2 = parsed
else:
in1, in2 = "From", "To"
custom_labels = None
norm_in1 = normalize_label(in1)
norm_in2 = normalize_label(in2)
# Skip identity conversions that only differ by spelling/abbreviation
if norm_in1 == norm_in2:
print(f"Skipping identity converter {slug}: {in1} -> {in2}")
continue
pair_key = (norm_in1, norm_in2)
if pair_key in seen_norm_pairs:
print(f"Skipping duplicate converter {slug}: {in1} -> {in2}")
continue
seen_norm_pairs.add(pair_key)
category = normalize_category(category_raw)
if not category:
raise ValueError(f'Category required for {display_name}')
@@ -118,8 +217,26 @@ def process():
c_type = 'standard'
factor_val = "1"
offset_val = "0"
if '1/x' in factor_raw:
# Special-case calculator families that require custom math beyond simple factors.
if 'molarity-to-grams-per-liter' == slug:
c_type = 'molarity'
custom_labels = {'in1': 'Molarity (mol/L)', 'in2': 'Grams per liter', 'in3': 'Molar mass (g/mol)'}
elif 'rockwell-c-to-vickers' == slug:
c_type = 'rockwell-vickers'
elif 'ev-to-lux' in slug or 'lux-to-ev' in slug:
c_type = 'ev-lux'
elif 'focal-length-to-angle-of-view' in slug:
c_type = 'aov'
elif 'awg' in slug:
c_type = 'awg'
elif 'swg-to' in slug or '-to-swg' in slug:
c_type = 'swg'
elif 'brinell-to-rockwell-c' == slug or 'rockwell-c-to-brinell' == slug:
c_type = 'brinell-rockwell'
elif 'saybolt-universal-seconds-to-centistokes' == slug:
c_type = 'sus-cst'
elif '1/x' in factor_raw:
c_type = 'inverse'
factor_val = "1"
elif 'Multi-Variable' in factor_raw:
@@ -167,6 +284,11 @@ def process():
except:
pass
# Give 3-col calculators honest display names instead of "A to B"
if c_type in ['3col', '3col-mul'] and split_conversion_name(display_name):
op = '*' if c_type == '3col-mul' else '/'
display_name = f"{in1} {op} {in2}"
# Avoid escaping single quotes by using JSON or dict
entry = {
'slug': slug,
@@ -180,11 +302,19 @@ def process():
# Determine labels
labels = {'in1': in1, 'in2': in2}
if c_type in ['3col', '3col-mul']:
# generic 3rd label
if 'watts' in slug and 'amps' in slug: labels['in3'] = 'Volts'
elif 'lumens' in slug: labels['in3'] = 'Area (sq m)'
elif 'moles' in slug: labels['in3'] = 'Molar Mass'
else: labels['in3'] = 'Result'
# generic 3rd label; make it descriptive instead of the vague "Result"
if 'watts' in slug and 'amps' in slug:
labels['in3'] = 'Volts'
elif 'lumens' in slug:
labels['in3'] = 'Area (sq m)'
elif 'moles' in slug:
labels['in3'] = 'Molar Mass'
else:
op = '*' if c_type == '3col-mul' else '/'
labels['in3'] = f"{in1} {op} {in2}"
if custom_labels:
labels = custom_labels
entry['labels'] = labels
@@ -240,7 +370,7 @@ def process():
def get_val(k):
if k in units_10: return 10, units_10[k]
if k in units_2: return 2, units_2[k]
if k == 'bit': return 10, -1 # placeholder relative to bytes, though bits are 1/8 byte. Handling simple bytes here only
if k == 'bit': return 2, -3 # bit is 1/8 of a byte (2^-3)
return None, None
b1, e1 = get_val(in1_key)
@@ -296,7 +426,7 @@ def process():
# Ensure types are right
# write to TS
out = """// THIS FILE IS AUTO-GENERATED BY migrate.py
export type CalcType = 'standard' | 'inverse' | '3col' | '3col-mul' | 'base' | 'text-bin' | 'bin-text' | 'dms-dd' | 'dd-dms' | 'dec-frac' | 'db-int' | 'db-spl' | 'db-v' | 'db-w';
export type CalcType = 'standard' | 'inverse' | '3col' | '3col-mul' | 'base' | 'text-bin' | 'bin-text' | 'dms-dd' | 'dd-dms' | 'dec-frac' | 'db-int' | 'db-spl' | 'db-v' | 'db-w' | 'awg' | 'brinell-rockwell' | 'ev-lux' | 'aov' | 'swg' | 'rockwell-vickers' | 'sus-cst' | 'molarity';
export interface CalculatorDef {
slug: string;
@@ -314,28 +444,12 @@ export interface CalculatorDef {
}
export const categories: Record<string, { label: string; icon: string }> = {
length: { label: 'Length / Distance', icon: '📏' },
weight: { label: 'Weight / Mass', icon: '⚖️' },
temperature: { label: 'Temperature', icon: '🌡️' },
volume: { label: 'Volume', icon: '🧪' },
fluids: { label: 'Fluids', icon: '💧' },
area: { label: 'Area', icon: '📐' },
speed: { label: 'Speed / Velocity', icon: '💨' },
pressure: { label: 'Pressure', icon: '🔽' },
energy: { label: 'Energy', icon: '' },
magnetism: { label: 'Magnetism', icon: '🧲' },
power: { label: 'Power', icon: '🔌' },
data: { label: 'Data Storage', icon: '💾' },
time: { label: 'Time', icon: '⏱️' },
angle: { label: 'Angle', icon: '📐' },
'number-systems':{ label: 'Number Systems', icon: '🔢' },
radiation: { label: 'Radiation', icon: '☢️' },
electrical: { label: 'Electrical', icon: '🔋' },
force: { label: 'Force / Torque', icon: '💪' },
light: { label: 'Light', icon: '💡' },
other: { label: 'Other', icon: '🔄' },
};
"""
for k, v in CATEGORIES.items():
out += f" '{k}': {json.dumps(v, ensure_ascii=False).replace('{', '{ ').replace('}', ' }')},\n"
out += "};\n"
out += """
export const calculators: CalculatorDef[] = [
"""
for e in calculators_ts_entries:
@@ -350,8 +464,13 @@ export const calculators: CalculatorDef[] = [
out += """
];
const slugIndex = new Map(calculators.map(c => [c.slug, c]));
const slugIndex: Map<string, CalculatorDef> = new Map(
calculators.map(calc => [calc.slug, calc])
);
"""
out += """
export function getCalculatorBySlug(slug: string): CalculatorDef | undefined {
return slugIndex.get(slug);
}
@@ -383,5 +502,22 @@ export function searchCalculators(query: string): CalculatorDef[] {
print(f"Generated {len(calculators_ts_entries)} calculators into calculators.ts")
# Generate stats.ts
total_count = len(calculators_ts_entries)
stats_content = f"""// THIS FILE IS AUTO-GENERATED BY migrate.py
export const categories: Record<string, {{ label: string; icon: string }}> = {json.dumps(CATEGORIES, indent=2, ensure_ascii=False)};
export const totalCalculators = {total_count};
"""
with open(STATS_FILE, 'w', encoding='utf-8') as f:
f.write(stats_content)
print(f"Generated stats.ts with {total_count} total calculators")
# Generate calculators.json for true lazy loading
os.makedirs(os.path.dirname(CALCULATORS_JSON), exist_ok=True)
with open(CALCULATORS_JSON, 'w', encoding='utf-8') as f:
json.dump(calculators_ts_entries, f, ensure_ascii=False, indent=2)
print(f"Generated calculators.json (Size: {os.path.getsize(CALCULATORS_JSON) // 1024}KB)")
if __name__ == '__main__':
process()

View File

@@ -0,0 +1,27 @@
import urllib.error
import urllib.request
import unittest
URL = 'https://howdoyouconvert.com/apothecary-ounces-to-amu'
class ApothecaryPageTests(unittest.TestCase):
def test_apothecary_page_returns_success(self) -> None:
"""The published URL should return a 200 so the calculator page stays healthy."""
request = urllib.request.Request(
URL,
headers={
'User-Agent': 'Mozilla/5.0',
'Accept': 'text/html,application/xhtml+xml',
},
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
status = response.getcode()
except urllib.error.HTTPError as exc:
self.fail(f'{URL} returned HTTP {exc.code} ({exc.reason})')
except urllib.error.URLError as exc:
self.fail(f'{URL} could not be fetched: {exc}')
self.assertEqual(status, 200, f'{URL} returned {status}')

106
tests/test_consistency.py Normal file
View File

@@ -0,0 +1,106 @@
import math
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CALCULATORS_TS = ROOT / "hdyc-svelte" / "src" / "lib" / "data" / "calculators.ts"
def _js_fmt(n: float) -> str:
"""Mimics the fmt() function in engine.ts"""
if not math.isfinite(n):
return str(n)
if n == 0:
return "0"
if abs(n) < 1e-6:
return f"{n:.6e}".replace("e-0", "e-").replace("e+0", "e+")
# engine.ts uses parseFloat(n.toFixed(6)).toString()
rounded = round(n, 6)
if rounded == 0: # Handle -0.0
return "0"
if rounded == int(rounded):
return str(int(rounded))
return str(rounded)
def _js_fmt_precise(n: float) -> str:
"""Mimics formatExampleValue in QuickConversionExample.svelte"""
if n is None or math.isnan(n):
return ""
if not math.isfinite(n):
return str(n)
if n == 0:
return "0"
rounded = round(n, 6)
if rounded != 0:
if rounded == int(rounded):
return str(int(rounded))
return str(rounded)
# Precise version for very small numbers
precise = f"{n:.12f}".rstrip('0').rstrip('.')
return precise if precise else "0"
class TestCalculatorsConsistency(unittest.TestCase):
def test_standard_calculators_consistency(self):
text = CALCULATORS_TS.read_text(encoding="utf-8")
# Extract the calculators array content
match = re.search(r"export const calculators: CalculatorDef\[\] = \[(.*?)\];", text, re.S)
self.assertTrue(match, "Could not find calculators array in calculators.ts")
body = match.group(1)
# Split by '{"slug":' to avoid splitting on nested braces
raw_entries = body.split('{"slug":')
errors = []
for raw_entry in raw_entries:
if not raw_entry.strip():
continue
entry = '{"slug":' + raw_entry
slug_match = re.search(r'"slug": "(.*?)"', entry)
if not slug_match:
continue
slug = slug_match.group(1)
type_match = re.search(r'"type": "(.*?)"', entry)
if not type_match or type_match.group(1) != "standard":
continue
# Use non-greedy search for factor/offset and handle potential whitespace
factor_match = re.search(r'"factor":\s*([0-9.eE+-]+)', entry)
offset_match = re.search(r'"offset":\s*([0-9.eE+-]+)', entry)
factor = float(factor_match.group(1)) if factor_match else 1.0
offset = float(offset_match.group(1)) if offset_match else 0.0
# 1. Formula Hint vs Chart Row 1 Consistency
# Logic: solve(config, 1, "1") -> val2 = fmt(1 * factor + offset)
row_one_output = _js_fmt(1.0 * factor + offset)
# 2. How to convert Examples Consistency (Inverse)
if factor != 0:
reverse_val = (1.0 - offset) / factor
formatted_reverse = _js_fmt_precise(reverse_val)
# Specific check for Réaumur to Kelvin (User request)
if slug == "reaumur-to-kelvin":
if formatted_reverse != "-217.72":
errors.append(f"[{slug}] Reverse example mismatch: expected -217.72, got {formatted_reverse}")
if row_one_output != "274.4":
errors.append(f"[{slug}] Chart row 1 mismatch: expected 274.4, got {row_one_output}")
# Specific check for Feet per minute to Knots (Previous bug fix)
if slug == "feet-per-minute-to-knots":
if row_one_output != "0.009875":
errors.append(f"[{slug}] Chart row 1 mismatch: expected 0.009875, got {row_one_output}")
if formatted_reverse != "101.268504":
errors.append(f"[{slug}] Reverse example mismatch: expected 101.268504, got {formatted_reverse}")
if errors:
self.fail("\n" + "\n".join(errors))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,46 @@
import math
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CALCULATORS_TS = ROOT / "hdyc-svelte" / "src" / "lib" / "data" / "calculators.ts"
NUMERIC_SAMPLES = [0.1, 0.5, 1, 2, 5, 10, 20, 50, 100]
TARGET_SLUG = "angstroms-to-nautical-miles"
def _parse_factor(slug: str) -> float:
text = CALCULATORS_TS.read_text(encoding="utf-8")
match = re.search(rf'"slug": "{slug}".*?"factor": ([0-9.eE+-]+)', text, flags=re.S)
if not match:
raise AssertionError(f"Could not find calculator definition for '{slug}'")
return float(match.group(1))
def _js_fmt(value: float) -> str:
if not math.isfinite(value):
return str(value)
if value == 0:
return "0"
abs_val = abs(value)
if abs_val < 1e-6:
return f"{value:.6e}"
rounded = float(f"{value:.6f}")
if rounded.is_integer():
return str(int(rounded))
return repr(rounded)
class QuickConversionChartRoundingTests(unittest.TestCase):
def test_angstroms_to_nautical_miles_chart_does_not_round_to_zero(self) -> None:
factor = _parse_factor(TARGET_SLUG)
outputs = [_js_fmt(sample * factor) for sample in NUMERIC_SAMPLES]
self.assertTrue(
any(output != "0" for output in outputs),
f"{TARGET_SLUG} quick conversion chart rounded every sample down to 0: {outputs}",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,27 @@
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CONVERSION_RATE = (
ROOT / "hdyc-svelte" / "src" / "lib" / "utils" / "conversionRate.ts"
)
class ConversionRateTooltipFormattingTests(unittest.TestCase):
def test_conversion_rate_text_uses_formatter(self) -> None:
text = CONVERSION_RATE.read_text(encoding="utf-8")
normalized = " ".join(text.split())
self.assertIn(
"formatConversionValue(config.factor)",
normalized,
"Conversion rate helper must format the factor before inserting it into the tooltip text",
)
self.assertIn(
"formatConversionValue(config.offset ?? 0)",
normalized,
"Conversion rate tooltip must format any offset before rendering",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,68 @@
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CALCULATORS_TS = ROOT / "hdyc-svelte" / "src" / "lib" / "data" / "calculators.ts"
CONVERSION_RATE_UTIL = ROOT / "hdyc-svelte" / "src" / "lib" / "utils" / "conversionRate.ts"
CATEGORY_PAGE = ROOT / "hdyc-svelte" / "src" / "routes" / "category" / "[category]" / "+page.svelte"
CALCULATOR_COMPONENT = ROOT / "hdyc-svelte" / "src" / "lib" / "components" / "Calculator.svelte"
TARGET_SLUG = "gauss-to-oersted"
NON_APPLICABLE_TWO_INPUT_STANDARD_SLUGS = {
"grams-per-liter-to-molarity",
}
def _extract_calculator_block(slug: str) -> str:
for line in CALCULATORS_TS.read_text(encoding="utf-8").splitlines():
if f'"slug": "{slug}"' in line:
return line
raise AssertionError(f"Could not find calculator definition for '{slug}'")
class GaussToOerstedConversionRateRegressionTests(unittest.TestCase):
def test_gauss_to_oersted_includes_factor_for_conversion_rate_and_tooltip(self) -> None:
block = _extract_calculator_block(TARGET_SLUG)
self.assertRegex(
block,
r'"factor":\s*[0-9.eE+-]+',
"Missing factor on gauss-to-oersted prevents conversion rate text from rendering in both calculator footer and category tooltip.",
)
def test_conversion_rate_is_wired_to_both_surfaces(self) -> None:
util_text = CONVERSION_RATE_UTIL.read_text(encoding="utf-8")
category_page_text = CATEGORY_PAGE.read_text(encoding="utf-8")
calculator_component_text = CALCULATOR_COMPONENT.read_text(encoding="utf-8")
self.assertIn("getConversionRateText", util_text)
self.assertIn("conversionRateText = getConversionRateText(calc)", category_page_text)
self.assertIn('role="tooltip"', category_page_text)
self.assertIn("conversionRateText = getConversionRateText(config)", calculator_component_text)
self.assertIn('<span class="formula-hint">', calculator_component_text)
def test_all_applicable_two_input_standard_calculators_have_factors(self) -> None:
missing_factors: list[str] = []
for line in CALCULATORS_TS.read_text(encoding="utf-8").splitlines():
if '"type": "standard"' not in line:
continue
if '"labels": {"in1":' not in line or '"in2":' not in line or '"in3":' in line:
continue
slug_match = re.search(r'"slug": "([^"]+)"', line)
if not slug_match:
continue
slug = slug_match.group(1)
if slug in NON_APPLICABLE_TWO_INPUT_STANDARD_SLUGS:
continue
if '"factor":' not in line:
missing_factors.append(slug)
self.assertEqual(
missing_factors,
[],
f"Two-input standard calculators missing factors (and therefore conversion-rate text): {missing_factors}",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -72,7 +72,7 @@ class HomepageCategoryRegressionTests(unittest.TestCase):
def test_homepage_uses_canonical_categories_map(self) -> None:
text = HOMEPAGE_SVELTE.read_text(encoding="utf-8")
self.assertIn("import { categories, calculators } from '$lib/data/calculators';", text)
self.assertIn("import { categories, totalCalculators } from '$lib/data/stats';", text)
self.assertIn("requiredCategoryFallbacks", text)
self.assertIn("fluids: { label: 'Fluids', icon: '💧' }", text)
self.assertIn("magnetism: { label: 'Magnetism', icon: '🧲' }", text)

View File

@@ -0,0 +1,26 @@
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
QUICK_CONVERSION_EXAMPLE = (
ROOT / "hdyc-svelte" / "src" / "lib" / "components" / "QuickConversionExample.svelte"
)
class QuickConversionExampleFormattingTests(unittest.TestCase):
def test_formula_uses_formatted_factor_and_offset(self) -> None:
text = QUICK_CONVERSION_EXAMPLE.read_text(encoding="utf-8")
normalized = " ".join(text.split())
snippet = (
"1 {config.labels.in1} = {formattedFactorValue}{hasOffset ? ` + ${formattedOffsetValue}` : ''} "
"{config.labels.in2}"
)
self.assertIn(
snippet,
normalized,
"The formula snippet should render formatted factor/offset values instead of raw floats",
)
if __name__ == "__main__":
unittest.main()