Implement stores and refills, improve testing

This commit is contained in:
Aerilyn Weber 2026-04-18 12:36:29 +09:00
parent 9f416903ef
commit 5536acd67d
137 changed files with 21218 additions and 221 deletions

144
docs/design/MeshiTrack.html Normal file
View file

@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>MeshiTrack</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600&family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="styles/tokens.css"/>
<link rel="stylesheet" href="styles/shell.css"/>
<link rel="stylesheet" href="styles/cabinet.css"/>
<link rel="stylesheet" href="styles/dashboard.css"/>
<link rel="stylesheet" href="styles/schedule.css?v=2"/>
<link rel="stylesheet" href="styles/other.css?v=2"/>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="src/data.jsx"></script>
<script type="text/babel" src="src/Icon.jsx"></script>
<script type="text/babel" src="src/Shell.jsx"></script>
<script type="text/babel" src="src/Cabinet.jsx"></script>
<script type="text/babel" src="src/Dashboard.jsx"></script>
<script type="text/babel" src="src/Schedule.jsx"></script>
<script type="text/babel" src="src/OtherPages.jsx"></script>
<script type="text/babel">
const { useState, useEffect } = React;
const TWEAKS = /*EDITMODE-BEGIN*/{
"theme": "light",
"accent": "sage"
}/*EDITMODE-END*/;
const ACCENTS = {
sage: { brand: '#2f6b4a', deep: '#1e4a32', soft: '#e6efe8' },
cobalt: { brand: '#2e5aa8', deep: '#1d3d75', soft: '#e4eaf5' },
terracotta: { brand: '#b55438', deep: '#7d3825', soft: '#f6e6de' },
graphite: { brand: '#2c2c28', deep: '#000', soft: '#e8e6df' },
};
const App = () => {
const [route, setRoute] = useState('dashboard');
const [theme, setTheme] = useState(TWEAKS.theme || 'light');
const [accent, setAccent] = useState(TWEAKS.accent || 'sage');
const [tweaksOpen, setTweaksOpen] = useState(false);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
const a = ACCENTS[accent] || ACCENTS.sage;
document.documentElement.style.setProperty('--brand', a.brand);
document.documentElement.style.setProperty('--brand-deep', a.deep);
document.documentElement.style.setProperty('--brand-soft', a.soft);
document.documentElement.style.setProperty('--brand-soft-ink', a.deep);
document.documentElement.style.setProperty('--viz-1', a.brand);
}, [theme, accent]);
useEffect(() => {
const onMsg = (e) => {
if (!e.data || typeof e.data !== 'object') return;
if (e.data.type === '__activate_edit_mode') setTweaksOpen(true);
if (e.data.type === '__deactivate_edit_mode') setTweaksOpen(false);
};
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
const setTweak = (key, val) => {
window.parent.postMessage({ type: '__edit_mode_set_keys', edits: { [key]: val } }, '*');
};
const { Sidebar, Topbar, NAV } = window.Shell;
const current = NAV.find(n => n.id === route);
const pages = {
dashboard: { title: 'Dashboard', subtitle: 'Household: Red Panda Den', Page: window.DashboardPage },
cabinet: { title: 'Medicine Cabinet', subtitle: 'Everything on hand, with days of supply', Page: window.CabinetPage },
schedule: { title: 'Schedule & Log', subtitle: 'Today and this week', Page: window.SchedulePage },
regimens: { title: 'Regimens', subtitle: 'Daily medication schedules', crumbs: ['Medicines', 'Regimens'], Page: window.RegimensPage },
organizer: { title: 'Pill Organizer', subtitle: 'Fill a week of pills at once', crumbs: ['Medicines', 'Organizer'], Page: window.OrganizerPage },
activity: { title: 'Activity', subtitle: 'Spending and cabinet changes', crumbs: ['Medicines', 'Activity'], Page: window.ActivityPage },
library: { title: 'Library', subtitle: 'All known medicines', crumbs: ['Medicines', 'Library'], Page: window.LibraryPage },
refills: { title: 'Refills', subtitle: 'Running-low alerts and shopping lists', Page: window.RefillsPage },
purchases: { title: 'Purchases', subtitle: 'Order history and pending arrivals', Page: window.PurchasesPage },
prices: { title: 'Prices', subtitle: 'Track & compare across stores', Page: window.PricesPage },
stores: { title: 'Stores', subtitle: 'Pharmacies and vendors', Page: window.StoresPage },
settings: { title: 'Settings', subtitle: 'Household and account', Page: window.SettingsPage },
};
const pg = pages[route];
const Page = pg.Page;
return (
<div className="mt-app">
<Sidebar route={route} onNav={setRoute} />
<div className="mt-main" data-screen-label={'MT / ' + pg.title}>
<Topbar
title={pg.title}
subtitle={pg.subtitle}
crumbs={pg.crumbs}
theme={theme}
onToggleTheme={() => { const t = theme === 'light' ? 'dark' : 'light'; setTheme(t); setTweak('theme', t); }}
actions={route === 'cabinet' ? <button className="mt-btn mt-btn--primary"><span></span> Add to cabinet</button> : null}
/>
<Page onNav={setRoute} />
</div>
<div className={'mt-tweaks' + (tweaksOpen ? ' is-open' : '')}>
<div className="mt-tweaks__title">
<span>Tweaks</span>
<button className="mt-btn mt-btn--subtle" onClick={() => setTweaksOpen(false)} style={{padding:'2px 6px'}}>×</button>
</div>
<div className="mt-tweaks__row">
<span>Theme</span>
<div className="mt-seg">
<button className={theme==='light'?'is-active':''} onClick={()=>{setTheme('light'); setTweak('theme','light');}}>Light</button>
<button className={theme==='dark'?'is-active':''} onClick={()=>{setTheme('dark'); setTweak('theme','dark');}}>Dark</button>
</div>
</div>
<div className="mt-tweaks__row">
<span>Accent</span>
<div className="mt-seg">
{Object.keys(ACCENTS).map(k => (
<button key={k} className={accent===k?'is-active':''} onClick={()=>{setAccent(k); setTweak('accent',k);}} style={{textTransform:'capitalize'}}>{k}</button>
))}
</div>
</div>
<div style={{fontSize: 10, color: 'var(--ink-faint)', marginTop: 8, lineHeight: 1.5}}>
See alt-directions canvas for 3 novel cabinet layouts.
</div>
</div>
</div>
);
};
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

355
docs/design/src/Cabinet.jsx Normal file
View file

@ -0,0 +1,355 @@
// Medicine Cabinet the hero screen.
// Layout: summary strip + filter rail + "cabinet grid" of med cards with
// days-of-supply bar, expiry, lot info, and inline quick actions.
const { CABINET, MED, MEDICINES, PRICES, STORES } = window.MESHI;
const cabinetStatus = (row) => {
const days = Math.floor(row.qty / (row.perDose * (1 / row.daysPerUnit)));
// days until expiry
const exp = new Date(row.expiry);
const daysToExp = Math.round((exp - new Date('2026-04-18')) / 86400000);
let level = 'ok';
if (row.qty <= row.lowAt) level = 'low';
if (daysToExp < 60) level = level === 'low' ? 'critical' : 'expiring';
if (row.qty <= Math.ceil(row.lowAt / 2)) level = 'critical';
return { days, daysToExp, level };
};
const fmtJPY = (n) => '¥' + Number(n).toLocaleString('en-US');
const CabinetSummary = () => {
const rows = CABINET.map(r => ({ ...r, ...cabinetStatus(r) }));
const total = rows.length;
const low = rows.filter(r => r.level === 'low' || r.level === 'critical').length;
const expiring = rows.filter(r => r.level === 'expiring' || r.level === 'critical').length;
const inventoryValue = rows.reduce((s, r) => {
const p = PRICES.find(p => p.medId === r.medId);
return s + (p ? (p.price / p.pkg) * r.qty : 0);
}, 0);
const stats = [
{ label: 'Items', value: total, hint: 'distinct medicines', tone: 'ink' },
{ label: 'Running low', value: low, hint: 'need a refill soon', tone: 'danger' },
{ label: 'Expiring <60d', value: expiring, hint: 'check dates', tone: 'warn' },
{ label: 'Cabinet value', value: fmtJPY(Math.round(inventoryValue)), hint: 'at last paid price', tone: 'brand' },
];
return (
<div className="cab-summary">
{stats.map(s => (
<div key={s.label} className={'cab-summary__item cab-summary__item--' + s.tone}>
<div className="cab-summary__label">{s.label}</div>
<div className="cab-summary__value num">{s.value}</div>
<div className="cab-summary__hint">{s.hint}</div>
</div>
))}
</div>
);
};
const DaysBar = ({ days, low = 14 }) => {
const cap = 60;
const pct = Math.min(100, (days / cap) * 100);
const tone = days <= low / 2 ? 'critical' : days <= low ? 'low' : 'ok';
return (
<div className={'daysbar daysbar--' + tone} title={days + ' days supply'}>
<div className="daysbar__track">
<div className="daysbar__fill" style={{ width: pct + '%' }} />
<div className="daysbar__mark" style={{ left: ((low / cap) * 100) + '%' }} />
</div>
<div className="daysbar__label num">
<span className="daysbar__num">{days}</span>
<span className="daysbar__unit">days</span>
</div>
</div>
);
};
const CabinetCard = ({ row, onOpen }) => {
const med = MED[row.medId];
const { days, daysToExp, level } = cabinetStatus(row);
const price = PRICES.find(p => p.medId === row.medId);
return (
<button className={'cab-card cab-card--lvl-' + level} onClick={onOpen}>
<div className="cab-card__top">
<div className="cab-card__swatch" style={{ '--c': 'var(--viz-' + med.color + ')' }}>
<Icon name={med.form === 'Injection' ? 'injection' : med.form === 'Capsule' ? 'capsule' : 'pill'} size={16} />
</div>
<div className="cab-card__meta">
<div className="cab-card__name">{med.name}</div>
<div className="cab-card__strength">{med.strength} · {med.form}</div>
</div>
{level !== 'ok' && (
<span className={'cab-card__flag mt-pill mt-pill--' + (level === 'critical' ? 'danger' : level === 'low' ? 'danger' : 'warn')}>
{level === 'critical' ? 'Critical' : level === 'low' ? 'Low' : 'Expiring'}
</span>
)}
</div>
<div className="cab-card__qty">
<div className="cab-card__qtyNum num">{row.qty}</div>
<div className="cab-card__qtyUnit">{row.unit}</div>
</div>
<DaysBar days={days} low={row.lowAt} />
<div className="cab-card__foot">
<div className="cab-card__footItem">
<div className="cab-card__footLabel">Expiry</div>
<div className={'cab-card__footValue num' + (daysToExp < 60 ? ' is-warn' : '')}>
{row.expiry.slice(0, 7)}
</div>
</div>
<div className="cab-card__footItem">
<div className="cab-card__footLabel">Lots</div>
<div className="cab-card__footValue num">{row.lots}</div>
</div>
<div className="cab-card__footItem">
<div className="cab-card__footLabel">Unit cost</div>
<div className="cab-card__footValue num">
{price ? fmtJPY(Math.round(price.price / price.pkg)) : '—'}
</div>
</div>
</div>
</button>
);
};
const CabinetFilters = ({ filter, setFilter, view, setView, query, setQuery }) => {
const tabs = [
{ id: 'all', label: 'All', count: CABINET.length },
{ id: 'low', label: 'Running low', count: CABINET.filter(r => cabinetStatus(r).level !== 'ok').length },
{ id: 'rx', label: 'Prescription', count: CABINET.filter(r => MED[r.medId].category === 'Prescription').length },
{ id: 'supplement', label: 'Supplements', count: CABINET.filter(r => MED[r.medId].category === 'Supplement').length },
];
return (
<div className="cab-filters">
<div className="cab-filters__tabs">
{tabs.map(t => (
<button
key={t.id}
className={'cab-filters__tab' + (filter === t.id ? ' is-active' : '')}
onClick={() => setFilter(t.id)}
>
{t.label}
<span className="cab-filters__count num">{t.count}</span>
</button>
))}
</div>
<div className="cab-filters__tools">
<div className="cab-filters__search">
<Icon name="search" size={13} />
<input placeholder="Filter" value={query} onChange={e => setQuery(e.target.value)} />
</div>
<div className="mt-seg">
<button className={view === 'grid' ? 'is-active' : ''} onClick={() => setView('grid')}>Grid</button>
<button className={view === 'table' ? 'is-active' : ''} onClick={() => setView('table')}>Table</button>
<button className={view === 'shelf' ? 'is-active' : ''} onClick={() => setView('shelf')}>Shelf</button>
</div>
<button className="mt-btn mt-btn--ghost">
<Icon name="filter" size={14} /> Sort: Days left
</button>
</div>
</div>
);
};
const CabinetTable = ({ rows, onOpen }) => (
<div className="cab-table">
<div className="cab-table__head">
<div>Medicine</div>
<div className="num">Qty</div>
<div>Supply</div>
<div>Expiry</div>
<div className="num">Unit cost</div>
<div>Status</div>
</div>
{rows.map(r => {
const med = MED[r.medId];
const { days, daysToExp, level } = cabinetStatus(r);
const price = PRICES.find(p => p.medId === r.medId);
return (
<button key={r.medId} className="cab-table__row" onClick={() => onOpen(r)}>
<div className="cab-table__cell cab-table__cellMed">
<div className="cab-card__swatch cab-card__swatch--sm" style={{ '--c': 'var(--viz-' + med.color + ')' }}>
<Icon name={med.form === 'Injection' ? 'injection' : med.form === 'Capsule' ? 'capsule' : 'pill'} size={12} />
</div>
<div>
<div style={{ fontWeight: 500 }}>{med.name}</div>
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>{med.strength} · {med.form}</div>
</div>
</div>
<div className="num" style={{ fontWeight: 500 }}>{r.qty}<span style={{ color: 'var(--ink-faint)', marginLeft: 4, fontWeight: 400 }}>{r.unit}</span></div>
<div><DaysBar days={days} low={r.lowAt} /></div>
<div className={'num' + (daysToExp < 60 ? ' is-warn' : '')}>{r.expiry}</div>
<div className="num">{price ? fmtJPY(Math.round(price.price / price.pkg)) : '—'}</div>
<div>
{level === 'ok' ? <span className="mt-pill mt-pill--ghost">In stock</span> : level === 'low' ? <span className="mt-pill mt-pill--danger">Low</span> : level === 'critical' ? <span className="mt-pill mt-pill--danger">Critical</span> : <span className="mt-pill mt-pill--warn">Expiring</span>}
</div>
</button>
);
})}
</div>
);
const CabinetShelf = ({ rows, onOpen }) => {
// Group by category, then render as "shelves"
const groups = {};
rows.forEach(r => {
const cat = MED[r.medId].category;
(groups[cat] = groups[cat] || []).push(r);
});
return (
<div className="cab-shelf">
{Object.entries(groups).map(([cat, items]) => (
<div key={cat} className="cab-shelf__level">
<div className="cab-shelf__tag">{cat}</div>
<div className="cab-shelf__bottles">
{items.map(r => {
const med = MED[r.medId];
const { days, level } = cabinetStatus(r);
const fillPct = Math.min(100, (days / 60) * 100);
return (
<button key={r.medId} className={'cab-bottle cab-bottle--lvl-' + level} onClick={() => onOpen(r)}>
<div className="cab-bottle__body" style={{ '--c': 'var(--viz-' + med.color + ')' }}>
<div className="cab-bottle__fill" style={{ height: fillPct + '%' }} />
<div className="cab-bottle__cap" />
</div>
<div className="cab-bottle__label">
<div className="cab-bottle__name">{med.name}</div>
<div className="cab-bottle__qty num">{r.qty} {r.unit}</div>
</div>
</button>
);
})}
</div>
<div className="cab-shelf__line" />
</div>
))}
</div>
);
};
const CabinetDetail = ({ row, onClose }) => {
if (!row) return null;
const med = MED[row.medId];
const { days, daysToExp, level } = cabinetStatus(row);
const price = PRICES.find(p => p.medId === row.medId);
const allPrices = PRICES.filter(p => p.medId === row.medId);
const cheapest = allPrices.reduce((a, b) => (a && a.price / a.pkg < b.price / b.pkg ? a : b), null);
return (
<div className="cab-detail" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="cab-detail__panel">
<div className="cab-detail__head">
<div className="mt-row">
<div className="cab-card__swatch" style={{ '--c': 'var(--viz-' + med.color + ')', width: 44, height: 44 }}>
<Icon name={med.form === 'Injection' ? 'injection' : med.form === 'Capsule' ? 'capsule' : 'pill'} size={20} />
</div>
<div>
<div className="cab-detail__title">{med.name}</div>
<div className="cab-detail__sub">{med.strength} · {med.form} · {med.category}</div>
</div>
</div>
<button className="mt-iconbtn" onClick={onClose}><Icon name="x" size={14} /></button>
</div>
<div className="cab-detail__hero">
<div>
<div className="cab-detail__bigLabel">On hand</div>
<div className="cab-detail__big num">{row.qty} <span>{row.unit}</span></div>
</div>
<div>
<div className="cab-detail__bigLabel">Supply</div>
<div className="cab-detail__big num">{days} <span>days</span></div>
</div>
<div>
<div className="cab-detail__bigLabel">Expires</div>
<div className={'cab-detail__big num' + (daysToExp < 60 ? ' is-warn' : '')}>{row.expiry}</div>
</div>
</div>
<div className="cab-detail__actions">
<button className="mt-btn mt-btn--primary"><Icon name="check" size={14} /> Log intake</button>
<button className="mt-btn mt-btn--ghost"><Icon name="plus" size={14} /> Add to cabinet</button>
<button className="mt-btn mt-btn--ghost"><Icon name="truck" size={14} /> Reorder</button>
<button className="mt-btn mt-btn--subtle"><Icon name="edit" size={14} /> Adjust</button>
</div>
<div className="mt-divider" />
<div className="cab-detail__section">
<div className="cab-detail__sectionTitle">Prices across stores</div>
<div className="cab-detail__prices">
{allPrices.map(p => {
const s = STORES.find(s => s.id === p.store);
const isCheap = p === cheapest;
return (
<div key={p.store + p.date} className={'cab-detail__price' + (isCheap ? ' is-best' : '')}>
<div className="cab-detail__priceStore">
<div style={{ fontWeight: 500 }}>{s?.name}</div>
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>{p.date}</div>
</div>
<div className="cab-detail__priceBody">
<div className="num" style={{ fontSize: 15, fontWeight: 500 }}>{fmtJPY(p.price)}</div>
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }} className="num">{fmtJPY(Math.round(p.price / p.pkg))} / {p.unit}</div>
</div>
{isCheap && <span className="mt-pill mt-pill--ok">Best</span>}
</div>
);
})}
</div>
</div>
{med.notes && (
<div className="cab-detail__section">
<div className="cab-detail__sectionTitle">Notes</div>
<div style={{ fontSize: 13, color: 'var(--ink-muted)' }}>{med.notes}</div>
</div>
)}
</div>
</div>
);
};
const CabinetPage = () => {
const [filter, setFilter] = useState('all');
const [view, setView] = useState('grid');
const [query, setQuery] = useState('');
const [detail, setDetail] = useState(null);
const rows = useMemo(() => {
return CABINET
.filter(r => {
if (filter === 'low') return cabinetStatus(r).level !== 'ok';
if (filter === 'rx') return MED[r.medId].category === 'Prescription';
if (filter === 'supplement') return MED[r.medId].category === 'Supplement';
return true;
})
.filter(r => !query || MED[r.medId].name.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => cabinetStatus(a).days - cabinetStatus(b).days);
}, [filter, query]);
return (
<div className="mt-page">
<CabinetSummary />
<div style={{ height: 20 }} />
<CabinetFilters filter={filter} setFilter={setFilter} view={view} setView={setView} query={query} setQuery={setQuery} />
<div style={{ height: 16 }} />
{view === 'grid' && (
<div className="cab-grid">
{rows.map(r => <CabinetCard key={r.medId} row={r} onOpen={() => setDetail(r)} />)}
</div>
)}
{view === 'table' && <CabinetTable rows={rows} onOpen={setDetail} />}
{view === 'shelf' && <CabinetShelf rows={rows} onOpen={setDetail} />}
<CabinetDetail row={detail} onClose={() => setDetail(null)} />
</div>
);
};
window.CabinetPage = CabinetPage;
window.cabinetStatus = cabinetStatus;
window.fmtJPY = fmtJPY;

View file

@ -0,0 +1,251 @@
// Dashboard today at a glance. Integrated view of meds + intake + refills + spending.
const { TODAY_INTAKE, MED, CABINET, REGIMENS, SPENDING, PURCHASES, ACTIVITY, PRICES, STORES } = window.MESHI;
const DashboardPage = ({ onNav }) => {
const [intake, setIntake] = useState(TODAY_INTAKE);
const taken = intake.filter(i => i.status === 'taken').length;
const total = intake.length;
const pct = Math.round((taken / total) * 100);
const lowMeds = CABINET.map(r => ({ r, s: cabinetStatus(r) })).filter(x => x.s.level !== 'ok').sort((a, b) => a.s.days - b.s.days);
const thisMonth = SPENDING[SPENDING.length - 1].amount;
const lastMonth = SPENDING[SPENDING.length - 2].amount;
const spendDelta = ((thisMonth - lastMonth) / lastMonth) * 100;
const toggleTake = (id) => {
setIntake(intake.map(i => i.id === id ? { ...i, status: i.status === 'taken' ? 'upcoming' : 'taken', takenAt: '21:04' } : i));
};
// Days supply across meds for mini sparkline
const sparkMax = 60;
const supplyData = CABINET.map(r => ({ med: MED[r.medId], days: cabinetStatus(r).days }))
.sort((a, b) => a.days - b.days);
return (
<div className="mt-page">
<div className="dash-hero">
<div className="dash-hero__greet">
<div className="dash-hero__day">Saturday · April 18</div>
<div className="dash-hero__title">Good evening, aeri.</div>
<div className="dash-hero__sub">Evening pills are due in <span className="mt-mono" style={{color:'var(--ink-strong)'}}>2h 34m</span>. Everything else is on track.</div>
</div>
<div className="dash-hero__ring">
<svg viewBox="0 0 120 120" width="120" height="120">
<circle cx="60" cy="60" r="52" fill="none" stroke="var(--bg-inset)" strokeWidth="10" />
<circle cx="60" cy="60" r="52" fill="none" stroke="var(--brand)" strokeWidth="10"
strokeDasharray={2 * Math.PI * 52}
strokeDashoffset={2 * Math.PI * 52 * (1 - pct / 100)}
strokeLinecap="round"
transform="rotate(-90 60 60)"
/>
<text x="60" y="56" textAnchor="middle" fill="var(--ink-strong)" style={{fontFamily:'var(--font-display)', fontSize:24, fontWeight:500}}>{taken}<tspan fill="var(--ink-muted)" fontSize="14">/{total}</tspan></text>
<text x="60" y="74" textAnchor="middle" fill="var(--ink-muted)" fontSize="10" letterSpacing="1">DOSES TAKEN</text>
</svg>
</div>
</div>
<div className="dash-grid">
<section className="mt-card dash-panel" style={{ gridColumn: 'span 8' }}>
<div className="mt-card__head">
<div>
<div className="mt-card__title">Today's schedule</div>
<div className="mt-card__sub">{total - taken} upcoming · {taken} completed</div>
</div>
<button className="mt-btn mt-btn--ghost" onClick={() => onNav('schedule')}>Open log <Icon name="arrow" size={12}/></button>
</div>
<div className="dash-schedule">
{['08:00', '21:00'].map(time => {
const items = intake.filter(i => i.scheduled === time);
if (!items.length) return null;
const allTaken = items.every(i => i.status === 'taken');
return (
<div key={time} className={'dash-sched__slot' + (allTaken ? ' is-done' : '')}>
<div className="dash-sched__time">
<div className="mono" style={{fontSize:18, fontWeight:500, color:'var(--ink-strong)'}}>{time}</div>
<div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.06em'}}>{time === '08:00' ? 'Morning' : 'Evening'}</div>
</div>
<div className="dash-sched__items">
{items.map(i => {
const m = MED[i.medId];
return (
<button key={i.id} className={'dash-sched__pill' + (i.status === 'taken' ? ' is-taken' : '')} onClick={() => toggleTake(i.id)}>
<span className={'dash-sched__check' + (i.status === 'taken' ? ' is-taken' : '')}>
{i.status === 'taken' && <Icon name="check" size={10} />}
</span>
<span style={{ color: 'var(--viz-' + m.color + ')', width: 6, height: 6, borderRadius: 3, background: 'currentColor', display: 'inline-block' }} />
<span className="dash-sched__name">{m.name}</span>
<span className="dash-sched__dose">{i.qty} {m.form.toLowerCase()}</span>
</button>
);
})}
</div>
</div>
);
})}
</div>
</section>
<section className="mt-card dash-panel" style={{ gridColumn: 'span 4' }}>
<div className="mt-card__head">
<div>
<div className="mt-card__title">Running low</div>
<div className="mt-card__sub">{lowMeds.length} need attention</div>
</div>
<button className="mt-btn mt-btn--ghost" onClick={() => onNav('refills')}>Refills</button>
</div>
<div className="dash-low">
{lowMeds.slice(0, 4).map(({ r, s }) => {
const m = MED[r.medId];
return (
<div key={r.medId} className="dash-low__item">
<div className="cab-card__swatch cab-card__swatch--sm" style={{ '--c': 'var(--viz-' + m.color + ')' }}>
<Icon name={m.form === 'Injection' ? 'injection' : m.form === 'Capsule' ? 'capsule' : 'pill'} size={12} />
</div>
<div style={{flex:1, minWidth:0}}>
<div style={{fontSize:13, fontWeight:500, color:'var(--ink-strong)'}}>{m.name}</div>
<div style={{fontSize:11, color:'var(--ink-muted)'}}>{r.qty} {r.unit}</div>
</div>
<div style={{textAlign:'right'}}>
<div className="num" style={{fontSize:14, fontWeight:600, color: s.level === 'critical' ? 'var(--danger)' : 'var(--warn)'}}>{s.days}d</div>
<div style={{fontSize:10, color:'var(--ink-faint)'}}>left</div>
</div>
</div>
);
})}
</div>
</section>
<section className="mt-card dash-panel" style={{ gridColumn: 'span 5' }}>
<div className="mt-card__head">
<div>
<div className="mt-card__title">Spending · 6 months</div>
<div className="mt-card__sub">{fmtJPY(thisMonth)} this month <span style={{color: spendDelta < 0 ? 'var(--ok)' : 'var(--danger)', marginLeft:4}}>{spendDelta < 0 ? '↓' : '↑'} {Math.abs(Math.round(spendDelta))}%</span></div>
</div>
<button className="mt-btn mt-btn--ghost" onClick={() => onNav('activity')}>Details</button>
</div>
<div className="dash-spend">
<svg viewBox="0 0 360 140" width="100%" height="140" preserveAspectRatio="none">
{SPENDING.map((m, i) => {
const max = Math.max(...SPENDING.map(s => s.amount));
const h = (m.amount / max) * 100;
const x = 20 + i * 56;
const isLast = i === SPENDING.length - 1;
return (
<g key={m.month}>
<rect x={x} y={120 - h} width="36" height={h} rx="3"
fill={isLast ? 'var(--brand)' : 'var(--brand-soft)'}
stroke={isLast ? 'var(--brand-deep)' : 'none'}
/>
<text x={x + 18} y="135" textAnchor="middle" fontSize="9" fill="var(--ink-faint)">{m.month.slice(5)}</text>
<text x={x + 18} y={120 - h - 4} textAnchor="middle" fontSize="9" fill={isLast ? 'var(--ink)' : 'var(--ink-muted)'} fontWeight={isLast ? 600 : 400}>
{(m.amount / 1000).toFixed(0)}k
</text>
</g>
);
})}
</svg>
</div>
</section>
<section className="mt-card dash-panel" style={{ gridColumn: 'span 7' }}>
<div className="mt-card__head">
<div>
<div className="mt-card__title">Cabinet days of supply</div>
<div className="mt-card__sub">At current usage</div>
</div>
<button className="mt-btn mt-btn--ghost" onClick={() => onNav('cabinet')}>Open cabinet</button>
</div>
<div className="dash-supply">
{supplyData.map(s => {
const pct = Math.min(100, (s.days / sparkMax) * 100);
const tone = s.days <= 7 ? 'critical' : s.days <= 14 ? 'low' : 'ok';
return (
<div key={s.med.id} className="dash-supply__row">
<div className="dash-supply__name">{s.med.name}</div>
<div className="dash-supply__bar">
<div className={'dash-supply__fill dash-supply__fill--' + tone} style={{ width: pct + '%' }} />
</div>
<div className="dash-supply__days num">{s.days}<span>d</span></div>
</div>
);
})}
</div>
</section>
<section className="mt-card dash-panel" style={{ gridColumn: 'span 6' }}>
<div className="mt-card__head">
<div>
<div className="mt-card__title">Pending orders</div>
<div className="mt-card__sub">{PURCHASES.filter(p => p.status === 'pending').length} awaiting arrival</div>
</div>
<button className="mt-btn mt-btn--ghost" onClick={() => onNav('purchases')}>All purchases</button>
</div>
<div className="dash-orders">
{PURCHASES.filter(p => p.status === 'pending').map(p => {
const s = STORES.find(s => s.id === p.store);
const total = p.items.reduce((a, i) => a + i.price, 0);
return (
<div key={p.id} className="dash-order">
<div className="dash-order__icon"><Icon name="truck" size={14} /></div>
<div style={{flex:1}}>
<div style={{fontWeight:500, fontSize:13}}>{s?.name}</div>
<div style={{fontSize:11, color:'var(--ink-muted)'}}>{p.items.map(i => MED[i.medId].name).join(', ')}</div>
</div>
<div style={{textAlign:'right'}}>
<div className="num" style={{fontSize:13, fontWeight:500}}>{fmtJPY(total)}</div>
<div style={{fontSize:10, color:'var(--ink-faint)'}}>ordered {p.date}</div>
</div>
</div>
);
})}
{PURCHASES.filter(p => p.status === 'received').slice(0, 2).map(p => {
const s = STORES.find(s => s.id === p.store);
return (
<div key={p.id} className="dash-order is-past">
<div className="dash-order__icon"><Icon name="check" size={14} /></div>
<div style={{flex:1}}>
<div style={{fontSize:12, color:'var(--ink-muted)'}}>{s?.name} · {p.items.length} items received</div>
</div>
<div style={{fontSize:10, color:'var(--ink-faint)'}}>{p.date}</div>
</div>
);
})}
</div>
</section>
<section className="mt-card dash-panel" style={{ gridColumn: 'span 6' }}>
<div className="mt-card__head">
<div>
<div className="mt-card__title">Recent activity</div>
<div className="mt-card__sub">Cabinet changes</div>
</div>
<button className="mt-btn mt-btn--ghost" onClick={() => onNav('activity')}>See all</button>
</div>
<div className="dash-activity">
{ACTIVITY.slice(0, 5).map(a => {
const m = MED[a.medId];
return (
<div key={a.id} className="dash-act">
<span className={'mt-pill mt-pill--' + (a.type === 'consumed' ? 'info' : a.type === 'added' ? 'ok' : 'warn')} style={{minWidth:76, justifyContent:'center'}}>{a.type}</span>
<span style={{flex:1, fontSize:12}}>
<strong style={{fontWeight:500}}>{m.name}</strong>
<span className="num" style={{marginLeft:6, color: a.delta < 0 ? 'var(--ink-muted)' : 'var(--ok)'}}>
{a.delta > 0 ? '+' : ''}{a.delta}
</span>
<span className="num" style={{color:'var(--ink-faint)', marginLeft:6}}>{a.before}{a.after}</span>
</span>
<span className="mono" style={{fontSize:10, color:'var(--ink-faint)'}}>{a.at.slice(5)}</span>
</div>
);
})}
</div>
</section>
</div>
</div>
);
};
window.DashboardPage = DashboardPage;

44
docs/design/src/Icon.jsx Normal file
View file

@ -0,0 +1,44 @@
// Minimal inline-SVG icon set. Stroke-based, 1.6px weight, 20px box.
const Icon = ({ name, size = 18, className = '', style }) => {
const s = { width: size, height: size, display: 'inline-block', flexShrink: 0, ...style };
const common = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round', className };
const paths = {
dashboard: <><rect x="3" y="3" width="8" height="10" rx="1.5"/><rect x="13" y="3" width="8" height="6" rx="1.5"/><rect x="3" y="15" width="8" height="6" rx="1.5"/><rect x="13" y="11" width="8" height="10" rx="1.5"/></>,
cabinet: <><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M4 12h16"/><circle cx="10" cy="7.5" r="0.6" fill="currentColor"/><circle cx="10" cy="16.5" r="0.6" fill="currentColor"/></>,
pill: <><rect x="2" y="9" width="20" height="6" rx="3" transform="rotate(-30 12 12)"/><path d="M7.4 7.8l9 5.2" transform="rotate(-30 12 12)"/></>,
clock: <><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></>,
list: <><path d="M8 6h13M8 12h13M8 18h13"/><circle cx="3.5" cy="6" r="0.8" fill="currentColor"/><circle cx="3.5" cy="12" r="0.8" fill="currentColor"/><circle cx="3.5" cy="18" r="0.8" fill="currentColor"/></>,
calendar: <><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/></>,
store: <><path d="M3 9l1.5-5h15L21 9M3 9v11h18V9M3 9h18"/><path d="M9 20v-6h6v6"/></>,
tag: <><path d="M3 12l9-9h8v8l-9 9z"/><circle cx="15.5" cy="8.5" r="1.4"/></>,
truck: <><rect x="2" y="7" width="12" height="10" rx="1.5"/><path d="M14 10h5l3 4v3h-8"/><circle cx="7" cy="18.5" r="1.8"/><circle cx="17" cy="18.5" r="1.8"/></>,
refresh: <><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></>,
search: <><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></>,
plus: <><path d="M12 5v14M5 12h14"/></>,
check: <><path d="M5 12l5 5L20 6"/></>,
x: <><path d="M6 6l12 12M18 6L6 18"/></>,
chev: <><path d="M9 6l6 6-6 6"/></>,
chevDown: <><path d="M6 9l6 6 6-6"/></>,
alert: <><path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.5"/></>,
bell: <><path d="M6 8a6 6 0 1 1 12 0c0 6 2 7 2 7H4s2-1 2-7zM10 19a2 2 0 0 0 4 0"/></>,
filter: <><path d="M4 5h16l-6 8v6l-4-2v-4z"/></>,
settings: <><circle cx="12" cy="12" r="3"/><path d="M20 12a8 8 0 0 0-.2-1.8l2-1.6-2-3.5-2.4 1a8 8 0 0 0-3-1.8L14 2h-4l-.4 2.4a8 8 0 0 0-3 1.8l-2.4-1-2 3.5 2 1.6A8 8 0 0 0 4 12c0 .6.1 1.2.2 1.8l-2 1.6 2 3.5 2.4-1a8 8 0 0 0 3 1.8L10 22h4l.4-2.4a8 8 0 0 0 3-1.8l2.4 1 2-3.5-2-1.6c.1-.6.2-1.2.2-1.8z"/></>,
sun: <><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4 12H2M22 12h-2M5 5l1.5 1.5M17.5 17.5L19 19M5 19l1.5-1.5M17.5 6.5L19 5"/></>,
moon: <><path d="M20 14A8 8 0 0 1 10 4a8 8 0 1 0 10 10z"/></>,
home: <><path d="M3 11l9-8 9 8v10H3z"/><path d="M9 21v-7h6v7"/></>,
trash: <><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></>,
edit: <><path d="M4 20l4-1 11-11-3-3L5 16zM14 6l3 3"/></>,
arrow: <><path d="M5 12h14M13 6l6 6-6 6"/></>,
vial: <><path d="M8 3h8M9 3v14a3 3 0 0 0 6 0V3"/><path d="M9 11h6"/></>,
capsule: <><rect x="3" y="9" width="18" height="6" rx="3"/><path d="M12 9v6"/></>,
injection: <><path d="M17 3l4 4M15 5l4 4M8 12l7-7 3 3-7 7-3.5.5zM8 12l-5 5M4 16l3 3"/></>,
trend: <><path d="M3 17l6-6 4 4 8-8"/><path d="M14 7h7v7"/></>,
fridge: <><rect x="5" y="3" width="14" height="18" rx="2"/><path d="M5 10h14"/><path d="M8 6v2M8 13v4"/></>,
box: <><path d="M3 7l9-4 9 4v10l-9 4-9-4z"/><path d="M3 7l9 4 9-4M12 11v10"/></>,
yen: <><path d="M5 4l7 9 7-9M7 13h10M7 17h10M12 13v7"/></>,
zap: <><path d="M13 2L4 14h7l-1 8 9-12h-7z"/></>,
};
return <svg {...common} style={s}>{paths[name] || null}</svg>;
};
window.Icon = Icon;

View file

@ -0,0 +1,518 @@
// Medium-fi placeholder pages for the remaining medicine + purchasing sections.
const { MED: M2, MEDICINES: MEDS2, CABINET: C2, REGIMENS: REG2, STORES: ST2, PRICES: PR2, PURCHASES: PU2, ACTIVITY: AC2 } = window.MESHI;
// ---- Library ----
const LibraryPage = () => {
const [q, setQ] = useState('');
const rows = MEDS2.filter(m => m.name.toLowerCase().includes(q.toLowerCase()));
return (
<div className="mt-page">
<div className="sub-toolbar">
<div className="cab-filters__search" style={{flex:1, maxWidth: 320}}>
<Icon name="search" size={13}/>
<input placeholder="Search medicines…" value={q} onChange={e=>setQ(e.target.value)} style={{width:'100%'}}/>
</div>
<div style={{display:'flex', gap: 8}}>
<button className="mt-btn mt-btn--ghost">All categories <Icon name="chevDown" size={12}/></button>
<button className="mt-btn mt-btn--ghost">All forms <Icon name="chevDown" size={12}/></button>
<button className="mt-btn mt-btn--primary"><Icon name="plus" size={12}/> Add medicine</button>
</div>
</div>
<div className="mt-card" style={{overflow:'hidden'}}>
{rows.map((m, i) => (
<div key={m.id} className="lib-row">
<div className="cab-card__swatch cab-card__swatch--sm" style={{'--c':'var(--viz-'+m.color+')'}}>
<Icon name={m.form === 'Injection' ? 'injection' : m.form === 'Capsule' ? 'capsule' : 'pill'} size={12}/>
</div>
<div style={{flex:1}}>
<div style={{fontWeight:500, fontSize:13, color:'var(--ink-strong)'}}>{m.name}</div>
<div style={{fontSize:11, color:'var(--ink-muted)'}}>{m.strength} · {m.form}</div>
</div>
<span className={'mt-pill mt-pill--' + (m.category === 'Prescription' ? 'info' : 'ghost')}>{m.category}</span>
<button className="mt-btn mt-btn--subtle"><Icon name="edit" size={12}/></button>
<button className="mt-btn mt-btn--subtle"><Icon name="trash" size={12}/></button>
</div>
))}
</div>
</div>
);
};
// ---- Regimens ----
const RegimensPage = () => (
<div className="mt-page">
<div className="sub-toolbar">
<div style={{display:'flex', gap:8}}>
<button className="mt-btn mt-btn--ghost">All regimens <Icon name="chevDown" size={12}/></button>
<button className="mt-btn mt-btn--ghost"><Icon name="trend" size={12}/> Burn rate</button>
</div>
<button className="mt-btn mt-btn--primary"><Icon name="plus" size={12}/> New regimen</button>
</div>
<div className="mt-col">
{REG2.map(r => (
<div key={r.id} className="mt-card" style={{padding: 18}}>
<div style={{display:'flex', justifyContent:'space-between', alignItems:'flex-start', gap: 16}}>
<div>
<div style={{display:'flex', alignItems:'center', gap: 8, marginBottom: 4}}>
<span style={{fontFamily:'var(--font-display)', fontSize: 18, fontWeight: 500, color:'var(--ink-strong)'}}>Aeri's {r.name}</span>
<span className="mt-pill mt-pill--ok">Active</span>
</div>
<div style={{fontSize:12, color:'var(--ink-muted)'}}>{r.items.length} medication{r.items.length>1?'s':''} · {r.time} · {r.freq}</div>
</div>
<div style={{display:'flex', gap: 6}}>
<button className="mt-btn mt-btn--ghost">Deactivate</button>
<button className="mt-btn mt-btn--subtle"><Icon name="edit" size={12}/></button>
<button className="mt-btn mt-btn--subtle"><Icon name="trash" size={12}/></button>
</div>
</div>
<div style={{display:'flex', flexWrap:'wrap', gap: 6, marginTop: 14}}>
{r.items.map(it => {
const m = M2[it.medId];
return (
<span key={it.medId} className="mt-pill mt-pill--outline" style={{padding: '4px 10px', fontSize: 11}}>
<span style={{background:'var(--viz-'+m.color+')', width:6, height:6, borderRadius:3, display:'inline-block', marginRight: 4}}/>
<strong style={{fontWeight: 500, color:'var(--ink-strong)'}}>{m.name}</strong>
<span style={{marginLeft:4, color:'var(--ink-muted)'}}>{it.qty} {it.unit}</span>
</span>
);
})}
</div>
</div>
))}
</div>
</div>
);
// ---- Organizer ----
const OrganizerPage = () => {
const [regimenId, setRegimenId] = useState(REG2[1].id);
const [days, setDays] = useState(7);
const regimen = REG2.find(r => r.id === regimenId);
// Compute inventory impact: for each item in regimen, how many units will be consumed?
const impact = regimen.items.map(it => {
const m = M2[it.medId];
const cabEntry = C2.find(c => c.medId === it.medId);
const onHand = cabEntry ? cabEntry.qty : 0;
const needed = it.qty * days;
const after = onHand - needed;
const perDay = cabEntry ? (cabEntry.perDose || 1) / (cabEntry.daysPerUnit || 1) : 1;
const daysAfter = perDay > 0 ? Math.floor(Math.max(0, after) / perDay) : 0;
const status = after < 0 ? 'short' : after < needed * 0.5 ? 'low' : 'ok';
return { m, it, onHand, needed, after, daysAfter, status, cabEntry };
});
const anyShort = impact.some(i => i.status === 'short');
const anyLow = impact.some(i => i.status === 'low');
return (
<div className="mt-page">
<div className="org-hero">
<div className="org-hero__controls">
<div>
<div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.08em', fontWeight:500}}>Fill from regimen</div>
<select className="org-hero__select" value={regimenId} onChange={e => setRegimenId(e.target.value)}>
{REG2.map(r => <option key={r.id} value={r.id}>Aeri's {r.name}</option>)}
</select>
<div style={{fontSize:12, color:'var(--ink-muted)', marginTop: 4}}>{regimen.time} · {regimen.items.length} medicines · {regimen.freq}</div>
</div>
<div className="org-hero__divider"/>
<div>
<div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.08em', fontWeight:500, marginBottom:8}}>Days to fill</div>
<div className="org-hero__stepper">
<button onClick={() => setDays(Math.max(1, days - 1))}></button>
<span className="num">{days}</span>
<button onClick={() => setDays(Math.min(30, days + 1))}>+</button>
</div>
<div className="org-hero__presets">
{[3, 7, 14, 30].map(d => (
<button key={d} className={'org-hero__preset' + (days === d ? ' is-active' : '')} onClick={() => setDays(d)}>{d}d</button>
))}
</div>
</div>
</div>
<div className="org-hero__summary">
<div className="org-hero__sumRow">
<div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.08em', fontWeight:500}}>This fill will dispense</div>
<div style={{fontSize: 22, fontWeight: 600, color:'var(--ink-strong)', letterSpacing:'-0.01em'}} className="num">
{impact.reduce((a,b) => a + b.needed, 0)} <span style={{fontSize:13, fontWeight:400, color:'var(--ink-muted)'}}>units across {impact.length} medicines</span>
</div>
</div>
{anyShort ? (
<div className="org-alert org-alert--danger">
<Icon name="alert" size={14}/>
<div>
<strong>Not enough stock.</strong> {impact.filter(i => i.status === 'short').length} medicine{impact.filter(i => i.status === 'short').length > 1 ? 's are' : ' is'} short for this fill.
</div>
<button className="mt-btn mt-btn--primary">Add to shopping list</button>
</div>
) : anyLow ? (
<div className="org-alert org-alert--warn">
<Icon name="alert" size={14}/>
<div><strong>Low after fill.</strong> Some meds will drop below 50% headroom.</div>
</div>
) : (
<div className="org-alert org-alert--ok">
<Icon name="check" size={14}/>
<div><strong>Ready to dispense.</strong> Cabinet has enough stock with comfortable headroom.</div>
</div>
)}
<div style={{display:'flex', gap: 8}}>
<button className="mt-btn mt-btn--primary" disabled={anyShort}><Icon name="zap" size={12}/> Dispense {days}-day fill</button>
<button className="mt-btn mt-btn--ghost">Print label</button>
<div style={{flex:1}}/>
<button className="mt-btn mt-btn--ghost">Fill history</button>
</div>
</div>
</div>
{/* Inventory impact table */}
<div className="mt-card" style={{marginTop: 16}}>
<div className="mt-card__head">
<div>
<div className="mt-card__title">Inventory impact</div>
<div className="mt-card__sub">What this fill will take from the cabinet</div>
</div>
</div>
<div className="org-impact">
<div className="org-impact__head">
<div>Medicine</div>
<div style={{textAlign:'right'}}>On hand</div>
<div style={{textAlign:'right'}}>Needed</div>
<div>After fill</div>
<div style={{textAlign:'right'}}>Days left</div>
</div>
{impact.map(row => {
const m = row.m;
const pct = row.onHand > 0 ? Math.max(0, Math.min(100, (row.after / row.onHand) * 100)) : 0;
return (
<div key={m.id} className={'org-impact__row org-impact__row--' + row.status}>
<div style={{display:'flex', alignItems:'center', gap: 10}}>
<div className="cab-card__swatch cab-card__swatch--sm" style={{'--c':'var(--viz-'+m.color+')'}}>
<Icon name={m.form === 'Injection' ? 'injection' : m.form === 'Capsule' ? 'capsule' : 'pill'} size={12}/>
</div>
<div>
<div style={{fontWeight:500, fontSize:13, color:'var(--ink-strong)'}}>{m.name}</div>
<div style={{fontSize:11, color:'var(--ink-muted)'}}>{row.it.qty} × {days} days = {row.needed} {m.form.toLowerCase()}</div>
</div>
</div>
<div style={{textAlign:'right'}}><span className="num" style={{fontSize:14, fontWeight:600}}>{row.onHand}</span></div>
<div style={{textAlign:'right'}}><span className="num" style={{fontSize:14, color:'var(--ink-muted)'}}>{row.needed}</span></div>
<div style={{display:'flex', alignItems:'center', gap: 8}}>
<div className="org-impact__bar">
<div className="org-impact__barBg"/>
<div className="org-impact__barFill" style={{width: Math.max(0, pct) + '%', background: row.status === 'short' ? 'var(--danger)' : row.status === 'low' ? 'var(--warn)' : 'var(--ok)'}}/>
</div>
<span className="num" style={{fontSize:13, fontWeight:600, color: row.after < 0 ? 'var(--danger)' : 'var(--ink-strong)', minWidth: 38, textAlign:'right'}}>{row.after < 0 ? row.after : row.after}</span>
</div>
<div style={{textAlign:'right'}}>
{row.status === 'short'
? <span className="mt-pill mt-pill--danger">Short</span>
: <span className="num" style={{fontSize:13, color: row.status === 'low' ? 'var(--warn)' : 'var(--ink-muted)'}}>{row.daysAfter}d</span>}
</div>
</div>
);
})}
</div>
</div>
</div>
);
};
// ---- Activity ----
const ActivityPage = () => {
const { SPENDING } = window.MESHI;
return (
<div className="mt-page">
<div className="mt-card" style={{marginBottom: 16}}>
<div className="mt-card__head">
<div>
<div className="mt-card__title">Spending summary</div>
<div className="mt-card__sub">Last 6 months</div>
</div>
<div className="mt-row" style={{gap:6}}>
<button className="mt-btn mt-btn--ghost">This year <Icon name="chevDown" size={12}/></button>
<button className="mt-btn mt-btn--ghost">All medicines <Icon name="chevDown" size={12}/></button>
</div>
</div>
<div style={{padding:'18px', display:'grid', gridTemplateColumns:'2fr 1fr', gap: 20}}>
<svg viewBox="0 0 360 180" width="100%" height="180">
{SPENDING.map((s, i) => {
const max = Math.max(...SPENDING.map(x=>x.amount));
const h = (s.amount / max) * 140;
const x = 20 + i * 56;
return (
<g key={s.month}>
<rect x={x} y={160 - h} width="36" height={h} rx="3" fill="var(--brand)" opacity={0.3 + (i/SPENDING.length)*0.7}/>
<text x={x+18} y="175" textAnchor="middle" fontSize="9" fill="var(--ink-faint)">{s.month.slice(5)}</text>
</g>
);
})}
</svg>
<div className="mt-col">
<div><div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.06em'}}>Total spent</div><div style={{fontFamily:'var(--font-display)', fontSize:28, fontWeight:500, color:'var(--ink-strong)'}} className="num">{fmtJPY(SPENDING.reduce((a,b)=>a+b.amount,0))}</div></div>
<div><div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.06em'}}>Monthly average</div><div className="num" style={{fontSize:18, fontWeight:500}}>{fmtJPY(Math.round(SPENDING.reduce((a,b)=>a+b.amount,0)/SPENDING.length))}</div></div>
<div><div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.06em'}}>Top medicine</div><div style={{fontSize:14, fontWeight:500}}>Semaglutide <span className="num" style={{color:'var(--ink-muted)'}}>{fmtJPY(56062)}</span></div></div>
</div>
</div>
</div>
<div className="mt-card">
<div className="mt-card__head">
<div>
<div className="mt-card__title">Cabinet activity</div>
<div className="mt-card__sub">{AC2.length} events</div>
</div>
<div className="mt-row" style={{gap:6}}>
<button className="mt-btn mt-btn--ghost">All types <Icon name="chevDown" size={12}/></button>
<button className="mt-btn mt-btn--ghost">All medicines <Icon name="chevDown" size={12}/></button>
</div>
</div>
<div style={{padding:'4px 18px 16px'}}>
{AC2.map(a => {
const m = M2[a.medId];
return (
<div key={a.id} className="act-row">
<span className="act-rail"><span className={'act-dot act-dot--'+a.type}/></span>
<span className={'mt-pill mt-pill--' + (a.type==='consumed'?'info':a.type==='added'?'ok':'warn')} style={{minWidth: 76, justifyContent:'center'}}>{a.type}</span>
<strong style={{fontSize:13, fontWeight:500, color:'var(--ink-strong)'}}>{m.name}</strong>
<span className="num" style={{color: a.delta < 0 ? 'var(--ink-muted)' : 'var(--ok)', fontSize: 13, fontWeight:500}}>{a.delta > 0?'+':''}{a.delta}</span>
<span className="num" style={{color:'var(--ink-faint)', fontSize:12}}>{a.before}{a.after}</span>
<span style={{flex:1}}/>
<span className="mono" style={{fontSize:11, color:'var(--ink-muted)'}}>{a.at}</span>
</div>
);
})}
</div>
</div>
</div>
);
};
// ---- Stores ----
const StoresPage = () => (
<div className="mt-page">
<div className="sub-toolbar">
<div style={{display:'flex', gap:8, alignItems:'center'}}>
<div className="cab-filters__search"><Icon name="search" size={13}/><input placeholder="Search stores…"/></div>
<button className="mt-btn mt-btn--ghost">All tags <Icon name="chevDown" size={12}/></button>
<label style={{display:'flex', gap:6, alignItems:'center', fontSize:12, color:'var(--ink-muted)'}}><input type="checkbox"/> Show inactive</label>
</div>
<button className="mt-btn mt-btn--primary"><Icon name="plus" size={12}/> Add store</button>
</div>
<div className="mt-grid" style={{gridTemplateColumns:'repeat(auto-fill, minmax(280px, 1fr))'}}>
{ST2.map(s => (
<div key={s.id} className="mt-card" style={{padding: 16}}>
<div style={{display:'flex', justifyContent:'space-between', alignItems:'flex-start'}}>
<div>
<div style={{fontWeight:500, fontSize:14, color:'var(--ink-strong)'}}>{s.name}</div>
<div style={{fontSize:11, color:'var(--brand)', marginTop:2}}>{s.url}</div>
</div>
<div style={{display:'flex', gap:4}}>
<button className="mt-btn mt-btn--subtle"><Icon name="edit" size={12}/></button>
</div>
</div>
<div style={{display:'flex', flexWrap:'wrap', gap: 4, marginTop: 10}}>
{s.tags.map(t => <span key={t} className="mt-pill mt-pill--ghost" style={{fontSize:10}}>{t}</span>)}
</div>
<div className="mt-divider"/>
<div style={{display:'flex', justifyContent:'space-between', fontSize:11, color:'var(--ink-muted)'}}>
<span>{PR2.filter(p=>p.store===s.id).length} prices tracked</span>
<span>{PU2.filter(p=>p.store===s.id).length} orders</span>
</div>
</div>
))}
</div>
</div>
);
// ---- Prices ----
const PricesPage = () => (
<div className="mt-page">
<div className="sub-toolbar">
<div style={{display:'flex', gap: 8}}>
<button className="mt-btn mt-btn--ghost">Semaglutide (14mg) <Icon name="chevDown" size={12}/></button>
<button className="mt-btn mt-btn--ghost">All stores <Icon name="chevDown" size={12}/></button>
</div>
<button className="mt-btn mt-btn--primary"><Icon name="plus" size={12}/> Record price</button>
</div>
<div className="mt-card" style={{marginBottom: 16}}>
<div className="mt-card__head"><div><div className="mt-card__title">Store comparison</div><div className="mt-card__sub">Best price highlighted</div></div></div>
<div className="prices-table">
<div className="prices-table__head">
<div>Store</div><div className="num">Price</div><div className="num">Per unit</div><div>Date</div>
</div>
{PR2.filter(p=>p.medId==='semaglutide').map(p => {
const s = ST2.find(s=>s.id===p.store);
const isBest = p.price/p.pkg === Math.min(...PR2.filter(x=>x.medId===p.medId).map(x=>x.price/x.pkg));
return (
<div key={p.store+p.date} className={'prices-table__row' + (isBest?' is-best':'')}>
<div style={{display:'flex', alignItems:'center', gap:8}}>
<span style={{fontWeight:500}}>{s?.name}</span>
{isBest && <span className="mt-pill mt-pill--ok" style={{fontSize:10}}>Cheapest</span>}
</div>
<div className="num">{fmtJPY(p.price)}</div>
<div className="num" style={{color:'var(--ink-muted)'}}>{fmtJPY(Math.round(p.price/p.pkg))}</div>
<div className="num" style={{color:'var(--ink-muted)'}}>{p.date}</div>
</div>
);
})}
</div>
</div>
<div className="mt-card">
<div className="mt-card__head"><div><div className="mt-card__title">All price records</div><div className="mt-card__sub">{PR2.length} entries</div></div></div>
<div className="prices-table">
<div className="prices-table__head" style={{gridTemplateColumns:'1.5fr 1fr 0.7fr 0.8fr 0.8fr 0.8fr'}}>
<div>Medicine</div><div>Store</div><div className="num">Qty</div><div className="num">Price</div><div className="num">Per unit</div><div>Date</div>
</div>
{PR2.map(p => {
const s = ST2.find(s=>s.id===p.store);
const m = M2[p.medId];
return (
<div key={p.medId+p.store+p.date} className="prices-table__row" style={{gridTemplateColumns:'1.5fr 1fr 0.7fr 0.8fr 0.8fr 0.8fr'}}>
<div style={{fontWeight:500}}>{m.name}</div>
<div>{s?.name}</div>
<div className="num">{p.pkg} {p.unit}</div>
<div className="num">{fmtJPY(p.price)}</div>
<div className="num" style={{color:'var(--ink-muted)'}}>{fmtJPY(Math.round(p.price/p.pkg))}</div>
<div className="num" style={{color:'var(--ink-muted)'}}>{p.date}</div>
</div>
);
})}
</div>
</div>
</div>
);
// ---- Refills ----
const RefillsPage = () => {
const low = C2.map(r => ({r, s: cabinetStatus(r)})).filter(x => x.s.level !== 'ok');
return (
<div className="mt-page">
<div className="mt-card" style={{marginBottom: 16}}>
<div className="mt-card__head">
<div>
<div className="mt-card__title">Refill alerts</div>
<div className="mt-card__sub">Threshold: 14 days</div>
</div>
<button className="mt-btn mt-btn--primary"><Icon name="plus" size={12}/> Generate refill list</button>
</div>
<div style={{padding:'4px 18px 16px'}}>
{low.map(({r, s}) => {
const m = M2[r.medId];
const bestPrice = PR2.filter(p=>p.medId===r.medId).sort((a,b)=> a.price/a.pkg - b.price/b.pkg)[0];
const bestStore = bestPrice && ST2.find(st=>st.id===bestPrice.store);
return (
<div key={r.medId} className="refill-row">
<div className="cab-card__swatch cab-card__swatch--sm" style={{'--c':'var(--viz-'+m.color+')'}}>
<Icon name={m.form === 'Injection' ? 'injection' : m.form === 'Capsule' ? 'capsule' : 'pill'} size={12}/>
</div>
<div style={{flex:1}}>
<div style={{fontWeight:500, color:'var(--ink-strong)'}}>{m.name} <span style={{color:'var(--ink-muted)', fontWeight:400}}>{m.strength}</span></div>
<div style={{fontSize:11, color: s.level==='critical' ? 'var(--danger)':'var(--warn)', fontWeight:500}}>{s.days} days left · {r.qty} {r.unit} in cabinet</div>
</div>
<div style={{textAlign:'right'}}>
<div style={{fontSize:11, color:'var(--ink-faint)'}}>Suggested</div>
<div className="num" style={{fontWeight:500}}>{bestPrice?.pkg || 30} units</div>
</div>
<div style={{textAlign:'right', minWidth: 140}}>
<div style={{fontSize:11, color:'var(--ink-faint)'}}>Best price</div>
<div style={{fontSize:12, fontWeight:500, color:'var(--brand)'}}>{bestStore?.name} · <span className="num">{bestPrice ? fmtJPY(bestPrice.price) : '—'}</span></div>
</div>
<button className="mt-btn mt-btn--primary">Add to list</button>
</div>
);
})}
</div>
</div>
<div className="mt-card">
<div className="mt-card__head">
<div><div className="mt-card__title">Refill lists</div><div className="mt-card__sub">Create from alerts or start blank</div></div>
<button className="mt-btn mt-btn--ghost"><Icon name="plus" size={12}/> New list</button>
</div>
<div style={{padding:'20px 18px', color:'var(--ink-muted)', fontSize:12, textAlign:'center'}}>No refill lists yet. Create one above or generate from alerts.</div>
</div>
</div>
);
};
// ---- Purchases ----
const PurchasesPage = () => (
<div className="mt-page">
<div className="sub-toolbar">
<div style={{display:'flex', gap:8}}>
<button className="mt-btn mt-btn--ghost">All statuses <Icon name="chevDown" size={12}/></button>
</div>
<button className="mt-btn mt-btn--primary"><Icon name="plus" size={12}/> Record purchase</button>
</div>
{['pending', 'received'].map(status => {
const items = PU2.filter(p => p.status === status);
if (!items.length) return null;
return (
<div key={status} style={{marginBottom: 20}}>
<div style={{fontSize:11, textTransform:'uppercase', letterSpacing:'.08em', color:'var(--ink-muted)', fontWeight:600, marginBottom: 8}}>{status === 'pending' ? 'Pending arrival' : 'Received'}</div>
<div className="mt-col">
{items.map(p => {
const s = ST2.find(s => s.id === p.store);
const total = p.items.reduce((a,i) => a + i.price, 0);
return (
<div key={p.id} className="mt-card" style={{padding: 16}}>
<div style={{display:'flex', justifyContent:'space-between', alignItems:'flex-start', marginBottom: 10}}>
<div>
<div style={{fontWeight:500, fontSize:14, color:'var(--ink-strong)'}}>{s?.name}</div>
<div className="num" style={{fontSize:11, color:'var(--ink-muted)'}}>{p.date}</div>
</div>
<span className={'mt-pill mt-pill--' + (status==='pending'?'warn':'ok')}>{status}</span>
</div>
<div style={{display:'flex', flexDirection:'column', gap:6, padding: '10px 0', borderTop:'1px dashed var(--border)'}}>
{p.items.map(i => {
const m = M2[i.medId];
return (
<div key={i.medId} style={{display:'flex', justifyContent:'space-between', fontSize:12}}>
<span>{m.name}</span>
<span style={{color:'var(--ink-muted)'}} className="num">{i.qty} {i.unit} · {fmtJPY(i.price)}</span>
</div>
);
})}
</div>
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', paddingTop: 10, borderTop:'1px solid var(--border)'}}>
<div className="num" style={{fontSize:15, fontWeight:600}}>{fmtJPY(total)}</div>
{status === 'pending' && <div style={{display:'flex', gap:6}}>
<button className="mt-btn mt-btn--primary">Mark as received</button>
<button className="mt-btn mt-btn--ghost">Cancel</button>
</div>}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
// ---- Settings ----
const SettingsPage = () => (
<div className="mt-page" style={{maxWidth: 720}}>
<div className="mt-card" style={{marginBottom: 16}}>
<div className="mt-card__head"><div className="mt-card__title">Household</div></div>
<div style={{padding: 18}}>
<div className="settings-row"><span>Name</span><span style={{fontWeight:500}}>Red Panda Den</span><button className="mt-btn mt-btn--ghost">Edit</button></div>
<div className="settings-row"><span>Invite code</span><span className="mono">2496F-7CF</span><button className="mt-btn mt-btn--ghost">Regenerate</button></div>
<div className="settings-row"><span>Members</span><span>1</span><button className="mt-btn mt-btn--ghost">Invite</button></div>
</div>
</div>
<div className="mt-card" style={{marginBottom: 16}}>
<div className="mt-card__head"><div className="mt-card__title">Account</div></div>
<div style={{padding: 18}}>
<p style={{fontSize:12, color:'var(--ink-muted)'}}>Account settings are managed through Keycloak.</p>
<button className="mt-btn mt-btn--ghost">Manage Keycloak account</button>
</div>
</div>
</div>
);
Object.assign(window, { LibraryPage, RegimensPage, OrganizerPage, ActivityPage, StoresPage, PricesPage, RefillsPage, PurchasesPage, SettingsPage });

View file

@ -0,0 +1,162 @@
// Schedule & Intake Log slot-based, collapsed by default
const { TODAY_INTAKE: INTAKE_SEED, MED: MED_S, REGIMENS: REGS_S } = window.MESHI;
const SchedulePage = () => {
const [intake, setIntake] = useState(INTAKE_SEED);
const [day, setDay] = useState(5);
const [expanded, setExpanded] = useState(null); // slot time
const weekDays = ['Mon 13', 'Tue 14', 'Wed 15', 'Thu 16', 'Fri 17', 'Sat 18', 'Sun 19'];
const adherence = [1.0, 1.0, 1.0, 0.88, 1.0, 0.50, 0.0];
const toggle = (id) => setIntake(intake.map(i => i.id === id ? { ...i, status: i.status === 'taken' ? 'upcoming' : 'taken', takenAt: '21:04' } : i));
const markSlot = (time) => setIntake(intake.map(i => i.scheduled === time ? { ...i, status: 'taken', takenAt: time } : i));
// Group by scheduled time
const slots = [
{ time: '08:00', label: 'Morning', icon: 'sun' },
{ time: '21:00', label: 'Evening', icon: 'moon' },
].map(s => {
const items = intake.filter(i => i.scheduled === s.time);
const done = items.filter(i => i.status === 'taken').length;
return { ...s, items, done, total: items.length, complete: done === items.length && items.length > 0 };
});
const totalDone = intake.filter(i => i.status === 'taken').length;
return (
<div className="mt-page">
{/* Week strip — adherence heatmap */}
<div className="sched-weekbar">
<div className="sched-weekbar__label">
<div style={{fontSize:10, color:'var(--ink-faint)', textTransform:'uppercase', letterSpacing:'.08em', fontWeight:500}}>This week</div>
<div style={{fontSize:13, color:'var(--ink-strong)', fontWeight:500, marginTop:2}}>
<span className="num">{Math.round(adherence.reduce((a,b)=>a+b,0)/adherence.length*100)}%</span> adherence
</div>
</div>
<div className="sched-weekbar__days">
{weekDays.map((d, i) => {
const isToday = i === day;
const a = adherence[i];
return (
<button key={d} className={'sched-weekday' + (isToday ? ' is-active' : '')} onClick={() => setDay(i)}>
<div className="sched-weekday__label">{d.split(' ')[0]}</div>
<div className="sched-weekday__num">{d.split(' ')[1]}</div>
<div className="sched-weekday__bar">
<div className="sched-weekday__fill" style={{height: (a*100) + '%', background: a >= 1 ? 'var(--ok)' : a >= 0.5 ? 'var(--warn)' : a > 0 ? 'var(--danger)' : 'var(--border-strong)'}}/>
</div>
</button>
);
})}
</div>
</div>
<div style={{display:'grid', gridTemplateColumns:'2fr 1fr', gap: 16, marginTop: 16}}>
<div className="mt-card">
<div className="mt-card__head">
<div>
<div className="mt-card__title">Saturday, April 18</div>
<div className="mt-card__sub"><span className="num">{totalDone}</span> of <span className="num">{intake.length}</span> doses · 18:30 now</div>
</div>
<button className="mt-btn mt-btn--ghost"><Icon name="plus" size={12}/> Log off-schedule</button>
</div>
<div className="sched-slots">
{slots.map(slot => {
const isOpen = expanded === slot.time;
const now = 18.5;
const slotHr = parseInt(slot.time);
const status = slot.complete ? 'done' : slotHr < now ? 'overdue' : 'upcoming';
return (
<div key={slot.time} className={'sched-slot sched-slot--' + status + (isOpen ? ' is-open' : '')}>
<button className="sched-slot__row" onClick={() => setExpanded(isOpen ? null : slot.time)}>
<div className="sched-slot__time">
<div className="sched-slot__hour num">{slot.time}</div>
<div className="sched-slot__phase">{slot.label}</div>
</div>
<div className="sched-slot__main">
<div className="sched-slot__summary">
<span className="num" style={{fontSize:15, fontWeight:600, color:'var(--ink-strong)'}}>{slot.done}/{slot.total}</span>
<span style={{fontSize:13, color:'var(--ink-muted)'}}>doses</span>
<div className="sched-slot__dots">
{slot.items.map(i => {
const m = MED_S[i.medId];
return <span key={i.id} className={'sched-slot__dot' + (i.status === 'taken' ? ' is-taken' : '')} style={{background: i.status === 'taken' ? 'var(--viz-' + m.color + ')' : 'transparent', borderColor: 'var(--viz-' + m.color + ')'}}/>;
})}
</div>
</div>
{status === 'done' && <span className="mt-pill mt-pill--ok">All taken</span>}
{status === 'overdue' && <span className="mt-pill mt-pill--warn">Overdue</span>}
{status === 'upcoming' && <span className="mt-pill mt-pill--ghost">Upcoming</span>}
</div>
<div className="sched-slot__actions" onClick={e => e.stopPropagation()}>
{!slot.complete && <button className="mt-btn mt-btn--primary" onClick={() => markSlot(slot.time)}><Icon name="check" size={12}/> Take all</button>}
<Icon name="chevDown" size={14} style={{transform: isOpen ? 'rotate(180deg)' : 'none', transition:'transform .2s', color:'var(--ink-faint)'}}/>
</div>
</button>
{isOpen && (
<div className="sched-slot__detail">
{slot.items.map(i => {
const m = MED_S[i.medId];
return (
<button key={i.id} className={'sched-slot__item' + (i.status === 'taken' ? ' is-taken' : '')} onClick={() => toggle(i.id)}>
<span className={'sched-check' + (i.status === 'taken' ? ' is-taken' : '')}>{i.status === 'taken' && <Icon name="check" size={10}/>}</span>
<span style={{background:'var(--viz-'+m.color+')', width:3, height:20, borderRadius:2}}/>
<span style={{flex:1, display:'flex', flexDirection:'column', alignItems:'flex-start'}}>
<span style={{fontWeight:500, fontSize:13, color:'var(--ink-strong)'}}>{m.name}</span>
<span style={{fontSize:11, color:'var(--ink-muted)'}}>{i.qty} {m.form.toLowerCase()} · {m.strength}</span>
</span>
{i.status === 'taken' && <span className="mono" style={{fontSize:11, color:'var(--ink-muted)'}}>{i.takenAt}</span>}
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
<div style={{padding:'4px 18px 16px', display:'flex', gap: 8, alignItems:'center', color:'var(--ink-faint)', fontSize:11}}>
<Icon name="info" size={12}/> Tap a slot to see individual medicines. Off-schedule doses logged separately.
</div>
</div>
<div className="mt-card">
<div className="mt-card__head">
<div>
<div className="mt-card__title">Active regimens</div>
<div className="mt-card__sub"><span className="num">{REGS_S.length}</span> configured</div>
</div>
</div>
<div style={{padding:'4px 18px 16px'}}>
{REGS_S.map(r => (
<div key={r.id} className="sched-regimen">
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:8}}>
<div>
<div style={{fontWeight:500, fontSize:13, color:'var(--ink-strong)'}}>Aeri's {r.name}</div>
<div style={{fontSize:11, color:'var(--ink-muted)'}}>{r.time} · {r.freq}</div>
</div>
<span className="mt-pill mt-pill--brand">Active</span>
</div>
<div style={{display:'flex', flexWrap:'wrap', gap: 4}}>
{r.items.map(it => {
const m = MED_S[it.medId];
return (
<span key={it.medId} className="mt-pill mt-pill--ghost" style={{fontSize:10}}>
<span style={{background:'var(--viz-'+m.color+')', width:6, height:6, borderRadius:3, display:'inline-block', marginRight:2}}/>
{m.name}
</span>
);
})}
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
};
window.SchedulePage = SchedulePage;

110
docs/design/src/Shell.jsx Normal file
View file

@ -0,0 +1,110 @@
// App shell: sidebar + topbar + routing
const { useState, useEffect, useMemo, useRef, createContext, useContext } = React;
const AppCtx = createContext(null);
const useApp = () => useContext(AppCtx);
const NAV = [
{ id: 'dashboard', label: 'Dashboard', icon: 'dashboard' },
{ id: 'cabinet', label: 'Cabinet', icon: 'cabinet', section: 'Medicines', badge: 2 },
{ id: 'schedule', label: 'Schedule & Log', icon: 'clock', section: 'Medicines' },
{ id: 'regimens', label: 'Regimens', icon: 'list', section: 'Medicines' },
{ id: 'organizer', label: 'Pill Organizer', icon: 'calendar', section: 'Medicines' },
{ id: 'library', label: 'Library', icon: 'pill', section: 'Medicines' },
{ id: 'refills', label: 'Shopping list', icon: 'refresh', section: 'Medicines', badge: 1 },
{ id: 'purchases', label: 'Purchases', icon: 'truck', section: 'Medicines' },
{ id: 'prices', label: 'Prices', icon: 'tag', section: 'Medicines' },
{ id: 'stores', label: 'Stores', icon: 'store', section: 'Medicines' },
{ id: 'activity', label: 'Activity & Spend', icon: 'trend', section: 'Medicines' },
{ id: 'settings', label: 'Settings', icon: 'settings' },
];
const Sidebar = ({ route, onNav }) => {
const grouped = useMemo(() => {
const g = {};
NAV.forEach(n => { (g[n.section] = g[n.section] || []).push(n); });
return g;
}, []);
return (
<aside className="mt-side">
<div className="mt-side__brand">
<div className="mt-side__logo">
<svg width="22" height="22" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="5" fill="var(--brand)"/><path d="M7 15l3-6 2 4 2-3 3 5" stroke="var(--brand-ink)" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div>
<div className="mt-side__wordmark">
<div className="mt-side__title">MeshiTrack</div>
<div className="mt-side__sub">Red Panda Den</div>
</div>
</div>
<nav className="mt-side__nav">
{Object.entries(grouped).map(([section, items]) => (
<div key={section} className="mt-side__group">
<div className="mt-side__section">{section}</div>
{items.map(item => (
<button
key={item.id}
className={'mt-side__item' + (route === item.id ? ' is-active' : '') + (item.soon ? ' is-soon' : '')}
onClick={() => !item.soon && onNav(item.id)}
disabled={item.soon}
>
<Icon name={item.icon} size={16} />
<span>{item.label}</span>
{item.badge ? <span className="mt-side__badge">{item.badge}</span> : null}
{item.soon ? <span className="mt-side__soon">Soon</span> : null}
</button>
))}
</div>
))}
</nav>
<div className="mt-side__foot">
<div className="mt-side__avatar">A</div>
<div className="mt-side__who">
<div className="mt-side__whoName">aerimagne</div>
<div className="mt-side__whoRole">Household owner</div>
</div>
</div>
</aside>
);
};
const Topbar = ({ title, subtitle, crumbs, onSearch, actions, theme, onToggleTheme }) => {
return (
<header className="mt-top">
<div className="mt-top__left">
{crumbs && crumbs.length > 1 && (
<div className="mt-top__crumbs">
{crumbs.map((c, i) => (
<React.Fragment key={i}>
{i > 0 && <Icon name="chev" size={12} style={{ opacity: 0.4 }} />}
<span className={i === crumbs.length - 1 ? 'is-current' : ''}>{c}</span>
</React.Fragment>
))}
</div>
)}
<div className="mt-top__titles">
<h1 className="mt-top__title">{title}</h1>
{subtitle && <div className="mt-top__sub">{subtitle}</div>}
</div>
</div>
<div className="mt-top__right">
<div className="mt-top__search">
<Icon name="search" size={14} />
<input placeholder="Search medicines, regimens, stores…" />
<kbd>K</kbd>
</div>
<button className="mt-iconbtn" title="Notifications">
<Icon name="bell" size={16} />
<span className="mt-iconbtn__dot"></span>
</button>
<button className="mt-iconbtn" onClick={onToggleTheme} title="Toggle theme">
<Icon name={theme === 'dark' ? 'sun' : 'moon'} size={16} />
</button>
{actions}
</div>
</header>
);
};
window.Shell = { Sidebar, Topbar, AppCtx, useApp, NAV };

129
docs/design/src/data.jsx Normal file
View file

@ -0,0 +1,129 @@
// Mock data for MeshiTrack. Grounded in the real household ("Red Panda Den"),
// extended with plausible timestamps, quantities, purchases and price history.
const MEDICINES = [
{ id: 'semaglutide', name: 'Semaglutide', strength: '14 mg', form: 'Tablet', category: 'Prescription', color: 1, notes: 'Weekly GLP-1. Store at room temp.' },
{ id: 'estradiol', name: 'Estradiol Valerate', strength: '10 mg', form: 'Injection', category: 'Prescription', color: 4, notes: 'Intramuscular, weekly.' },
{ id: 'spironolactone', name: 'Spironolactone', strength: '100 mg', form: 'Tablet', category: 'Prescription', color: 3 },
{ id: 'tadalafil', name: 'Tadalafil', strength: '5 mg', form: 'Tablet', category: 'Prescription', color: 3 },
{ id: 'dutasteride', name: 'Dutasteride', strength: '0.5 mg', form: 'Tablet', category: 'Prescription', color: 3 },
{ id: 'progesterone', name: 'Progesterone', strength: '200 mg', form: 'Capsule', category: 'Prescription', color: 4 },
{ id: 'magnesium', name: 'Magnesium', strength: '400 mg', form: 'Capsule', category: 'Supplement', color: 5 },
{ id: 'vitamin-d', name: 'Vitamin D', strength: '2000 IU', form: 'Capsule', category: 'Supplement', color: 2 },
{ id: 'vitamin-a', name: 'Vitamin A', strength: '5000 IU', form: 'Capsule', category: 'Supplement', color: 2 },
];
const MED = Object.fromEntries(MEDICINES.map(m => [m.id, m]));
// Cabinet inventory summed across item lots
const CABINET = [
{ medId: 'dutasteride', qty: 80, unit: 'tablets', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2027-08-12', lowAt: 14 },
{ medId: 'estradiol', qty: 2, unit: 'vials', lots: 1, daysPerUnit: 14, perDose: 0.5, expiry: '2026-11-30', lowAt: 1 },
{ medId: 'magnesium', qty: 115, unit: 'capsules', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2028-02-01', lowAt: 20 },
{ medId: 'progesterone', qty: 35, unit: 'capsules', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2027-04-30', lowAt: 14 },
{ medId: 'semaglutide', qty: 26, unit: 'tablets', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2026-09-15', lowAt: 7 },
{ medId: 'spironolactone', qty: 62, unit: 'tablets', lots: 1, daysPerUnit: 1, perDose: 2, expiry: '2027-01-20', lowAt: 14 },
{ medId: 'tadalafil', qty: 7, unit: 'tablets', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2026-12-01', lowAt: 7 },
{ medId: 'vitamin-a', qty: 211, unit: 'capsules', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2028-05-05', lowAt: 30 },
{ medId: 'vitamin-d', qty: 96, unit: 'capsules', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2028-03-10', lowAt: 30 },
];
// Daily schedule regimens
const REGIMENS = [
{
id: 'morning', name: 'Morning Pills', time: '08:00', freq: 'daily', active: true,
items: [
{ medId: 'semaglutide', qty: 1, unit: 'tablet' },
],
},
{
id: 'evening', name: 'Evening Pills', time: '21:00', freq: 'daily', active: true,
items: [
{ medId: 'spironolactone', qty: 1, unit: 'tablet' },
{ medId: 'tadalafil', qty: 1, unit: 'tablet' },
{ medId: 'dutasteride', qty: 1, unit: 'tablet' },
{ medId: 'progesterone', qty: 1, unit: 'capsule' },
{ medId: 'vitamin-a', qty: 1, unit: 'capsule' },
{ medId: 'magnesium', qty: 1, unit: 'capsule' },
{ medId: 'vitamin-d', qty: 1, unit: 'capsule' },
],
},
{
id: 'injections', name: 'Injections', time: 'Sun 10:00', freq: 'weekly', active: true,
items: [
{ medId: 'estradiol', qty: 0.5, unit: 'vial' },
],
},
];
// Today's intake some done, some upcoming
const TODAY_INTAKE = [
{ id: 't1', regimen: 'morning', medId: 'semaglutide', qty: 1, scheduled: '08:00', status: 'taken', takenAt: '08:12' },
{ id: 't2', regimen: 'evening', medId: 'spironolactone', qty: 1, scheduled: '21:00', status: 'upcoming' },
{ id: 't3', regimen: 'evening', medId: 'tadalafil', qty: 1, scheduled: '21:00', status: 'upcoming' },
{ id: 't4', regimen: 'evening', medId: 'dutasteride', qty: 1, scheduled: '21:00', status: 'upcoming' },
{ id: 't5', regimen: 'evening', medId: 'progesterone', qty: 1, scheduled: '21:00', status: 'upcoming' },
{ id: 't6', regimen: 'evening', medId: 'vitamin-a', qty: 1, scheduled: '21:00', status: 'upcoming' },
{ id: 't7', regimen: 'evening', medId: 'magnesium', qty: 1, scheduled: '21:00', status: 'upcoming' },
{ id: 't8', regimen: 'evening', medId: 'vitamin-d', qty: 1, scheduled: '21:00', status: 'upcoming' },
];
// Stores
const STORES = [
{ id: 'osakado', name: 'Osakado', url: 'osakado.clinic', tags: ['pharmacy', 'online'], currency: 'JPY', active: true },
{ id: 'inhouse', name: 'In-House Pharmacy', url: 'local', tags: ['pharmacy'], currency: 'JPY', active: true },
{ id: 'amazon-jp', name: 'Amazon JP', url: 'amazon.co.jp', tags: ['online', 'bulk'], currency: 'JPY', active: true },
{ id: 'matsumoto', name: 'Matsumoto Kiyoshi', url: 'matsukiyo.co.jp', tags: ['pharmacy', 'grocery'], currency: 'JPY', active: true },
];
// Prices multiple stores per med where it makes sense
const PRICES = [
{ medId: 'semaglutide', store: 'osakado', price: 24862, pkg: 30, unit: 'tablet', date: '2026-03-29' },
{ medId: 'semaglutide', store: 'inhouse', price: 31200, pkg: 30, unit: 'tablet', date: '2026-02-10' },
{ medId: 'tadalafil', store: 'osakado', price: 4537, pkg: 30, unit: 'tablet', date: '2026-04-02' },
{ medId: 'tadalafil', store: 'amazon-jp', price: 5180, pkg: 30, unit: 'tablet', date: '2026-03-15' },
{ medId: 'spironolactone', store: 'osakado', price: 2190, pkg: 100, unit: 'tablet', date: '2026-03-01' },
{ medId: 'dutasteride', store: 'osakado', price: 3860, pkg: 30, unit: 'tablet', date: '2026-03-20' },
{ medId: 'estradiol', store: 'osakado', price: 9800, pkg: 5, unit: 'vial', date: '2026-02-28' },
{ medId: 'progesterone', store: 'osakado', price: 2980, pkg: 30, unit: 'capsule', date: '2026-03-05' },
{ medId: 'magnesium', store: 'amazon-jp', price: 1480, pkg: 120, unit: 'capsule', date: '2026-01-22' },
{ medId: 'magnesium', store: 'matsumoto', price: 1680, pkg: 120, unit: 'capsule', date: '2026-02-14' },
{ medId: 'vitamin-d', store: 'amazon-jp', price: 890, pkg: 100, unit: 'capsule', date: '2026-02-10' },
{ medId: 'vitamin-a', store: 'amazon-jp', price: 1240, pkg: 250, unit: 'capsule', date: '2026-01-08' },
];
// Purchases
const PURCHASES = [
{ id: 'p1', store: 'osakado', date: '2026-04-02', status: 'pending', items: [{ medId: 'tadalafil', qty: 28, unit: 'tablet', price: 4537 }] },
{ id: 'p2', store: 'osakado', date: '2026-04-02', status: 'received', items: [{ medId: 'semaglutide', qty: 30, unit: 'tablet', price: 24862 }] },
{ id: 'p3', store: 'amazon-jp', date: '2026-03-22', status: 'received', items: [{ medId: 'magnesium', qty: 120, unit: 'capsule', price: 1480 }, { medId: 'vitamin-d', qty: 100, unit: 'capsule', price: 890 }] },
{ id: 'p4', store: 'osakado', date: '2026-03-20', status: 'received', items: [{ medId: 'dutasteride', qty: 30, unit: 'tablet', price: 3860 }, { medId: 'spironolactone', qty: 100, unit: 'tablet', price: 2190 }] },
{ id: 'p5', store: 'osakado', date: '2026-02-28', status: 'received', items: [{ medId: 'estradiol', qty: 5, unit: 'vial', price: 9800 }] },
];
// Cabinet activity events (consumed / adjusted / added)
const ACTIVITY = [
{ id: 'a1', type: 'consumed', medId: 'vitamin-d', delta: -7, before: 103, after: 96, at: '2026-04-03 08:52' },
{ id: 'a2', type: 'consumed', medId: 'magnesium', delta: -7, before: 122, after: 115, at: '2026-04-03 08:52' },
{ id: 'a3', type: 'adjusted', medId: 'estradiol', delta: -1, before: 3, after: 2, at: '2026-04-03 08:57' },
{ id: 'a4', type: 'consumed', medId: 'vitamin-a', delta: -7, before: 218, after: 211, at: '2026-04-03 08:52' },
{ id: 'a5', type: 'consumed', medId: 'spironolactone', delta: -14, before: 76, after: 62, at: '2026-04-03 08:52' },
{ id: 'a6', type: 'added', medId: 'tadalafil', delta: +30, before: -23, after: 7, at: '2026-04-02 14:10', ref: 'Purchase p2' },
{ id: 'a7', type: 'consumed', medId: 'semaglutide', delta: -1, before: 27, after: 26, at: '2026-04-01 08:15' },
{ id: 'a8', type: 'consumed', medId: 'progesterone', delta: -1, before: 36, after: 35, at: '2026-04-03 21:04' },
];
// Monthly spending (last 6 months), JPY
const SPENDING = [
{ month: '2025-11', amount: 28400 },
{ month: '2025-12', amount: 41200 },
{ month: '2026-01', amount: 19600 },
{ month: '2026-02', amount: 38480 },
{ month: '2026-03', amount: 35780 },
{ month: '2026-04', amount: 9937 },
];
window.MESHI = {
MEDICINES, MED, CABINET, REGIMENS, TODAY_INTAKE,
STORES, PRICES, PURCHASES, ACTIVITY, SPENDING,
};

View file

@ -0,0 +1,473 @@
/* Cabinet — hero screen */
.cab-summary {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
border-radius: var(--r-md);
overflow: hidden;
}
.cab-summary__item {
background: var(--bg-elev);
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 3px;
}
.cab-summary__label {
font-size: 11px;
color: var(--ink-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 500;
}
.cab-summary__value {
font-size: 26px;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--ink-strong);
line-height: 1.15;
}
.cab-summary__item--danger .cab-summary__value { color: var(--danger); }
.cab-summary__item--warn .cab-summary__value { color: var(--warn); }
.cab-summary__item--brand .cab-summary__value { color: var(--brand); }
.cab-summary__hint { font-size: 11px; color: var(--ink-faint); margin-top: 2px; }
/* Filters */
.cab-filters {
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.cab-filters__tabs {
display: flex;
gap: 4px;
background: var(--bg-elev);
padding: 4px;
border-radius: var(--r-sm);
border: 1px solid var(--border);
}
.cab-filters__tab {
padding: 6px 12px;
font-size: 12px;
color: var(--ink-muted);
border-radius: 6px;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.12s;
}
.cab-filters__tab:hover { color: var(--ink); }
.cab-filters__tab.is-active {
background: var(--ink);
color: var(--bg-elev);
font-weight: 500;
}
.cab-filters__count {
font-size: 10px;
padding: 1px 5px;
border-radius: 6px;
background: var(--bg-inset);
color: var(--ink-muted);
}
.cab-filters__tab.is-active .cab-filters__count {
background: rgba(255,255,255,0.12);
color: var(--bg-elev);
}
.cab-filters__tools {
display: flex;
gap: 8px;
align-items: center;
}
.cab-filters__search {
display: flex;
align-items: center;
gap: 6px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 6px 10px;
color: var(--ink-muted);
}
.cab-filters__search input {
border: 0; outline: 0; background: transparent; width: 120px; font-size: 12px;
}
.cab-filters__search input::placeholder { color: var(--ink-faint); }
/* Grid */
.cab-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 14px;
}
.cab-card {
text-align: left;
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--r-md);
transition: all 0.15s;
position: relative;
overflow: hidden;
}
.cab-card:hover {
border-color: var(--border-strong);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.cab-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 3px;
background: var(--viz-1);
opacity: 0;
transition: opacity 0.15s;
}
.cab-card:hover::before { opacity: 0.6; }
.cab-card--lvl-low::before, .cab-card--lvl-critical::before {
background: var(--danger); opacity: 1;
}
.cab-card--lvl-expiring::before { background: var(--warn); opacity: 1; }
.cab-card__top {
display: flex;
align-items: center;
gap: 10px;
}
.cab-card__swatch {
width: 34px; height: 34px;
border-radius: var(--r-sm);
display: grid; place-items: center;
background: color-mix(in oklab, var(--c) 16%, var(--bg-elev));
color: var(--c);
flex-shrink: 0;
}
.cab-card__swatch--sm { width: 22px; height: 22px; border-radius: 6px; }
.cab-card__meta { flex: 1; min-width: 0; }
.cab-card__name {
font-size: 14px;
font-weight: 500;
color: var(--ink-strong);
letter-spacing: -0.01em;
}
.cab-card__strength {
font-size: 11px;
color: var(--ink-muted);
margin-top: 1px;
}
.cab-card__flag { flex-shrink: 0; }
.cab-card__qty {
display: flex;
align-items: baseline;
gap: 6px;
}
.cab-card__qtyNum {
font-size: 26px;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--ink-strong);
line-height: 1;
}
.cab-card__qtyUnit {
font-size: 12px;
color: var(--ink-muted);
}
.cab-card__foot {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
padding-top: 12px;
border-top: 1px dashed var(--border);
}
.cab-card__footLabel {
font-size: 10px;
color: var(--ink-faint);
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 500;
}
.cab-card__footValue {
font-size: 12px;
color: var(--ink);
font-weight: 500;
margin-top: 2px;
}
.cab-card__footValue.is-warn { color: var(--warn); }
/* Days bar */
.daysbar {
display: flex;
align-items: center;
gap: 10px;
}
.daysbar__track {
flex: 1;
height: 6px;
background: var(--bg-inset);
border-radius: 3px;
position: relative;
overflow: hidden;
}
.daysbar__fill {
height: 100%;
background: var(--ok);
border-radius: 3px;
transition: width 0.3s;
}
.daysbar--low .daysbar__fill { background: var(--warn); }
.daysbar--critical .daysbar__fill { background: var(--danger); }
.daysbar__mark {
position: absolute;
top: -2px; bottom: -2px;
width: 1px;
background: var(--ink-faint);
opacity: 0.5;
}
.daysbar__label {
font-size: 11px;
display: flex;
align-items: baseline;
gap: 3px;
min-width: 50px;
justify-content: flex-end;
}
.daysbar__num {
font-weight: 600;
color: var(--ink);
font-size: 13px;
}
.daysbar__unit { color: var(--ink-muted); font-size: 10px; }
.daysbar--low .daysbar__num { color: var(--warn); }
.daysbar--critical .daysbar__num { color: var(--danger); }
/* Table view */
.cab-table {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--r-md);
overflow: hidden;
}
.cab-table__head, .cab-table__row {
display: grid;
grid-template-columns: 2fr 0.7fr 1.4fr 1fr 0.8fr 0.8fr;
gap: 14px;
padding: 10px 18px;
align-items: center;
font-size: 12px;
text-align: left;
width: 100%;
}
.cab-table__head {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-faint);
background: var(--bg-inset);
border-bottom: 1px solid var(--border);
font-weight: 600;
}
.cab-table__row {
border-bottom: 1px solid var(--border);
transition: background 0.1s;
}
.cab-table__row:last-child { border-bottom: 0; }
.cab-table__row:hover { background: var(--bg-inset); }
.cab-table__cellMed {
display: flex; align-items: center; gap: 10px;
}
/* Shelf view */
.cab-shelf {
display: flex;
flex-direction: column;
gap: 40px;
padding: 20px 0 40px;
}
.cab-shelf__level {
position: relative;
}
.cab-shelf__tag {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-faint);
font-weight: 600;
margin-bottom: 12px;
}
.cab-shelf__bottles {
display: flex;
gap: 20px;
align-items: flex-end;
min-height: 160px;
padding: 0 4px;
}
.cab-shelf__line {
height: 3px;
background: linear-gradient(180deg, var(--border-strong), var(--border));
border-radius: 1px;
margin-top: 8px;
box-shadow: 0 1px 0 var(--bg-inset);
}
.cab-bottle {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
transition: transform 0.15s;
width: 80px;
}
.cab-bottle:hover { transform: translateY(-3px); }
.cab-bottle__body {
width: 46px;
height: 110px;
background: color-mix(in oklab, var(--c) 18%, var(--bg-elev));
border: 1.5px solid color-mix(in oklab, var(--c) 50%, var(--bg-elev));
border-radius: 8px 8px 6px 6px;
position: relative;
overflow: hidden;
}
.cab-bottle__fill {
position: absolute;
left: 0; right: 0; bottom: 0;
background: var(--c);
transition: height 0.4s;
}
.cab-bottle__cap {
position: absolute;
top: -6px; left: 50%; transform: translateX(-50%);
width: 28px; height: 8px;
background: var(--ink-muted);
border-radius: 3px 3px 2px 2px;
}
.cab-bottle--lvl-low .cab-bottle__body, .cab-bottle--lvl-critical .cab-bottle__body {
border-color: var(--danger);
}
.cab-bottle__label { text-align: center; }
.cab-bottle__name {
font-size: 11px;
font-weight: 500;
color: var(--ink);
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cab-bottle__qty { font-size: 10px; color: var(--ink-muted); }
/* Detail drawer */
.cab-detail {
position: fixed;
inset: 0;
background: rgba(20, 18, 10, 0.3);
z-index: 50;
display: flex;
justify-content: flex-end;
animation: fade-in 0.2s;
}
@keyframes fade-in { from { opacity: 0; } }
.cab-detail__panel {
width: min(520px, 100%);
background: var(--bg-elev);
border-left: 1px solid var(--border);
box-shadow: var(--shadow-lg);
overflow-y: auto;
animation: slide-in 0.2s;
padding: 24px 28px 32px;
}
@keyframes slide-in { from { transform: translateX(40px); opacity: 0; } }
.cab-detail__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.cab-detail__title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 500;
letter-spacing: -0.02em;
color: var(--ink-strong);
}
.cab-detail__sub {
font-size: 12px;
color: var(--ink-muted);
margin-top: 2px;
}
.cab-detail__hero {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
padding: 16px;
background: var(--bg-inset);
border-radius: var(--r-md);
margin-bottom: 16px;
}
.cab-detail__bigLabel {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-faint);
font-weight: 600;
margin-bottom: 4px;
}
.cab-detail__big {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--ink-strong);
line-height: 1.1;
}
.cab-detail__big span {
font-size: 11px;
color: var(--ink-muted);
font-weight: 400;
font-family: var(--font-sans);
margin-left: 2px;
}
.cab-detail__big.is-warn { color: var(--warn); }
.cab-detail__actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.cab-detail__section { margin-top: 20px; }
.cab-detail__sectionTitle {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-muted);
font-weight: 600;
margin-bottom: 10px;
}
.cab-detail__prices {
display: flex;
flex-direction: column;
gap: 6px;
}
.cab-detail__price {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--r-sm);
background: var(--bg-elev);
}
.cab-detail__price.is-best {
border-color: var(--ok);
background: var(--ok-soft);
}
.cab-detail__priceStore { flex: 1; font-size: 13px; }
.cab-detail__priceBody { text-align: right; }

View file

@ -0,0 +1,152 @@
/* Dashboard */
.dash-hero {
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
padding: 4px 4px 24px;
border-bottom: 1px solid var(--border);
margin-bottom: 20px;
}
.dash-hero__day {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-muted);
font-weight: 500;
margin-bottom: 6px;
}
.dash-hero__title {
font-family: var(--font-display);
font-size: 34px;
font-weight: 400;
letter-spacing: -0.02em;
color: var(--ink-strong);
line-height: 1.05;
}
.dash-hero__sub {
font-size: 14px;
color: var(--ink-muted);
margin-top: 8px;
max-width: 520px;
}
.dash-grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 16px;
}
.dash-panel { overflow: hidden; }
/* Schedule */
.dash-schedule { padding: 4px 18px 18px; }
.dash-sched__slot {
display: grid;
grid-template-columns: 80px 1fr;
gap: 16px;
padding: 14px 0;
border-bottom: 1px dashed var(--border);
align-items: flex-start;
}
.dash-sched__slot:last-child { border-bottom: 0; }
.dash-sched__slot.is-done { opacity: 0.55; }
.dash-sched__time { padding-top: 4px; }
.dash-sched__items {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.dash-sched__pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px 6px 8px;
background: var(--bg-inset);
border: 1px solid var(--border);
border-radius: 999px;
font-size: 12px;
transition: all 0.12s;
}
.dash-sched__pill:hover { border-color: var(--border-strong); }
.dash-sched__pill.is-taken { background: var(--ok-soft); border-color: transparent; }
.dash-sched__pill.is-taken .dash-sched__name { text-decoration: line-through; color: var(--ink-muted); }
.dash-sched__check {
width: 16px; height: 16px;
border-radius: 50%;
border: 1.5px solid var(--border-strong);
display: grid; place-items: center;
color: transparent;
transition: all 0.15s;
}
.dash-sched__check.is-taken { background: var(--ok); border-color: var(--ok); color: white; }
.dash-sched__name { font-weight: 500; color: var(--ink-strong); }
.dash-sched__dose { color: var(--ink-muted); font-size: 11px; }
/* Low */
.dash-low { padding: 4px 18px 16px; display: flex; flex-direction: column; gap: 4px; }
.dash-low__item {
display: flex;
gap: 10px;
align-items: center;
padding: 8px 0;
border-bottom: 1px dashed var(--border);
}
.dash-low__item:last-child { border-bottom: 0; }
/* Spend */
.dash-spend { padding: 12px 18px 18px; }
/* Supply */
.dash-supply { padding: 4px 18px 16px; display: flex; flex-direction: column; gap: 6px; }
.dash-supply__row {
display: grid;
grid-template-columns: 140px 1fr 48px;
gap: 12px;
align-items: center;
font-size: 12px;
padding: 4px 0;
}
.dash-supply__name { font-weight: 500; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dash-supply__bar {
height: 8px;
background: var(--bg-inset);
border-radius: 2px;
overflow: hidden;
position: relative;
}
.dash-supply__fill { height: 100%; border-radius: 2px; transition: width 0.3s; }
.dash-supply__fill--ok { background: var(--brand); }
.dash-supply__fill--low { background: var(--warn); }
.dash-supply__fill--critical { background: var(--danger); }
.dash-supply__days { font-weight: 600; text-align: right; }
.dash-supply__days span { color: var(--ink-faint); font-weight: 400; margin-left: 1px; font-size: 10px; }
/* Orders */
.dash-orders { padding: 4px 18px 16px; display: flex; flex-direction: column; gap: 4px; }
.dash-order {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
border-bottom: 1px dashed var(--border);
}
.dash-order:last-child { border-bottom: 0; }
.dash-order__icon {
width: 28px; height: 28px;
border-radius: 50%;
background: var(--brand-soft);
color: var(--brand-soft-ink);
display: grid; place-items: center;
}
.dash-order.is-past .dash-order__icon { background: var(--bg-inset); color: var(--ink-muted); }
/* Activity */
.dash-activity { padding: 4px 18px 16px; }
.dash-act {
display: flex;
gap: 10px;
align-items: center;
padding: 8px 0;
border-bottom: 1px dashed var(--border);
}
.dash-act:last-child { border-bottom: 0; }

View file

@ -0,0 +1,199 @@
/* Other pages */
.sub-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 12px; }
.form-label { display:block; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-muted); font-weight: 600; margin-bottom: 6px; }
.form-select {
padding: 8px 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--r-sm);
font-size: 13px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.lib-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
}
.lib-row:last-child { border-bottom: 0; }
.lib-row:hover { background: var(--bg-inset); }
.org-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 10px; }
.org-day { padding: 14px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--r-md); }
.org-day__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; }
.org-day__slots { display: flex; flex-wrap: wrap; gap: 4px; min-height: 70px; }
.org-pill {
width: 14px; height: 20px; background: var(--c); border-radius: 7px;
box-shadow: inset 0 -3px 0 rgba(0,0,0,0.1), inset 0 3px 0 rgba(255,255,255,0.2);
display: inline-block;
}
.org-day__count { font-size: 10px; color: var(--ink-faint); text-transform: uppercase; letter-spacing: .06em; font-weight:600; margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--border); }
/* Organizer — fill + inventory impact */
.org-hero {
display: grid;
grid-template-columns: 1fr 1.3fr;
gap: 0;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--r-md);
overflow: hidden;
}
.org-hero__controls {
padding: 20px 22px;
background: var(--bg-inset);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 18px;
}
.org-hero__divider { height: 1px; background: var(--border); }
.org-hero__select {
display: block;
margin-top: 6px;
padding: 8px 12px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--r-sm);
font-size: 14px;
font-weight: 500;
color: var(--ink-strong);
width: 100%;
font-family: inherit;
}
.org-hero__stepper {
display: inline-flex;
align-items: center;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--r-sm);
overflow: hidden;
}
.org-hero__stepper button {
width: 32px; height: 36px;
border: none; background: transparent;
font-size: 16px; color: var(--ink-strong);
cursor: pointer;
}
.org-hero__stepper button:hover { background: var(--bg-inset); }
.org-hero__stepper span {
padding: 0 14px; font-size: 20px; font-weight: 600; min-width: 48px; text-align: center;
color: var(--ink-strong);
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
height: 36px;
line-height: 36px;
}
.org-hero__presets { display: flex; gap: 4px; margin-top: 8px; }
.org-hero__preset {
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 999px;
background: transparent;
font-size: 11px;
color: var(--ink-muted);
cursor: pointer;
font-family: inherit;
}
.org-hero__preset.is-active { background: var(--ink); color: var(--bg-elev); border-color: var(--ink); }
.org-hero__summary {
padding: 22px;
display: flex;
flex-direction: column;
gap: 16px;
}
.org-alert {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: var(--r-sm);
font-size: 13px;
}
.org-alert strong { font-weight: 600; color: var(--ink-strong); }
.org-alert > div:first-of-type { flex: 1; }
.org-alert--ok { background: var(--ok-soft); color: var(--ok); }
.org-alert--ok strong { color: var(--ok); }
.org-alert--warn { background: var(--warn-soft); color: var(--warn); }
.org-alert--warn strong { color: var(--warn); }
.org-alert--danger { background: var(--danger-soft); color: var(--danger); }
.org-alert--danger strong { color: var(--danger); }
.org-impact { padding: 4px 18px 16px; }
.org-impact__head, .org-impact__row {
display: grid;
grid-template-columns: 2.4fr 0.8fr 0.8fr 1.6fr 0.8fr;
gap: 14px;
padding: 10px 8px;
font-size: 13px;
align-items: center;
}
.org-impact__head {
font-size: 10px; text-transform: uppercase; letter-spacing: .06em;
color: var(--ink-faint); font-weight: 600;
border-bottom: 1px solid var(--border);
}
.org-impact__row { border-bottom: 1px dashed var(--border); }
.org-impact__row:last-child { border-bottom: 0; }
.org-impact__row--short { background: color-mix(in oklab, var(--danger) 5%, transparent); }
.org-impact__bar {
flex: 1;
position: relative;
height: 6px;
border-radius: 3px;
overflow: hidden;
background: var(--bg-inset);
}
.org-impact__barFill {
position: absolute; top: 0; left: 0; bottom: 0;
border-radius: 3px;
transition: width .2s;
}
.prices-table { padding: 0 18px 16px; }
.prices-table__head, .prices-table__row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr;
gap: 14px;
padding: 10px 8px;
font-size: 13px;
align-items: center;
}
.prices-table__head {
font-size: 10px; text-transform: uppercase; letter-spacing: .06em;
color: var(--ink-faint); font-weight: 600;
border-bottom: 1px solid var(--border);
}
.prices-table__row { border-bottom: 1px dashed var(--border); }
.prices-table__row:last-child { border-bottom: 0; }
.prices-table__row.is-best { background: var(--ok-soft); border-radius: 8px; margin: 2px 0; border-bottom: 0; }
.refill-row {
display: flex; align-items: center; gap: 12px;
padding: 12px 0; border-bottom: 1px dashed var(--border);
}
.refill-row:last-child { border-bottom: 0; }
.act-row {
display: flex; align-items: center; gap: 10px;
padding: 10px 0;
position: relative;
}
.act-rail { width: 12px; position: relative; align-self: stretch; }
.act-dot { position: absolute; top: 14px; left: 2px; width: 8px; height: 8px; border-radius: 50%; }
.act-dot--consumed { background: var(--info); }
.act-dot--added { background: var(--ok); }
.act-dot--adjusted { background: var(--warn); }
.settings-row {
display: grid; grid-template-columns: 120px 1fr auto;
gap: 16px; align-items: center;
padding: 10px 0; border-bottom: 1px dashed var(--border);
}
.settings-row:last-child { border-bottom: 0; }
.settings-row > span:first-child { color: var(--ink-muted); font-size: 12px; }

View file

@ -0,0 +1,181 @@
/* Schedule page — slot-based */
/* Week bar: compact adherence heatmap */
.sched-weekbar {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 12px 16px;
display: flex;
align-items: center;
gap: 20px;
}
.sched-weekbar__days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
flex: 1;
}
.sched-weekday {
padding: 8px 6px;
border-radius: var(--r-sm);
border: 1px solid transparent;
background: transparent;
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
gap: 2px 8px;
align-items: center;
transition: background .12s, border-color .12s;
text-align: left;
cursor: pointer;
}
.sched-weekday:hover { background: var(--bg-inset); }
.sched-weekday.is-active { background: var(--bg-inset); border-color: var(--border-strong); }
.sched-weekday__label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--ink-faint);
font-weight: 600;
grid-row: 1;
}
.sched-weekday__num {
font-size: 14px;
font-weight: 600;
color: var(--ink-strong);
grid-row: 2;
}
.sched-weekday__bar {
grid-column: 2;
grid-row: 1 / 3;
width: 5px;
height: 28px;
background: var(--bg-inset);
border-radius: 2px;
position: relative;
overflow: hidden;
}
.sched-weekday__fill {
position: absolute;
bottom: 0;
left: 0;
right: 0;
border-radius: 2px;
transition: height .2s;
}
/* Today: slot rows */
.sched-slots {
padding: 4px 18px 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.sched-slot {
border: 1px solid var(--border);
border-radius: var(--r-md);
background: var(--bg-elev);
overflow: hidden;
transition: border-color .12s;
}
.sched-slot:hover { border-color: var(--border-strong); }
.sched-slot.is-open { border-color: var(--border-strong); }
.sched-slot--done { background: color-mix(in oklab, var(--ok) 4%, var(--bg-elev)); }
.sched-slot--overdue { border-color: color-mix(in oklab, var(--warn) 40%, var(--border)); }
.sched-slot__row {
width: 100%;
display: grid;
grid-template-columns: 100px 1fr auto;
align-items: center;
gap: 16px;
padding: 14px 16px;
background: transparent;
border: none;
text-align: left;
cursor: pointer;
}
.sched-slot__time { display: flex; flex-direction: column; gap: 2px; }
.sched-slot__hour {
font-size: 20px;
font-weight: 600;
color: var(--ink-strong);
letter-spacing: -0.01em;
line-height: 1;
}
.sched-slot__phase {
font-size: 11px;
color: var(--ink-muted);
text-transform: uppercase;
letter-spacing: .06em;
font-weight: 500;
}
.sched-slot__main {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.sched-slot__summary {
display: flex;
align-items: center;
gap: 10px;
}
.sched-slot__dots {
display: flex;
gap: 3px;
margin-left: 4px;
}
.sched-slot__dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1.5px solid;
background: transparent;
transition: all .15s;
}
.sched-slot__actions {
display: flex;
align-items: center;
gap: 8px;
}
.sched-slot__detail {
border-top: 1px solid var(--border);
padding: 10px 16px 14px 116px;
display: flex;
flex-direction: column;
gap: 4px;
background: var(--bg-inset);
}
.sched-slot__item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--r-sm);
transition: all 0.12s;
text-align: left;
width: 100%;
cursor: pointer;
}
.sched-slot__item:hover { border-color: var(--border-strong); }
.sched-slot__item.is-taken { opacity: 0.65; }
.sched-check {
width: 18px; height: 18px; border-radius: 50%;
border: 1.5px solid var(--border-strong);
display: grid; place-items: center;
color: transparent;
flex-shrink: 0;
}
.sched-check.is-taken { background: var(--ok); border-color: var(--ok); color: white; }
.sched-regimen {
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--r-sm);
margin-bottom: 8px;
}

View file

@ -0,0 +1,370 @@
/* Shell: sidebar + topbar + layout */
.mt-app {
display: grid;
grid-template-columns: 248px 1fr;
min-height: 100vh;
background: var(--bg);
}
/* Sidebar */
.mt-side {
background: var(--bg-elev);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
}
.mt-side__brand {
display: flex;
gap: 10px;
align-items: center;
padding: 18px 18px 14px;
border-bottom: 1px solid var(--border);
}
.mt-side__logo {
width: 34px; height: 34px;
border-radius: 10px;
display: grid; place-items: center;
background: var(--brand-soft);
}
.mt-side__title {
font-family: var(--font-display);
font-size: 17px;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--ink-strong);
}
.mt-side__sub {
font-size: 11px;
color: var(--ink-muted);
font-variant-numeric: tabular-nums;
}
.mt-side__nav {
flex: 1;
overflow-y: auto;
padding: 10px 10px 16px;
}
.mt-side__group { margin-bottom: 14px; }
.mt-side__section {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-faint);
padding: 10px 10px 6px;
}
.mt-side__item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 7px 10px;
border-radius: var(--r-sm);
font-size: 13px;
color: var(--ink-muted);
text-align: left;
transition: background 0.1s, color 0.1s;
white-space: nowrap;
}
.mt-side__item > span:not(.mt-side__badge) { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mt-side__item { min-width: 0; }
.mt-side__item:hover { background: var(--bg-inset); color: var(--ink); }
.mt-side__item.is-active {
background: var(--brand-soft);
color: var(--brand-soft-ink);
font-weight: 500;
}
.mt-side__item svg { opacity: 0.8; }
.mt-side__item.is-active svg { opacity: 1; }
.mt-side__soon {
margin-left: auto;
font-size: 9px;
font-weight: 600;
color: var(--ink-faint);
background: var(--bg-inset);
padding: 1px 6px;
border-radius: 8px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.mt-side__item.is-soon { opacity: 0.55; cursor: not-allowed; }
.mt-side__item.is-soon:hover { background: transparent; color: var(--ink-muted); }
.mt-side__badge {
margin-left: auto;
background: var(--danger-soft);
color: var(--danger);
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 8px;
min-width: 18px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.mt-side__foot {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-top: 1px solid var(--border);
}
.mt-side__avatar {
width: 32px; height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, var(--viz-4), var(--viz-3));
color: white;
display: grid; place-items: center;
font-weight: 600;
font-size: 13px;
}
.mt-side__whoName { font-size: 13px; font-weight: 500; color: var(--ink-strong); }
.mt-side__whoRole { font-size: 11px; color: var(--ink-muted); }
/* Topbar */
.mt-main { display: flex; flex-direction: column; min-width: 0; }
.mt-top {
display: flex;
align-items: flex-end;
justify-content: space-between;
padding: 22px 32px 18px;
border-bottom: 1px solid var(--border);
background: var(--bg);
position: sticky;
top: 0;
z-index: 5;
backdrop-filter: blur(8px);
}
.mt-top__crumbs {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--ink-muted);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 500;
}
.mt-top__crumbs .is-current { color: var(--ink); }
.mt-top__title {
font-family: var(--font-display);
font-size: 28px;
font-weight: 500;
letter-spacing: -0.02em;
color: var(--ink-strong);
margin: 0;
line-height: 1.1;
}
.mt-top__sub {
font-size: 13px;
color: var(--ink-muted);
margin-top: 4px;
}
.mt-top__right {
display: flex;
align-items: center;
gap: 10px;
}
.mt-top__search {
position: relative;
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 6px 10px;
min-width: 280px;
color: var(--ink-muted);
transition: border-color 0.15s;
}
.mt-top__search:focus-within { border-color: var(--brand); }
.mt-top__search input {
border: 0; outline: 0; background: transparent;
font-size: 13px; flex: 1; color: var(--ink);
}
.mt-top__search input::placeholder { color: var(--ink-faint); }
.mt-top__search kbd {
font-family: var(--font-mono);
font-size: 10px;
padding: 2px 5px;
background: var(--bg-inset);
border-radius: 4px;
border: 1px solid var(--border);
color: var(--ink-muted);
}
.mt-iconbtn {
width: 32px; height: 32px;
border-radius: var(--r-sm);
display: grid; place-items: center;
background: var(--bg-elev);
border: 1px solid var(--border);
color: var(--ink-muted);
position: relative;
transition: all 0.1s;
}
.mt-iconbtn:hover { color: var(--ink); border-color: var(--border-strong); }
.mt-iconbtn__dot {
position: absolute;
top: 6px; right: 6px;
width: 6px; height: 6px;
border-radius: 50%;
background: var(--danger);
border: 1.5px solid var(--bg-elev);
}
/* Page container */
.mt-page {
padding: 28px 32px 56px;
max-width: 1400px;
width: 100%;
}
/* Buttons */
.mt-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: var(--r-sm);
font-size: 13px;
font-weight: 500;
transition: all 0.12s;
border: 1px solid transparent;
line-height: 1;
white-space: nowrap;
}
.mt-btn--primary {
background: var(--brand);
color: var(--brand-ink);
}
.mt-btn--primary:hover { background: var(--brand-deep); }
.mt-btn--ghost {
background: var(--bg-elev);
color: var(--ink);
border-color: var(--border);
}
.mt-btn--ghost:hover { border-color: var(--border-strong); background: var(--bg-inset); }
.mt-btn--subtle {
color: var(--ink-muted);
padding: 6px 10px;
}
.mt-btn--subtle:hover { color: var(--ink); background: var(--bg-inset); }
.mt-btn--danger {
color: var(--danger);
border-color: var(--danger-soft);
background: var(--bg-elev);
}
.mt-btn--danger:hover { background: var(--danger-soft); }
/* Card */
.mt-card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--r-md);
}
.mt-card__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px 14px;
border-bottom: 1px solid var(--border);
}
.mt-card__title {
font-family: var(--font-display);
font-size: 16px;
font-weight: 500;
color: var(--ink-strong);
letter-spacing: -0.01em;
}
.mt-card__sub {
font-size: 12px;
color: var(--ink-muted);
margin-top: 2px;
}
.mt-card__body { padding: 16px 18px; }
/* Pill / badge */
.mt-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
line-height: 1.6;
letter-spacing: 0.01em;
border: 1px solid transparent;
}
.mt-pill--brand { background: var(--brand-soft); color: var(--brand-soft-ink); }
.mt-pill--ok { background: var(--ok-soft); color: var(--ok); }
.mt-pill--warn { background: var(--warn-soft); color: var(--warn); }
.mt-pill--danger { background: var(--danger-soft); color: var(--danger); }
.mt-pill--info { background: var(--info-soft); color: var(--info); }
.mt-pill--ghost { background: var(--bg-inset); color: var(--ink-muted); }
.mt-pill--outline { border-color: var(--border); color: var(--ink-muted); }
/* Utility */
.mt-row { display: flex; gap: 12px; align-items: center; }
.mt-col { display: flex; flex-direction: column; gap: 12px; }
.mt-grid { display: grid; gap: 12px; }
.mt-divider { height: 1px; background: var(--border); margin: 12px 0; }
.mt-stack > * + * { margin-top: 12px; }
.mt-mono { font-family: var(--font-mono); font-size: 12px; }
/* Tweaks panel */
.mt-tweaks {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 100;
background: var(--bg-elev);
border: 1px solid var(--border-strong);
border-radius: var(--r-md);
box-shadow: var(--shadow-lg);
padding: 14px;
min-width: 240px;
display: none;
}
.mt-tweaks.is-open { display: block; }
.mt-tweaks__title {
font-family: var(--font-display);
font-size: 13px;
font-weight: 500;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.mt-tweaks__row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-top: 1px solid var(--border);
font-size: 12px;
}
.mt-tweaks__row:first-of-type { border-top: 0; }
.mt-seg {
display: inline-flex;
background: var(--bg-inset);
border-radius: 6px;
padding: 2px;
gap: 0;
}
.mt-seg button {
padding: 4px 10px;
border-radius: 4px;
font-size: 11px;
color: var(--ink-muted);
}
.mt-seg button.is-active {
background: var(--bg-elev);
color: var(--ink);
box-shadow: var(--shadow-sm);
}

View file

@ -0,0 +1,123 @@
/* MeshiTrack design tokens */
:root {
/* Type */
--font-sans: 'Inter Tight', -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
--font-display: 'Fraunces', 'Inter Tight', serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
/* Neutrals (warm off-white → near-black) */
--bg: #f6f4ef;
--bg-elev: #ffffff;
--bg-inset: #efebe3;
--border: #e4dfd4;
--border-strong: #cfc8b8;
--ink-faint: #a39b89;
--ink-muted: #6e6759;
--ink: #1d1b17;
--ink-strong: #0b0a08;
/* Brand — refined sage-leaning green (was straight green) */
--brand: #2f6b4a;
--brand-deep: #1e4a32;
--brand-ink: #ffffff;
--brand-soft: #e6efe8;
--brand-soft-ink: #1e4a32;
/* Status */
--danger: #b8361a;
--danger-soft: #fbe8e0;
--warn: #a66a0a;
--warn-soft: #f9ecd2;
--ok: #3e7a4c;
--ok-soft: #e3ede0;
--info: #3a5a85;
--info-soft: #e1e8f1;
/* Data viz (cabinet categories / charts) */
--viz-1: #2f6b4a;
--viz-2: #a66a0a;
--viz-3: #3a5a85;
--viz-4: #8a4c6e;
--viz-5: #6b6237;
--viz-6: #7a3a28;
/* Shape */
--r-xs: 6px;
--r-sm: 10px;
--r-md: 14px;
--r-lg: 20px;
--r-xl: 28px;
/* Shadow */
--shadow-sm: 0 1px 2px rgba(20, 18, 10, 0.04), 0 0 0 1px rgba(20, 18, 10, 0.04);
--shadow-md: 0 4px 16px -6px rgba(20, 18, 10, 0.08), 0 0 0 1px rgba(20, 18, 10, 0.05);
--shadow-lg: 0 20px 40px -20px rgba(20, 18, 10, 0.22), 0 0 0 1px rgba(20, 18, 10, 0.06);
}
[data-theme='dark'] {
--bg: #141310;
--bg-elev: #1c1b17;
--bg-inset: #100f0c;
--border: #2a2823;
--border-strong: #3a372f;
--ink-faint: #6a6559;
--ink-muted: #9a9386;
--ink: #ece8de;
--ink-strong: #f8f5ec;
--brand: #5fa87b;
--brand-deep: #8fc9a4;
--brand-ink: #0b0a08;
--brand-soft: #1e2c23;
--brand-soft-ink: #8fc9a4;
--danger: #e37358;
--danger-soft: #2c1a15;
--warn: #d6a152;
--warn-soft: #2c2217;
--ok: #7aba89;
--ok-soft: #1a2820;
--info: #7ea3cf;
--info-soft: #1a2028;
--viz-1: #5fa87b;
--viz-2: #d6a152;
--viz-3: #7ea3cf;
--viz-4: #c088a5;
--viz-5: #b8ac76;
--viz-6: #d88a74;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.04);
--shadow-md: 0 4px 16px -6px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05);
--shadow-lg: 0 20px 40px -20px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.06);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: var(--font-sans);
font-feature-settings: 'ss01', 'cv11';
color: var(--ink);
background: var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-size: 14px;
line-height: 1.45;
letter-spacing: -0.005em;
transition: background-color 0.2s, color 0.2s;
}
button { font: inherit; color: inherit; cursor: pointer; border: 0; background: none; padding: 0; }
input, select, textarea { font: inherit; color: inherit; }
a { color: inherit; text-decoration: none; }
::selection { background: var(--brand); color: var(--brand-ink); }
/* Scrollbars */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 10px; border: 2px solid var(--bg); }
.mono { font-family: var(--font-mono); }
.serif { font-family: var(--font-display); }
.num { font-variant-numeric: tabular-nums; }