This commit is contained in:
whilb 2025-08-16 17:54:12 -07:00
commit 97f9a95415
21 changed files with 2963 additions and 0 deletions

View file

@ -0,0 +1,21 @@
import {fmt, revive, persist, labelInput, labelSelect} from '/js/util.js';
export default {
id:'bandwidth', name:'Bandwidth / Bytes Converter', about:'Convert bits↔bytes and SI↔IEC units',
render(root){
const key='calc_bandwidth_v1';
const s = revive(key,{value:100, unit:'Mbps'});
const units = ['bps','Kbps','Mbps','Gbps','Tbps','B/s','KB/s','MB/s','GB/s','TB/s','KiB/s','MiB/s','GiB/s','TiB/s'];
const factors = {'bps':1,'Kbps':1e3,'Mbps':1e6,'Gbps':1e9,'Tbps':1e12,'B/s':8,'KB/s':8e3,'MB/s':8e6,'GB/s':8e9,'TB/s':8e12,'KiB/s':8*1024,'MiB/s':8*1024**2,'GiB/s':8*1024**3,'TiB/s':8*1024**4};
const ui = document.createElement('div');
ui.append(labelInput('Value','number','value', s.value,{step:'0.000001',min:'0'}), labelSelect('Unit','unit', s.unit, units.map(u=>[u,u])));
const out = document.createElement('div'); out.className='result'; ui.append(out);
function calc(){
const v = +ui.querySelector('[name=value]').value||0;
const unit = ui.querySelector('[name=unit]').value;
const bps = v * factors[unit];
out.innerHTML = units.map(u=>`<div><strong>${u}</strong>: ${fmt.format(bps / factors[u])}</div>`).join('');
persist(key,{value:v, unit});
}
ui.addEventListener('input', calc); calc(); root.append(ui);
}
}

View file

@ -0,0 +1,399 @@
import {revive, persist, labelInput, labelSelect} from '/js/util.js';
export default {
id:'currency', name:'Currency Converter', about:'Convert between currencies using real-time exchange rates.',
render(root){
const key='calc_currency_v1';
const s = revive(key,{amount:100, from:'USD', to:'EUR', manualRate: null});
const ui = document.createElement('div');
// Popular currencies with better formatting
const currencies = [
['USD', 'US Dollar'],
['EUR', 'Euro'],
['GBP', 'British Pound'],
['JPY', 'Japanese Yen'],
['CAD', 'Canadian Dollar'],
['AUD', 'Australian Dollar'],
['CHF', 'Swiss Franc'],
['CNY', 'Chinese Yuan'],
['INR', 'Indian Rupee'],
['BRL', 'Brazilian Real']
];
// Create a more user-friendly form layout
const formContainer = document.createElement('div');
formContainer.style.cssText = `
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-bottom: 20px;
`;
// Amount input (full width)
const amountDiv = document.createElement('div');
amountDiv.style.gridColumn = '1 / -1';
amountDiv.innerHTML = `
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--text);">
Amount to Convert
</label>
<input type="number" name="amount" value="${s.amount}" step="0.01" min="0"
style="width: 100%; padding: 12px; border: 1px solid var(--border); border-radius: 8px; font-size: 16px;"
placeholder="Enter amount">
`;
// From currency (left column) - using Select Lite
const fromDiv = document.createElement('div');
fromDiv.innerHTML = `
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--text);">
From Currency
</label>
<select name="from" data-ui="lite" style="width: 100%;">
${currencies.map(([code, name]) =>
`<option value="${code}" ${code === s.from ? 'selected' : ''}>${code} - ${name}</option>`
).join('')}
</select>
`;
// To currency (right column) - using Select Lite
const toDiv = document.createElement('div');
toDiv.innerHTML = `
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--text);">
To Currency
</label>
<select name="to" data-ui="lite" style="width: 100%;">
${currencies.map(([code, name]) =>
`<option value="${code}" ${code === s.to ? 'selected' : ''}>${code} - ${name}</option>`
).join('')}
</select>
`;
formContainer.append(amountDiv, fromDiv, toDiv);
ui.append(formContainer);
// Manual rate input option
const manualRateDiv = document.createElement('div');
manualRateDiv.innerHTML = `
<div style="margin: 15px 0; padding: 15px; border: 1px solid var(--border); border-radius: 8px; background: var(--k-bg);">
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--text);">
Custom Rate (Optional)
</label>
<input type="number" name="manualRate" placeholder="Enter custom conversion rate" step="0.0001" min="0"
style="width: 100%; padding: 10px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px;">
<div style="font-size: 12px; color: var(--muted); margin-top: 8px; line-height: 1.4;">
<strong>Format:</strong> 1 ${s.from} = X ${s.to}<br>
<strong>Example:</strong> If 1 USD = 0.85 EUR, enter 0.85<br>
<strong>Leave empty</strong> to use current market rates
</div>
</div>
`;
ui.append(manualRateDiv);
// Fetch rates button with better styling
const fetchBtn = document.createElement('button');
fetchBtn.type = 'button';
fetchBtn.textContent = 'Update Exchange Rates';
fetchBtn.className = 'btn';
fetchBtn.style.cssText = `
width: 100%;
padding: 12px;
margin: 15px 0;
font-size: 16px;
font-weight: 500;
background: var(--accent);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
`;
fetchBtn.addEventListener('mouseenter', () => fetchBtn.style.background = 'var(--accent-hover)');
fetchBtn.addEventListener('mouseleave', () => fetchBtn.style.background = 'var(--accent)');
ui.append(fetchBtn);
const out = document.createElement('div');
out.className = 'result';
out.style.cssText = `
margin: 20px 0;
padding: 15px;
background: var(--k-bg);
border-radius: 8px;
border-left: 4px solid var(--accent);
`;
ui.append(out);
const status = document.createElement('div');
status.className='status';
ui.append(status);
// Static rates from today (2025-09-01) - no prefetching
const staticRates = {
USD: 1,
EUR: 0.856,
GBP: 0.741,
JPY: 147.15,
CAD: 1.37,
AUD: 1.53,
CHF: 0.801,
CNY: 7.13,
INR: 88.19,
BRL: 5.43
};
let rates = staticRates;
let lastFetch = 0;
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
// Function to get current static rate for currency pair
function getCurrentStaticRate(from, to) {
if (from === to) return 1;
if (rates && rates[from] && rates[to]) {
return rates[to] / rates[from];
}
return null;
}
// Function to update custom rate with current static rate
function updateCustomRateWithStatic() {
const from = ui.querySelector('[name=from]').value;
const to = ui.querySelector('[name=to]').value;
const staticRate = getCurrentStaticRate(from, to);
if (staticRate !== null) {
const manualRateInput = ui.querySelector('[name=manualRate]');
// Update manual rate to current market rate when fetching new rates
manualRateInput.value = staticRate.toFixed(4);
manualRateInput.placeholder = `Current rate: ${staticRate.toFixed(4)}`;
// Trigger calculation with the new rates
calc();
}
}
async function fetchRates(){
const now = Date.now();
if (rates && (now - lastFetch) < CACHE_DURATION) {
status.textContent = 'Using cached rates (updated within 5 minutes)';
status.className = 'status success';
status.style.display = 'block';
setTimeout(() => {
status.textContent = '';
status.style.display = 'none';
}, 3000);
return rates;
}
try {
status.textContent = 'Fetching latest exchange rates...';
status.className = 'status loading';
const response = await fetch('https://api.exchangerate-api.com/v4/latest/USD');
if (!response.ok) throw new Error('Failed to fetch rates');
const data = await response.json();
rates = data.rates;
rates.USD = 1; // Base currency
lastFetch = now;
status.textContent = 'Rates updated successfully!';
status.className = 'status success';
status.style.display = 'block';
setTimeout(() => {
status.textContent = '';
status.style.display = 'none';
}, 1500);
// Update custom rate with new rates and recalculate
updateCustomRateWithStatic();
return rates;
} catch (error) {
console.error('Failed to fetch exchange rates:', error);
status.textContent = 'Failed to fetch rates. Using static rates from today.';
status.className = 'status error';
// Keep using static rates
return rates;
}
}
function calc(){
const amount = +ui.querySelector('[name=amount]').value || 0;
const from = ui.querySelector('[name=from]').value;
const to = ui.querySelector('[name=to]').value;
const manualRate = +ui.querySelector('[name=manualRate]').value || null;
console.log('Calc function called with:', { amount, from, to, manualRate });
if (amount <= 0) {
out.innerHTML = '<div class="muted">Enter a positive amount to convert</div>';
return;
}
if (from === to) {
out.innerHTML = `
<div style="font-size: 18px; font-weight: 600; color: var(--text);">
${amount.toFixed(2)} ${from}
</div>
<div style="color: var(--muted); margin-top: 5px;">
Same currency - no conversion needed
</div>
`;
return;
}
let conversionRate;
let rateSource;
let rateDate;
if (manualRate && manualRate > 0) {
console.log('Using manual rate:', manualRate);
// Use manual rate
conversionRate = manualRate;
// Determine if this is user-entered or default custom rate
const staticRate = staticRates[to] / staticRates[from];
const isUserEntered = Math.abs(manualRate - staticRate) > 0.0001; // Allow for floating point precision
rateSource = isUserEntered ? 'Custom rate' : 'Static rates';
rateDate = isUserEntered ? 'Manual input' : '2025-09-01';
out.innerHTML = `
<div style="font-size: 24px; font-weight: 700; color: var(--accent); margin-bottom: 10px;">
${(amount * conversionRate).toFixed(2)} ${to}
</div>
<div style="color: var(--text); margin-bottom: 5px;">
<strong>Conversion:</strong> ${amount} ${from} × ${conversionRate.toFixed(4)} = ${(amount * conversionRate).toFixed(2)} ${to}
</div>
<div style="color: var(--muted); font-size: 14px;">
<strong>Rate:</strong> 1 ${from} = ${conversionRate.toFixed(4)} ${to}<br>
<strong>Source:</strong> ${rateSource}<br>
<strong>Date:</strong> ${rateDate}
</div>
`;
} else if (rates) {
console.log('Using market rates, manual rate was:', manualRate);
// Use fetched or static rates (fallback when no manual rate)
conversionRate = rates[to] / rates[from];
rateSource = lastFetch > 0 ? 'Live rates' : 'Static rates';
rateDate = lastFetch > 0 ? new Date(lastFetch).toLocaleString() : '2025-09-01';
out.innerHTML = `
<div style="font-size: 24px; font-weight: 700; color: var(--accent); margin-bottom: 10px;">
${(amount * conversionRate).toFixed(2)} ${to}
</div>
<div style="color: var(--text); margin-bottom: 5px;">
<strong>Conversion:</strong> ${amount} ${from} × ${conversionRate.toFixed(4)} = ${(amount * conversionRate).toFixed(2)} ${to}
</div>
<div style="color: var(--muted); font-size: 14px;">
<strong>Rate:</strong> 1 ${from} = ${conversionRate.toFixed(4)} ${to}<br>
<strong>Rate:</strong> 1 ${to} = ${(1/conversionRate).toFixed(4)} ${from}<br>
<strong>Source:</strong> ${rateSource}<br>
<strong>Rates accurate as of:</strong> ${rateDate}
</div>
`;
} else {
out.innerHTML = '<div class="muted">Unable to calculate conversion</div>';
return;
}
persist(key, {amount, from, to, manualRate});
}
// Manual rate input handler
const manualRateInput = manualRateDiv.querySelector('[name="manualRate"]');
manualRateInput.value = s.manualRate || '';
// Debug logging
console.log('Setting up manual rate input handler');
// Set up the event listener
manualRateInput.addEventListener('input', (e) => {
console.log('Manual rate input event fired:', e.target.value);
// Clear any existing status messages
status.textContent = '';
status.style.display = 'none';
// Update the stored manual rate
s.manualRate = +e.target.value || null;
console.log('Updated manual rate to:', s.manualRate);
// Simple test - just update the display immediately
if (s.manualRate && s.manualRate > 0) {
const amount = +ui.querySelector('[name=amount]').value || 0;
const from = ui.querySelector('[name=from]').value;
const to = ui.querySelector('[name=to]').value;
console.log('Immediate calculation test:', { amount, from, to, manualRate: s.manualRate });
// Determine if this is user-entered or default custom rate
const staticRate = staticRates[to] / staticRates[from];
const isUserEntered = Math.abs(s.manualRate - staticRate) > 0.0001; // Allow for floating point precision
const rateSource = isUserEntered ? 'Custom rate' : 'Static rates';
// Show immediate result
out.innerHTML = `
<div style="font-size: 24px; font-weight: 700; color: var(--accent); margin-bottom: 10px;">
${(amount * s.manualRate).toFixed(2)} ${to}
</div>
<div style="color: var(--text); margin-bottom: 5px;">
<strong>Conversion:</strong> ${amount} ${from} × ${s.manualRate.toFixed(4)} = ${(amount * s.manualRate).toFixed(2)} ${to}
</div>
<div style="color: var(--muted); font-size: 14px;">
<strong>Rate:</strong> 1 ${from} = ${s.manualRate.toFixed(4)} ${to}<br>
<strong>Source:</strong> ${rateSource}
</div>
`;
} else {
// Fall back to normal calculation
calc();
}
});
// Also add change event as backup
manualRateInput.addEventListener('change', (e) => {
console.log('Manual rate change event fired:', e.target.value);
s.manualRate = +e.target.value || null;
calc();
});
// Currency change handlers
const fromSelect = fromDiv.querySelector('[name=from]');
const toSelect = toDiv.querySelector('[name=to]');
fromSelect.addEventListener('change', () => {
s.from = fromSelect.value;
// Update manual rate description and custom rate
const desc = manualRateDiv.querySelector('div');
desc.innerHTML = desc.innerHTML.replace(/1 [A-Z]{3} = X [A-Z]{3}/, `1 ${s.from} = X ${s.to}`);
updateCustomRateWithStatic();
calc();
});
toSelect.addEventListener('change', () => {
s.to = toSelect.value;
// Update manual rate description and custom rate
const desc = manualRateDiv.querySelector('div');
desc.innerHTML = desc.innerHTML.replace(/1 [A-Z]{3} = X [A-Z]{3}/, `1 ${s.from} = X ${s.to}`);
updateCustomRateWithStatic();
calc();
});
// Amount input handler
const amountInput = amountDiv.querySelector('[name="amount"]');
amountInput.addEventListener('input', calc);
// Fetch rates button click handler
fetchBtn.addEventListener('click', async () => {
await fetchRates();
calc(); // Recalculate with new rates
});
// Initialize custom rate with current static rate
updateCustomRateWithStatic();
// Initial calculation
calc();
root.append(ui);
}
}

View file

@ -0,0 +1,50 @@
import {currency, revive, persist, labelInput, labelSelect} from '/js/util.js';
export default {
id:'interest', name:'Interest (Simple & Compound)', about:'Compute simple or compound interest with flexible compounding and contributions.',
render(root){
const key='calc_interest_v1';
const s = revive(key,{principal:1000, rate:5, years:3, compound:'12', contrib:0, contribFreq:'12'});
const ui = document.createElement('div');
ui.append(
labelInput('Principal','number','principal', s.principal,{step:'0.01',min:'0'}),
labelInput('Annual rate (%)','number','rate', s.rate,{step:'0.0001',min:'0'}),
labelSelect('Compounding','compound', s.compound, [['1','Yearly'],['4','Quarterly'],['12','Monthly'],['365','Daily'],['0','Simple (no compounding)']]),
labelInput('Years','number','years', s.years,{step:'0.1',min:'0'}),
labelInput('Recurring contribution (per period below)','number','contrib', s.contrib,{step:'0.01',min:'0'}),
labelSelect('Contribution frequency','contribFreq', s.contribFreq, [['1','Yearly'],['4','Quarterly'],['12','Monthly']])
);
const out = document.createElement('div'); out.className='result'; ui.append(out);
function calc(){
const P = +ui.querySelector('[name=principal]').value||0;
const r = (+ui.querySelector('[name=rate]').value||0)/100;
const years = +ui.querySelector('[name=years]').value||0;
const n = +ui.querySelector('[name=compound]').value; // 0 => simple
const A = +ui.querySelector('[name=contrib]').value||0;
const f = +ui.querySelector('[name=contribFreq]').value||1;
let future=0, interest=0;
if(n===0){
interest = P * r * years;
const contribs = A * f * years;
future = P + interest + contribs;
}else{
const periods = n * years;
const i = r / n;
future = P * Math.pow(1+i, periods);
if(A>0){
const eff = Math.pow(1+i, n/f) - 1;
const m = Math.round(periods * f / n);
future += A * ((Math.pow(1+eff, m) - 1) / eff);
}
interest = future - P - (A>0?A*Math.round(n*years * f / n):0);
}
out.innerHTML = `
<div><strong>Future value:</strong> ${currency(future)}</div>
<div class="muted">Estimated interest earned: ${currency(Math.max(0,interest))}</div>
`;
persist(key,{principal:P, rate:r*100, years, compound:String(n), contrib:A, contribFreq:String(f)});
}
ui.addEventListener('input', calc); calc(); root.append(ui);
}
}

View file

@ -0,0 +1,22 @@
import {revive, persist, labelInput} from '/js/util.js';
export default {
id:'nmea', name:'NMEA 0183 Checksum', about:'Paste without the leading $ and without *XX.',
render(root){
const key='calc_nmea_v1';
const s = revive(key,{sentence:'GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,', expected:''});
const ui = document.createElement('div');
ui.append(labelInput('Sentence (no $ and no *XX)','text','sentence', s.sentence), labelInput('Expected checksum (optional, hex)','text','expected', s.expected,{placeholder:'e.g. 47'}));
const out = document.createElement('div'); out.className='result'; ui.append(out);
function checksum(str){ let x=0; for(const ch of str) x ^= ch.charCodeAt(0); return x; }
function calc(){
const sentence = ui.querySelector('[name=sentence]').value.trim();
const exp = ui.querySelector('[name=expected]').value.trim();
const sum = checksum(sentence);
const hex = sum.toString(16).toUpperCase().padStart(2,'0');
let html = `<div><strong>Computed:</strong> <code class="k">*${hex}</code></div>`;
if(exp){ const ok = hex === exp.toUpperCase(); html += `<div>${ok ? 'Match' : 'Mismatch'} (expected <code class="k">*${exp.toUpperCase()}</code>)</div>`; }
out.innerHTML = html; persist(key,{sentence, expected:exp});
}
ui.addEventListener('input', calc); calc(); root.append(ui);
}
}

View file

@ -0,0 +1,32 @@
import {fmt, revive, persist, labelInput, labelSelect} from '/js/util.js';
export default {
id:'raid', name:'RAID Usable Capacity', about:'Estimates only; assumes identical drives. Controller/FS overhead not included.',
render(root){
const key='calc_raid_v1';
const s = revive(key,{level:'5', drives:6, size:18});
const ui = document.createElement('div');
ui.append(
labelSelect('RAID level','level', s.level, [['0','RAID 0 (stripe)'],['1','RAID 1 (mirror)'],['5','RAID 5 (1 disk parity)'],['6','RAID 6 (2 disk parity)'],['10','RAID 10 (mirror+stripe)']]),
labelInput('Number of drives','number','drives', s.drives,{min:'1',step:'1'}),
labelInput('Drive size (TB, decimal)','number','size', s.size,{min:'0',step:'0.01'})
);
const out = document.createElement('div'); out.className='result'; ui.append(out);
function calc(){
const L = ui.querySelector('[name=level]').value;
const N = Math.max(1, parseInt(ui.querySelector('[name=drives]').value||0,10));
const S = (+ui.querySelector('[name=size]').value||0) * 1e12;
let usable=0, ft='';
if(L==='0'){ usable = N*S; ft='0 disk'; }
else if(L==='1'){ usable = Math.floor(N/2)*S; ft='can lose all but 1 in each mirror pair'; }
else if(L==='5'){ usable = (N-1)*S; ft='1 disk'; }
else if(L==='6'){ usable = (N-2)*S; ft='2 disks'; }
else if(L==='10'){ usable = Math.floor(N/2)*S; ft='1 disk per mirror pair'; }
const teb = usable / (1024**4);
const tb = usable / 1e12;
out.innerHTML = `<div><strong>Usable:</strong> ${fmt.format(tb)} TB · ${fmt.format(teb)} TiB</div><div class="muted">Fault tolerance: ${ft}</div>`;
persist(key,{level:L, drives:N, size:+ui.querySelector('[name=size]').value});
}
ui.addEventListener('input', calc); calc(); root.append(ui);
}
}

207
public/css/styles.css Normal file
View file

@ -0,0 +1,207 @@
/* ---- Color System ---- */
:root{
/* light defaults */
--bg:#f6f7fb; --card:#ffffff; --text:#0f1220; --muted:#5b6473;
--border:#dfe3ee; --accent:#2563eb; --accent2:#7c3aed;
--k-bg:#f1f5ff; --k-border:#cdd9ff;
--br:16px; --gap:14px; --shadow:0 6px 20px rgba(0,0,0,.08); --max:1100px;
}
@media (prefers-color-scheme: dark){
:root{
--bg:#0f1220; --card:#151933; --text:#e7ebf3; --muted:#9aa3b2;
--border:#242a44; --accent:#7dd3fc; --accent2:#a78bfa;
--k-bg:rgba(125,211,252,.06); --k-border:#2a3357;
--shadow:0 8px 30px rgba(0,0,0,.25);
}
}
/* explicit overrides win over system */
:root[data-theme="light"]{
--bg:#f6f7fb; --card:#ffffff; --text:#0f1220; --muted:#5b6473;
--border:#dfe3ee; --accent:#2563eb; --accent2:#7c3aed;
--k-bg:#f1f5ff; --k-border:#cdd9ff; --shadow:0 6px 20px rgba(0,0,0,.08);
}
:root[data-theme="dark"]{
--bg:#0f1220; --card:#151933; --text:#e7ebf3; --muted:#9aa3b2;
--border:#242a44; --accent:#7dd3fc; --accent2:#a78bfa;
--k-bg:rgba(125,211,252,.06); --k-border:#2a3357;
--shadow:0 8px 30px rgba(0,0,0,.25);
}
:root { color-scheme: light; }
@media (prefers-color-scheme: dark) { :root { color-scheme: dark; } }
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
input, select, textarea { color-scheme: inherit; }
:root{
--select-arrow: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M1 1l5 5 5-5'/%3E%3C/svg%3E");
}
select{
appearance: none;
-webkit-appearance: none;
background-color: var(--card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 2.25rem 10px 10px; /* room for arrow */
background-image: var(--select-arrow);
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 12px auto;
}
select:focus{
outline: none;
border-color: var(--accent2);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent2) 25%, transparent);
}
select:disabled{ opacity:.55; cursor:not-allowed; }
/* ---- Base ---- */
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--text);font:16px/1.5 system-ui,Segoe UI,Roboto,Ubuntu,Cantarell,sans-serif}
.wrap{max-width:var(--max);margin:0 auto;padding:16px}
.bar{position:sticky;top:0;background:linear-gradient(180deg,rgba(0,0,0,.06),rgba(0,0,0,0));backdrop-filter:blur(8px);border-bottom:1px solid var(--border);z-index:10}
.bar__inner{display:flex;align-items:center;gap:12px;justify-content:space-between}
.brand{font-weight:700}
.btn{background:transparent;border:1px solid var(--border);color:var(--text);padding:8px 10px;border-radius:999px;cursor:pointer}
/* ---- Layout ---- */
.layout{display:grid;grid-template-columns:240px 1fr;gap:16px}
@media (max-width: 820px){
.layout{grid-template-columns:1fr}
}
/* ---- Vertical nav ---- */
.sidenav{border:1px solid var(--border);border-radius:var(--br);padding:10px;background:var(--card);box-shadow:var(--shadow);position:sticky;top:70px;height:fit-content}
.sidenav .navlist{display:flex;flex-direction:column;gap:6px;margin:0;padding:0;list-style:none}
.sidenav .navlist button{width:100%;text-align:left;background:transparent;border:1px solid var(--border);color:var(--text);padding:10px;border-radius:10px;cursor:pointer}
.sidenav .navlist button[aria-current="page"]{border-color:var(--accent2);box-shadow:0 0 0 2px color-mix(in srgb, var(--accent2) 25%, transparent) inset}
.sidenav .navlist a{
display:block; text-decoration:none;
background:transparent; border:1px solid var(--border);
color:var(--text); padding:10px; border-radius:10px;
}
.sidenav .navlist a[aria-current="page"]{
border-color:var(--accent2);
box-shadow:0 0 0 2px color-mix(in srgb, var(--accent2) 25%, transparent) inset;
}
/* Select Lite (themed, accessible, tiny) */
.select-lite{ position:relative; }
.select-lite > select{
position:absolute; inset:auto auto auto auto; /* keep in DOM but invisible */
opacity:0; pointer-events:none; width:0; height:0;
}
.select-lite__button{
width:100%;
text-align:left;
background:var(--card);
color:var(--text);
border:1px solid var(--border);
border-radius:10px;
padding:10px 2.25rem 10px 10px;
cursor:pointer;
background-image: var(--select-arrow, url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M1 1l5 5 5-5'/%3E%3C/svg%3E"));
background-repeat:no-repeat; background-position:right 10px center; background-size:12px auto;
}
.select-lite__button:focus{
outline:none;
border-color:var(--accent2);
box-shadow:0 0 0 2px color-mix(in srgb, var(--accent2) 25%, transparent);
}
.select-lite__menu{
position:absolute; z-index:1000;
top:calc(100% + 6px); left:0; right:0;
background:var(--card);
color:var(--text);
border:1px solid var(--border);
border-radius:10px;
box-shadow:var(--shadow);
max-height:260px; overflow:auto; padding:6px;
}
.select-lite__option{
padding:8px 10px; border-radius:8px; outline:none;
}
.select-lite__option[aria-selected="true"],
.select-lite__option:hover,
.select-lite__option:focus{ background:var(--accent); color:white; }
.select-lite__option.search-match {
background: var(--accent) !important;
color: white !important;
font-weight: bold !important;
}
/* ---- Main content ---- */
.content{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:var(--gap)}
.card{background:var(--card);padding:14px;border-radius:var(--br);box-shadow:var(--shadow);border:1px solid var(--border)}
.card h2{margin:0 0 6px 0;font-size:18px}
.muted{color:var(--muted);font-size:14px}
label{display:block;margin-top:10px;margin-bottom:4px;color:var(--muted);font-size:14px}
input,select,textarea{width:100%;background:transparent;color:var(--text);border:1px solid var(--border);border-radius:10px;padding:10px}
.result{margin-top:12px;padding:10px;border:1px dashed var(--k-border);border-radius:10px;background:var(--k-bg)}
.k{padding:2px 6px;border-radius:6px;border:1px solid var(--k-border);background:var(--k-bg)}
.foot{color:var(--muted);font-size:13px;margin-top:20px}
/* ---- Status indicators ---- */
.status {
padding: 8px 12px;
border-radius: 8px;
font-size: 14px;
margin-top: 8px;
text-align: center;
}
.status.loading {
background-color: var(--k-bg);
color: var(--accent);
border: 1px solid var(--k-border);
}
.status.success {
background-color: #f0fdf4;
color: #166534;
border: 1px solid #bbf7d0;
}
.status.error {
background-color: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
/* Dark theme status adjustments */
@media (prefers-color-scheme: dark) {
.status.success {
background-color: #064e3b;
color: #6ee7b7;
border: 1px solid #065f46;
}
.status.error {
background-color: #450a0a;
color: #fca5a5;
border: 1px solid #7f1d1d;
}
}
:root[data-theme="dark"] .status.success {
background-color: #064e3b;
color: #6ee7b7;
border: 1px solid #065f46;
}
:root[data-theme="dark"] .status.error {
background-color: #450a0a;
color: #fca5a5;
border: 1px solid #7f1d1d;
}

30
public/index.html Normal file
View file

@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>calculator.127local.net</title>
<meta name="description" content="Framework-free calculators. All compute happens locally." />
<link rel="stylesheet" href="/css/styles.css">
<!-- Preload just the tiny core -->
<link rel="modulepreload" href="/js/app.js">
<base href="/">
</head>
<body>
<header class="bar">
<div class="wrap bar__inner">
<div class="brand">calculator.127local.net</div>
<button id="themeToggle" class="btn" aria-label="Toggle color scheme">Auto</button>
</div>
</header>
<div class="wrap layout">
<aside id="nav" class="sidenav"></aside>
<main id="view" class="content"></main>
</div>
<footer class="wrap foot">No tracking. No server. Everything runs in your browser.</footer>
<script type="module" src="/js/app.js"></script>
</body>
</html>

163
public/js/app.js Normal file
View file

@ -0,0 +1,163 @@
import {el, initTheme, enhanceSelects} from './util.js';
const CALCS = [
{ id:'interest', name:'Interest (Simple & Compound)', about:'Simple/compound interest', path:'../calculators/interest.js' },
{ id:'raid', name:'RAID', about:'Usable capacity', path:'../calculators/raid.js' },
{ id:'bandwidth', name:'Bandwidth', about:'Bits↔bytes unit conv.', path:'../calculators/bandwidth.js' },
{ id:'nmea', name:'NMEA', about:'0183 XOR checksum', path:'../calculators/nmea.js' },
{ id:'currency', name:'Currency Converter', about:'Convert between currencies', path:'../calculators/currency.js' }
];
const navEl = document.getElementById('nav');
const viewEl = document.getElementById('view');
const themeBtn= document.getElementById('themeToggle');
initTheme(themeBtn);
const moduleCache = new Map();
const viewCache = new Map();
function metaById(id){ return CALCS.find(c=>c.id===id) || CALCS[0] }
/* ---------- History API routing (path + query) ---------- */
function normalize(id, params){
const qs = params && [...params].length ? `?${params.toString()}` : '';
return `/${id}${qs}`;
}
function parseRoute(){
const path = location.pathname.replace(/^\/+/, '');
const id = path || 'interest';
const params = new URLSearchParams(location.search);
return { id, params };
}
function setRoute(id, params, {replace=true}={}){
const target = normalize(id, params || new URLSearchParams());
const current = `${location.pathname}${location.search}`;
if (target === current) return; // no-op if unchanged
if (replace) history.replaceState(null, '', target);
else history.pushState(null, '', target);
mount(); // re-render
}
function formToParams(card){
const p = new URLSearchParams();
card.querySelectorAll('input[name], select[name], textarea[name]').forEach(el=>{
let v = el.type === 'checkbox' ? (el.checked ? '1' : '0') : el.value;
if (v !== '') p.set(el.name, v);
});
return p;
}
function paramsToForm(card, params){
let touched = false;
card.querySelectorAll('input[name], select[name], textarea[name]').forEach(el=>{
const k = el.name;
if (!params.has(k)) return;
const v = params.get(k);
if (el.type === 'checkbox'){
const val = (v==='1' || v==='true');
if(el.checked !== val){ el.checked = val; touched = true; }
}else{
if(el.value !== v){ el.value = v; touched = true; }
}
});
if (touched) card.dispatchEvent(new Event('input', {bubbles:true}));
}
/* ---------- Nav: anchors + minimal history ---------- */
function renderNav(active){
navEl.innerHTML = '';
const ul = el('ul',{class:'navlist'});
for(const c of CALCS){
const a = el('a', {
href: `/${c.id}`,
'data-calc': c.id,
'aria-current': c.id===active ? 'page' : null
}, c.name);
ul.append(el('li',{}, a));
}
navEl.append(ul);
}
// delegate clicks so we can decide push vs replace
navEl.addEventListener('click', (e)=>{
const a = e.target.closest('a[data-calc]');
if(!a) return;
// allow new tab/window/defaults
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.altKey) return;
e.preventDefault();
const id = a.dataset.calc;
// shift-click = commit to history (push); otherwise replace (minimal history)
setRoute(id, null, {replace: !e.shiftKey});
});
function loadModule(spec){
if(!moduleCache.has(spec)) moduleCache.set(spec, import(spec));
return moduleCache.get(spec);
}
async function ensureMounted(id){
if(viewCache.has(id)) return viewCache.get(id);
const meta = metaById(id);
const card = el('section',{class:'card'});
viewCache.set(id, card);
// placeholder heading
card.append(el('h2',{}, meta.name), el('div',{class:'muted'}, meta.about||''));
try{
const mod = await loadModule(meta.path);
const calc = mod.default;
card.innerHTML = '';
card.append(el('h2',{}, calc.name || meta.name));
if(calc.about) card.append(el('div',{class:'muted'}, calc.about));
calc.render(card);
enhanceSelects(card);
}catch(e){
console.error(e);
card.innerHTML = `<div class="muted">Failed to load calculator. Check console.</div>`;
}
return card;
}
function attachUrlSync(card, id){
// Debounced replaceState on input changes (never pushes to history)
let t;
card.addEventListener('input', ()=>{
clearTimeout(t);
t = setTimeout(()=>{
const newParams = formToParams(card);
const {id:curId, params:curParams} = parseRoute();
const sameId = (curId === id);
const sameQs = sameId && newParams.toString() === curParams.toString();
if(!sameQs) setRoute(id, newParams, {replace:true});
}, 150);
});
}
async function show(id, params){
renderNav(id);
for(const [cid, card] of viewCache.entries()){
card.style.display = (cid===id) ? '' : 'none';
}
if(!viewCache.has(id)){
const card = await ensureMounted(id);
viewEl.append(card);
attachUrlSync(card, id);
}
paramsToForm(viewCache.get(id), params);
}
async function mount(){
const {id, params} = parseRoute();
// normalize root to /interest once
if(!location.pathname || location.pathname === '/' ){
setRoute('interest', params, {replace:true});
return;
}
await show(id, params);
// idle prefetch
const idle = window.requestIdleCallback || ((fn)=>setTimeout(fn,300));
idle(()=> CALCS.forEach(c=> loadModule(c.path).catch(()=>{})));
}
addEventListener('popstate', mount); // back/forward
mount();

206
public/js/util.js Normal file
View file

@ -0,0 +1,206 @@
// public/js/util.js
export const fmt = new Intl.NumberFormat(undefined,{maximumFractionDigits:6});
export const currency = (v,cur='USD')=> new Intl.NumberFormat(undefined,{style:'currency',currency:cur,maximumFractionDigits:2}).format(v);
export const persist = (key, obj) => localStorage.setItem(key, JSON.stringify(obj));
export const revive = (key, fallback={}) => { try { return JSON.parse(localStorage.getItem(key)) || fallback } catch { return fallback } };
export function el(tag, attrs={}, children=[]){
const x = document.createElement(tag);
for (const [k,v] of Object.entries(attrs||{})){
if (k === 'class') x.className = v ?? '';
else if (k === 'html') x.innerHTML = v ?? '';
else if (k === 'on' && v && typeof v === 'object'){
for (const [evt, fn] of Object.entries(v)){ if (typeof fn === 'function') x.addEventListener(evt, fn, false); }
} else {
if (v !== null && v !== undefined && v !== false){
x.setAttribute(k, v === true ? '' : String(v));
}
}
}
(Array.isArray(children) ? children : [children]).forEach(ch => { if (ch != null) x.append(ch) });
return x;
}
export function labelInput(labelText, type, name, value, attrs={}){
const w = el('div');
w.append(el('label',{html:labelText}));
const input = el('input',{type, name, value: String(value ?? ''), ...attrs});
w.append(input); return w;
}
export function labelSelect(labelText, name, value, options){
const w = el('div');
w.append(el('label',{html:labelText}));
const sel = el('select',{name, 'data-ui':'lite'}); // opt into Select Lite
options.forEach(([val, text])=>{
const opt = el('option',{value:val}); opt.textContent = text;
if (String(val) === String(value)) opt.selected = true;
sel.append(opt);
});
w.append(sel); return w;
}
/* ---- Theme helpers ---- */
export function applyTheme(mode){
if(mode === 'auto'){ document.documentElement.removeAttribute('data-theme'); }
else{ document.documentElement.setAttribute('data-theme', mode); }
localStorage.setItem('theme', mode);
}
export function initTheme(toggleBtn){
const saved = localStorage.getItem('theme') || 'auto';
applyTheme(saved);
if(toggleBtn){
toggleBtn.textContent = titleForTheme(saved);
toggleBtn.addEventListener('click', ()=>{
const next = nextTheme(localStorage.getItem('theme')||'auto');
applyTheme(next);
toggleBtn.textContent = titleForTheme(next);
});
}
}
function nextTheme(mode){ return mode==='auto' ? 'light' : mode==='light' ? 'dark' : 'auto' }
function titleForTheme(mode){ return mode==='auto' ? 'Auto' : mode==='light' ? 'Light' : 'Dark' }
/* ---- Select Lite: progressively enhance <select> with a themed list ---- */
export function enhanceSelects(scope=document){
scope.querySelectorAll('select[data-ui="lite"]').forEach(sel=>{
if (sel.dataset.enhanced) return;
sel.dataset.enhanced = '1';
// wrapper
const wrap = el('div', {class:'select-lite'});
sel.parentNode.insertBefore(wrap, sel);
wrap.append(sel);
// button showing current value
const btn = el('button', {type:'button', class:'select-lite__button', 'aria-haspopup':'listbox', 'aria-expanded':'false'});
const label = ()=> sel.options[sel.selectedIndex]?.text || '';
btn.textContent = label();
wrap.append(btn);
// menu
const menu = el('div', {class:'select-lite__menu', role:'listbox', tabindex:'-1', hidden:true});
const mkOption = (opt, idx)=>{
const item = el('div', {class:'select-lite__option', role:'option', 'data-value':opt.value, 'data-idx':idx, 'aria-selected': String(opt.selected)});
item.textContent = opt.text;
return item;
};
[...sel.options].forEach((opt,i)=> menu.append(mkOption(opt,i)));
wrap.append(menu);
// open/close helpers
const open = ()=>{
menu.hidden = false; wrap.classList.add('is-open');
btn.setAttribute('aria-expanded','true');
// focus selected and ensure menu is focusable
const current = menu.querySelector(`[data-idx="${sel.selectedIndex}"]`) || menu.firstElementChild;
current?.focus();
// Ensure menu can receive focus for search
menu.focus();
// outside click to close
setTimeout(()=>{ document.addEventListener('pointerdown', onDocDown, {capture:true, once:true}); }, 0);
};
const close = ()=>{
menu.hidden = true; wrap.classList.remove('is-open');
btn.setAttribute('aria-expanded','false');
btn.focus();
};
const onDocDown = (e)=>{ if(!wrap.contains(e.target)) close(); };
// button events
btn.addEventListener('click', ()=> menu.hidden ? open() : close());
btn.addEventListener('keydown', (e)=>{
if(e.key==='ArrowDown' || e.key==='Enter' || e.key===' '){ e.preventDefault(); open(); }
});
// Add search functionality
let searchBuffer = '';
let searchTimeout = null;
const handleSearch = (key) => {
if (key.length === 1 && key.match(/[a-zA-Z0-9]/)) {
searchBuffer += key.toLowerCase();
console.log('Search buffer:', searchBuffer); // Debug log
// Clear search buffer after 500ms of no typing (faster response)
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchBuffer = '';
console.log('Search buffer cleared'); // Debug log
}, 500);
// Find and highlight matching option
const options = [...menu.querySelectorAll('.select-lite__option')];
const matchingOption = options.find(opt =>
opt.textContent.toLowerCase().startsWith(searchBuffer)
);
if (matchingOption) {
console.log('Found match:', matchingOption.textContent); // Debug log
// Remove previous highlights and search matches
options.forEach(o => {
o.classList.remove('search-match');
o.style.background = '';
o.style.color = '';
o.style.fontWeight = '';
});
// Highlight matching option with strong visual feedback
matchingOption.classList.add('search-match');
matchingOption.style.background = 'var(--accent)';
matchingOption.style.color = 'white';
matchingOption.style.fontWeight = 'bold';
// Focus and scroll to the matching option
matchingOption.focus();
matchingOption.scrollIntoView({ block: 'nearest' });
} else {
console.log('No match found for:', searchBuffer); // Debug log
}
}
};
// menu interactions
menu.addEventListener('click', (e)=>{
const opt = e.target.closest('.select-lite__option'); if(!opt) return;
commit(opt.dataset.value);
});
menu.addEventListener('keydown', (e)=>{
console.log('Menu keydown:', e.key); // Debug log
// Handle search typing
if (e.key.length === 1 && e.key.match(/[a-zA-Z0-9]/)) {
e.preventDefault();
handleSearch(e.key);
return;
}
const opts = [...menu.querySelectorAll('.select-lite__option')];
const i = opts.indexOf(document.activeElement);
if(e.key==='ArrowDown'){ e.preventDefault(); (opts[i+1]||opts[0]).focus(); }
else if(e.key==='ArrowUp'){ e.preventDefault(); (opts[i-1]||opts[opts.length-1]).focus(); }
else if(e.key==='Home'){ e.preventDefault(); opts[0].focus(); }
else if(e.key==='End'){ e.preventDefault(); opts[opts.length-1].focus(); }
else if(e.key==='Enter' || e.key===' '){
e.preventDefault();
const t = document.activeElement;
if(t?.classList.contains('select-lite__option')) {
commit(t.dataset.value);
}
}
else if(e.key==='Escape'){ e.preventDefault(); close(); }
});
function commit(value){
if (sel.value !== value){
sel.value = value;
btn.textContent = label();
// update aria-selected
menu.querySelectorAll('.select-lite__option').forEach(o=> o.setAttribute('aria-selected', String(o.dataset.value===value)));
// bubble change to app
sel.dispatchEvent(new Event('input', {bubbles:true}));
sel.dispatchEvent(new Event('change', {bubbles:true}));
}
close();
}
});
}