first commit
This commit is contained in:
commit
8b790b7601
86 changed files with 6348 additions and 0 deletions
72
ui_kits/mobile/MobileApp.jsx
Normal file
72
ui_kits/mobile/MobileApp.jsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// MobileApp — stateful navigation between screens for ONE device frame
|
||||
|
||||
function MobileApp({ initial = "queue", initialTx = null, initialEntity = "all", initialPickerOpen = false }) {
|
||||
const [screen, setScreen] = React.useState(initial);
|
||||
const [entity, setEntity] = React.useState(initialEntity);
|
||||
const [transactions, setTransactions] = React.useState(window.AKEFIN_DATA.transactions);
|
||||
const [activeTxId, setActiveTxId] = React.useState(initialTx);
|
||||
const [pickerOpen, setPickerOpen] = React.useState(initialPickerOpen);
|
||||
const tx = activeTxId ? transactions.find(t => t.id === activeTxId) : null;
|
||||
const [account, setAccount] = React.useState(tx?.suggestedAccount || null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (tx) setAccount(tx.suggestedAccount || null);
|
||||
}, [tx?.id]);
|
||||
|
||||
const openTx = (id) => { setActiveTxId(id); setScreen("detail"); };
|
||||
const back = () => { setScreen("queue"); setActiveTxId(null); };
|
||||
const approve = () => {
|
||||
setTransactions(prev => prev.filter(t => t.id !== activeTxId));
|
||||
back();
|
||||
};
|
||||
const skip = () => {
|
||||
setTransactions(prev => prev.filter(t => t.id !== activeTxId));
|
||||
back();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="m-app">
|
||||
<div className="m-app-body">
|
||||
{screen === "queue" && <MQueueScreen transactions={transactions} entity={entity} onTap={openTx} />}
|
||||
{screen === "detail" && <MDetailScreen tx={tx} onBack={back} onApprove={approve} onSkip={skip} account={account} onOpenPicker={() => setPickerOpen(true)} />}
|
||||
{screen === "rules" && <MRulesScreen entity={entity} />}
|
||||
{screen === "ledger" && <MLedgerScreen entity={entity} />}
|
||||
{screen === "import" && <MImportScreen entity={entity} />}
|
||||
</div>
|
||||
<MTabBar active={screen === "detail" ? "queue" : screen} onChange={(s) => { setActiveTxId(null); setScreen(s); }} />
|
||||
<MAccountPicker open={pickerOpen} onClose={() => setPickerOpen(false)}
|
||||
onPick={(a) => { setAccount(a); setPickerOpen(false); }}
|
||||
current={account} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A tiny rules screen for mobile
|
||||
function MRulesScreen({ entity }) {
|
||||
const rules = window.AKEFIN_DATA.ruleSuggestions;
|
||||
return (
|
||||
<div className="m-screen">
|
||||
<MHeader eyebrow="AI SUGGESTIONS" title="Rules" />
|
||||
<div className="m-list">
|
||||
{rules.map(r => (
|
||||
<div key={r.id} className="m-rule-card">
|
||||
<div className="m-rule-top">
|
||||
<code className="m-rule-pattern ko">"{r.pattern}"</code>
|
||||
<MConfidenceChip score={r.score} tier="llm" />
|
||||
</div>
|
||||
<div className="m-rule-map">
|
||||
<span className="m-arrow">→</span>
|
||||
<span className="mono">{r.target}</span>
|
||||
</div>
|
||||
<div className="m-rule-foot">
|
||||
<span className="m-rule-occ"><span className="num">{r.occurrences}</span> matches · 30d</span>
|
||||
<button className="m-rule-promote">Promote ✓</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { MobileApp, MRulesScreen });
|
||||
68
ui_kits/mobile/MobileAtoms.jsx
Normal file
68
ui_kits/mobile/MobileAtoms.jsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// Mobile-tuned atoms — larger touch targets, same visual language.
|
||||
// Re-uses .chip / .entity-badge / .tier / .amount from colors_and_type.css.
|
||||
|
||||
function MEntityBadge({ entity, size = "md" }) {
|
||||
const map = {
|
||||
personal: "personal", tfox: "tfox", finacode: "finacode",
|
||||
all: "personal"
|
||||
};
|
||||
const labels = { personal: "Personal", tfox: "9TFox", finacode: "Finacode", all: "All" };
|
||||
return <span className={`m-entity-badge ${map[entity]} sz-${size}`}>{labels[entity]}</span>;
|
||||
}
|
||||
|
||||
function MConfidenceChip({ score, tier }) {
|
||||
let cls = "low";
|
||||
if (tier === "rules" || (score !== null && score >= 1)) cls = "rules";
|
||||
else if (score !== null && score >= 0.85) cls = "high";
|
||||
else if (score !== null && score >= 0.70) cls = "mid";
|
||||
const display = score === null ? "—" : score.toFixed(2);
|
||||
return <span className={`m-chip ${cls}`}>★ {display}</span>;
|
||||
}
|
||||
|
||||
function MTierBadge({ tier }) {
|
||||
const map = { rules: "Rules", llm: "LLM", agent: "Agent", unmatched: "Unmatched" };
|
||||
return <span className={`m-tier ${tier}`}>{map[tier]}</span>;
|
||||
}
|
||||
|
||||
function MAmount({ value, ccy, big = false }) {
|
||||
if (value === null || value === undefined) return <span className="m-amount muted">—</span>;
|
||||
const cls = value > 0 ? "pos" : "neg";
|
||||
const sign = value > 0 ? "+ " : "− ";
|
||||
const abs = Math.abs(value);
|
||||
const formatted = abs.toLocaleString("en-US", { maximumFractionDigits: ccy === "EUR" ? 2 : 0 });
|
||||
return (
|
||||
<span className={`m-amount ${cls} ${big ? "big" : ""}`}>
|
||||
{sign}{formatted}<span className="m-ccy"> {ccy}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MIcon({ name, size = 18, stroke = 1.5, color = "currentColor" }) {
|
||||
const d = window.ICONS_DATA[name];
|
||||
if (!d) return null;
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d={d}/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
window.ICONS_DATA = {
|
||||
check: "M20 6 L9 17 L4 12",
|
||||
x: "M18 6 L6 18 M6 6 L18 18",
|
||||
search: "M11 4 a7 7 0 1 0 0 14 a7 7 0 0 0 0 -14 M21 21 L16.65 16.65",
|
||||
chevDown: "M6 9 L12 15 L18 9",
|
||||
chevRight: "M9 6 L15 12 L9 18",
|
||||
chevLeft: "M15 6 L9 12 L15 18",
|
||||
plus: "M12 5 L12 19 M5 12 L19 12",
|
||||
more: "M5 12 h.01 M12 12 h.01 M19 12 h.01",
|
||||
arrowRight: "M5 12 H19 M13 6 L19 12 L13 18",
|
||||
filter: "M3 5 H21 L14 13 V20 L10 22 V13 L3 5",
|
||||
rule: "M4 4 H20 V20 H4 Z M4 9 H20 M4 14 H20 M9 9 V20",
|
||||
ledger: "M5 3 H17 a2 2 0 0 1 2 2 V21 L12 17 L5 21 Z",
|
||||
activity: "M22 12 H18 L15 21 L9 3 L6 12 H2",
|
||||
import: "M12 2 L12 22 M19 15 L12 22 L5 15",
|
||||
refresh: "M21 12 a9 9 0 1 1 -3 -6.7 M21 4 V10 H15",
|
||||
};
|
||||
|
||||
Object.assign(window, { MEntityBadge, MConfidenceChip, MTierBadge, MAmount, MIcon });
|
||||
311
ui_kits/mobile/MobileScreens.jsx
Normal file
311
ui_kits/mobile/MobileScreens.jsx
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
// Mobile screens — Queue, Detail, Picker (sheet), Import, Ledger
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Header — universal mobile top bar
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
function MHeader({ left, title, right, eyebrow }) {
|
||||
return (
|
||||
<div className="m-header">
|
||||
<div className="m-header-row">
|
||||
<div className="m-header-left">{left}</div>
|
||||
<div className="m-header-right">{right}</div>
|
||||
</div>
|
||||
{eyebrow && <div className="m-header-eyebrow">{eyebrow}</div>}
|
||||
{title && <div className="m-header-title">{title}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tab bar
|
||||
function MTabBar({ active, onChange }) {
|
||||
const tabs = [
|
||||
{ id: "queue", icon: "activity", label: "Review" },
|
||||
{ id: "rules", icon: "rule", label: "Rules" },
|
||||
{ id: "ledger", icon: "ledger", label: "Ledger" },
|
||||
{ id: "import", icon: "import", label: "Import" },
|
||||
];
|
||||
return (
|
||||
<div className="m-tabbar">
|
||||
{tabs.map(t => (
|
||||
<button key={t.id} className={`m-tab ${active === t.id ? "active" : ""}`}
|
||||
onClick={() => onChange(t.id)}>
|
||||
<MIcon name={t.icon} size={18} />
|
||||
<span>{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// 1. QUEUE SCREEN — list of staged transactions
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
function MQueueScreen({ transactions, entity, onTap }) {
|
||||
const filtered = transactions.filter(t => entity === "all" || t.entity === entity);
|
||||
return (
|
||||
<div className="m-screen">
|
||||
<MHeader
|
||||
eyebrow="REVIEW QUEUE"
|
||||
title="Review"
|
||||
left={<button className="m-icon-btn"><MIcon name="filter" /></button>}
|
||||
right={<button className="m-icon-btn"><MIcon name="search" /></button>}
|
||||
/>
|
||||
<div className="m-scope-strip">
|
||||
<span className="m-stripe" style={{
|
||||
background: entity === "personal" ? "#3D6E70" :
|
||||
entity === "tfox" ? "#B4541A" :
|
||||
entity === "finacode" ? "#5A4FA3" : "#1A1814"
|
||||
}}></span>
|
||||
<span className="m-scope-lbl">SCOPE</span>
|
||||
<span className="m-scope-name">{entity === "all" ? "All entities" : entity === "personal" ? "Personal" : entity === "tfox" ? "9TFox" : "Finacode"}</span>
|
||||
<span className="m-scope-count">{filtered.length} STAGED</span>
|
||||
</div>
|
||||
<div className="m-list">
|
||||
{filtered.map(t => (
|
||||
<button key={t.id} className="m-tx-row" onClick={() => onTap(t.id)}>
|
||||
<div className="m-tx-top">
|
||||
<span className="m-tx-date">{t.date.slice(5)}</span>
|
||||
<MEntityBadge entity={t.entity} />
|
||||
<MAmount value={t.amount} ccy={t.ccy} />
|
||||
</div>
|
||||
<div className="m-tx-payee ko">{t.payee}</div>
|
||||
<div className="m-tx-bottom">
|
||||
<MConfidenceChip score={t.score} tier={t.tier} />
|
||||
<MTierBadge tier={t.tier} />
|
||||
{t.suggestedAccount && (
|
||||
<span className="m-tx-suggest">→ <span className="mono">{t.suggestedAccount.split(":").slice(-2).join(":")}</span></span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// 2. DETAIL SCREEN — full review for one transaction
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
function MDetailScreen({ tx, onBack, onApprove, onSkip, onOpenPicker, account }) {
|
||||
if (!tx) return null;
|
||||
const sourceAccount = tx.entity === "personal" ? "Personal:Assets:Bank:Toss"
|
||||
: tx.entity === "tfox" ? "9TFox:Assets:Bank:Kakao"
|
||||
: "Finacode:Assets:Bank:Wise";
|
||||
const isExpense = tx.amount < 0;
|
||||
const debit = isExpense ? account : sourceAccount;
|
||||
const credit = isExpense ? sourceAccount : account;
|
||||
const abs = Math.abs(tx.amount).toLocaleString();
|
||||
return (
|
||||
<div className="m-screen">
|
||||
<MHeader
|
||||
eyebrow={`TRANSACTION · ${tx.id.toUpperCase()}`}
|
||||
left={<button className="m-icon-btn" onClick={onBack}><MIcon name="chevLeft" /></button>}
|
||||
right={<button className="m-icon-btn"><MIcon name="more" /></button>}
|
||||
/>
|
||||
<div className="m-detail-hero">
|
||||
<div className="m-detail-payee ko">{tx.payee}</div>
|
||||
{tx.payeeNote && <div className="m-detail-note">{tx.payeeNote}</div>}
|
||||
<div className="m-detail-amount"><MAmount value={tx.amount} ccy={tx.ccy} big /></div>
|
||||
<div className="m-detail-meta">
|
||||
<span className="mono">{tx.date}</span>
|
||||
<span className="dot">·</span>
|
||||
<MEntityBadge entity={tx.entity} />
|
||||
<span className="dot">·</span>
|
||||
<span className="m-source ko">{tx.sourceAccount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="m-section">
|
||||
<div className="m-section-head"><span>AI SUGGESTION</span><MConfidenceChip score={tx.score} tier={tx.tier} /></div>
|
||||
<div className="m-suggestion">
|
||||
<span className="m-arrow">→</span>
|
||||
<span className="m-acct-text">{tx.suggestedAccount || <span className="muted">No suggestion · unmatched</span>}</span>
|
||||
</div>
|
||||
<div className="m-tier-row">
|
||||
<MTierBadge tier={tx.tier} />
|
||||
<span className="m-tier-text">matched by {tx.tier === "rules" ? "pattern rule" : tx.tier === "llm" ? "LLM (gpt-4o-mini)" : tx.tier === "agent" ? "agent · 3 tool calls" : "no tier — needs override"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="m-section">
|
||||
<div className="m-section-head"><span>CATEGORIZE TO</span></div>
|
||||
<button className="m-picker-trigger" onClick={onOpenPicker}>
|
||||
<span className="m-acct-path mono">
|
||||
{account ? account : <span className="muted">Choose account…</span>}
|
||||
</span>
|
||||
<MIcon name="chevRight" size={14} color="var(--fg-muted)" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="m-section">
|
||||
<div className="m-section-head"><span>LEDGER PREVIEW</span></div>
|
||||
{account ? (
|
||||
<div className="m-ledger-preview">
|
||||
<div className="m-ledger-row">
|
||||
<span className="m-ledger-acct">{debit}</span>
|
||||
<span className="m-ledger-amt"> {abs}</span>
|
||||
<span className="m-ledger-ccy">{tx.ccy}</span>
|
||||
</div>
|
||||
<div className="m-ledger-row">
|
||||
<span className="m-ledger-acct">{credit}</span>
|
||||
<span className="m-ledger-amt">-{abs}</span>
|
||||
<span className="m-ledger-ccy">{tx.ccy}</span>
|
||||
</div>
|
||||
<div className="m-ledger-foot">balanced · 2 legs</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="m-ledger-empty">Choose an account to preview the posting</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="m-detail-actions">
|
||||
<button className="m-action primary" onClick={onApprove}><MIcon name="check" size={16} /> APPROVE</button>
|
||||
<button className="m-action ghost">OVERRIDE</button>
|
||||
<button className="m-action ghost" onClick={onSkip}>SKIP</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// 3. ACCOUNT PICKER — modal sheet
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
function MAccountPicker({ open, onClose, onPick, current }) {
|
||||
const [q, setQ] = React.useState("");
|
||||
if (!open) return null;
|
||||
const accounts = window.AKEFIN_DATA.allAccounts;
|
||||
const mru = ["Personal:Expenses:Food:Coffee", "Personal:Expenses:Groceries", "9TFox:Expenses:Software"];
|
||||
const matches = q ? accounts.filter(a => a.toLowerCase().includes(q.toLowerCase())) : accounts;
|
||||
|
||||
return (
|
||||
<div className="m-sheet-overlay" onClick={onClose}>
|
||||
<div className="m-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="m-sheet-handle"></div>
|
||||
<div className="m-sheet-head">
|
||||
<button className="m-link" onClick={onClose}>Cancel</button>
|
||||
<span className="m-sheet-title">Pick account</span>
|
||||
<button className="m-link strong">Confirm</button>
|
||||
</div>
|
||||
<div className="m-sheet-search">
|
||||
<MIcon name="search" size={14} color="var(--fg-muted)" />
|
||||
<input autoFocus type="text" placeholder="Search accounts…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</div>
|
||||
{!q && (
|
||||
<div className="m-sheet-section">
|
||||
<div className="m-sheet-label">RECENT</div>
|
||||
{mru.map(a => (
|
||||
<button key={a} className="m-sheet-row" onClick={() => onPick(a)}>
|
||||
<span className="mono">{a}</span>
|
||||
<span className="m-mru">MRU</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="m-sheet-section">
|
||||
<div className="m-sheet-label">{q ? "MATCHES" : "ALL ACCOUNTS"}</div>
|
||||
{matches.map(a => (
|
||||
<button key={a} className={`m-sheet-row ${a === current ? "active" : ""}`} onClick={() => onPick(a)}>
|
||||
<span className="mono">{a}</span>
|
||||
{a === current && <MIcon name="check" size={14} color="var(--conf-rules)" />}
|
||||
</button>
|
||||
))}
|
||||
<button className="m-sheet-row create">
|
||||
<MIcon name="plus" size={14} />
|
||||
<span>Create new account…</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// 4. IMPORT SCREEN
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
function MImportScreen({ entity }) {
|
||||
const runs = window.AKEFIN_DATA.importRuns.filter(r => entity === "all" || r.entity === entity);
|
||||
const total = runs.reduce((acc, r) => ({
|
||||
rows: acc.rows + r.rows, auto: acc.auto + r.auto, high: acc.high + r.high,
|
||||
review: acc.review + r.review, failed: acc.failed + r.failed,
|
||||
}), { rows: 0, auto: 0, high: 0, review: 0, failed: 0 });
|
||||
|
||||
return (
|
||||
<div className="m-screen">
|
||||
<MHeader
|
||||
eyebrow="STATUS"
|
||||
title="Import"
|
||||
right={<button className="m-icon-btn"><MIcon name="refresh" /></button>}
|
||||
/>
|
||||
<div className="m-stats">
|
||||
<div className="m-stat"><span className="m-stat-num">{total.rows}</span><span className="m-stat-lbl">TOTAL</span></div>
|
||||
<div className="m-stat"><span className="m-stat-num" style={{color: "var(--conf-rules)"}}>{total.auto}</span><span className="m-stat-lbl">AUTO</span></div>
|
||||
<div className="m-stat"><span className="m-stat-num" style={{color: "var(--conf-mid)"}}>{total.review}</span><span className="m-stat-lbl">REVIEW</span></div>
|
||||
<div className="m-stat"><span className="m-stat-num" style={{color: "var(--conf-low)"}}>{total.failed}</span><span className="m-stat-lbl">FAILED</span></div>
|
||||
</div>
|
||||
|
||||
<div className="m-section-head" style={{ padding: "16px 16px 8px" }}><span>RECENT RUNS</span></div>
|
||||
<div className="m-list">
|
||||
{runs.map(r => (
|
||||
<div key={r.id} className="m-import-row">
|
||||
<div className="m-import-row-top">
|
||||
<span className="m-import-when mono">{r.at}</span>
|
||||
<MEntityBadge entity={r.entity} />
|
||||
</div>
|
||||
<div className="m-import-row-src ko">{r.source}</div>
|
||||
<div className="m-import-bar">
|
||||
<div style={{width: `${(r.auto/r.rows)*100}%`, background: "var(--conf-rules)"}}></div>
|
||||
<div style={{width: `${(r.high/r.rows)*100}%`, background: "var(--conf-high)"}}></div>
|
||||
<div style={{width: `${(r.review/r.rows)*100}%`, background: "var(--conf-mid)"}}></div>
|
||||
<div style={{width: `${(r.failed/r.rows)*100}%`, background: "var(--conf-low)"}}></div>
|
||||
</div>
|
||||
<div className="m-import-row-meta">
|
||||
<span className="mono">{r.rows} rows</span>
|
||||
<span className="dot">·</span>
|
||||
<span className="mono">{r.auto} auto</span>
|
||||
<span className="dot">·</span>
|
||||
<span className="mono">{r.review} review</span>
|
||||
{r.failed > 0 && <><span className="dot">·</span><span className="mono" style={{color:"var(--conf-low)"}}>{r.failed} failed</span></>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// 5. LEDGER SCREEN
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
function MLedgerScreen({ entity }) {
|
||||
const data = window.AKEFIN_DATA.accountsByEntity;
|
||||
const entities = entity === "all" ? ["personal", "tfox", "finacode"] : [entity];
|
||||
return (
|
||||
<div className="m-screen">
|
||||
<MHeader eyebrow="READ-ONLY · GIT-SYNCED" title="Ledger" />
|
||||
{entities.map(e => {
|
||||
const map = window.AKEFIN_DATA.entities.find(x => x.id === e);
|
||||
const accounts = data[e] || [];
|
||||
return (
|
||||
<div key={e} className="m-ledger-entity">
|
||||
<div className="m-ledger-entity-head">
|
||||
<span className="m-stripe" style={{background: map.color}}></span>
|
||||
<span className="m-ledger-entity-name">{map.label}</span>
|
||||
<span className="m-ledger-entity-meta mono">{accounts.length} accts</span>
|
||||
</div>
|
||||
{accounts.map(a => (
|
||||
<div key={a.path} className={`m-ledger-acct-row ${a.kind}`}>
|
||||
<span className="mono">{a.path.split(":").slice(-1)[0]}</span>
|
||||
<span className="m-ledger-acct-full mono">{a.path}</span>
|
||||
<MAmount value={a.balance} ccy={a.ccy} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
MHeader, MTabBar, MQueueScreen, MDetailScreen, MAccountPicker, MImportScreen, MLedgerScreen,
|
||||
});
|
||||
25
ui_kits/mobile/README.md
Normal file
25
ui_kits/mobile/README.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Akefin Mobile — UI Kit
|
||||
|
||||
A pixel-faithful recreation of the **Akefin mobile app** (iOS-focused; Android renders identically with platform chrome substitutions). For the on-the-go review case.
|
||||
|
||||
## Screens
|
||||
|
||||
1. **Review queue** — compact stack of staged transactions, swipeable. Tap to review.
|
||||
2. **Transaction detail** — full-screen card with payee, amount, AI suggestion, account picker, ledger preview, **Approve / Override / Skip**.
|
||||
3. **Account picker** — sheet with fuzzy search + MRU + hierarchical browse.
|
||||
4. **Import status** — recent runs and totals.
|
||||
5. **Ledger** — per-entity account balances summary.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `index.html` | Live prototype — three iPhone frames side-by-side showing the main screens |
|
||||
| `MobileApp.jsx` | Stateful navigation between screens |
|
||||
| `MobileScreens.jsx` | All five screens (Queue, Detail, Picker, Import, Ledger) |
|
||||
| `MobileAtoms.jsx` | Mobile-tuned chips/badges (slightly larger touch targets) |
|
||||
| `ios-frame.jsx` | Starter iOS device frame |
|
||||
|
||||
## Caveats
|
||||
|
||||
Recreated from the brief. Iterate.
|
||||
105
ui_kits/mobile/index.html
Normal file
105
ui_kits/mobile/index.html
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=1480">
|
||||
<title>Akefin Mobile</title>
|
||||
<link rel="icon" href="../../assets/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="../../colors_and_type.css">
|
||||
<link rel="stylesheet" href="mobile.css">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; min-height: 100vh; }
|
||||
body {
|
||||
background: #DCD7CC;
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(26,24,20,0.045) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(26,24,20,0.045) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--fg);
|
||||
padding: 48px 32px 64px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.stage {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
max-width: 1480px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.frame-wrap {
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.frame-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.stage-title {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.stage-title .display {
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 36px;
|
||||
color: var(--fg-strong);
|
||||
line-height: 1.1;
|
||||
}
|
||||
.stage-title .sub {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
color: var(--fg-muted);
|
||||
margin-top: 6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="stage-title">
|
||||
<div class="display">Akefin Mobile</div>
|
||||
<div class="sub">Review queue · Transaction detail · Account picker sheet — tap any device to interact</div>
|
||||
</div>
|
||||
<div class="stage" id="stage"></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 src="../web/data.js"></script>
|
||||
<script type="text/babel" src="ios-frame.jsx"></script>
|
||||
<script type="text/babel" src="MobileAtoms.jsx"></script>
|
||||
<script type="text/babel" src="MobileScreens.jsx"></script>
|
||||
<script type="text/babel" src="MobileApp.jsx"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
function Stage() {
|
||||
const frames = [
|
||||
{ id: "queue", label: "01 · Review queue", props: { initial: "queue" } },
|
||||
{ id: "detail", label: "02 · Transaction detail", props: { initial: "detail", initialTx: "t02" } },
|
||||
{ id: "picker", label: "03 · Account picker", props: { initial: "detail", initialTx: "t02", initialPickerOpen: true } },
|
||||
];
|
||||
return (
|
||||
<React.Fragment>
|
||||
{frames.map(f => (
|
||||
<div key={f.id} className="frame-wrap">
|
||||
<IOSDevice width={402} height={874}>
|
||||
<MobileApp {...f.props} />
|
||||
</IOSDevice>
|
||||
<div className="frame-label">{f.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
ReactDOM.createRoot(document.getElementById("stage")).render(<Stage />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
338
ui_kits/mobile/ios-frame.jsx
Normal file
338
ui_kits/mobile/ios-frame.jsx
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
|
||||
// iOS.jsx — Simplified iOS 26 (Liquid Glass) device frame
|
||||
// Based on the iOS 26 UI Kit + Figma status bar spec. No assets, no deps.
|
||||
// Exports: IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Status bar
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSStatusBar({ dark = false, time = '9:41' }) {
|
||||
const c = dark ? '#fff' : '#000';
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', gap: 154, alignItems: 'center', justifyContent: 'center',
|
||||
padding: '21px 24px 19px', boxSizing: 'border-box',
|
||||
position: 'relative', zIndex: 20, width: '100%',
|
||||
}}>
|
||||
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', paddingTop: 1.5 }}>
|
||||
<span style={{
|
||||
fontFamily: '-apple-system, "SF Pro", system-ui', fontWeight: 590,
|
||||
fontSize: 17, lineHeight: '22px', color: c,
|
||||
}}>{time}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, paddingTop: 1, paddingRight: 1 }}>
|
||||
<svg width="19" height="12" viewBox="0 0 19 12">
|
||||
<rect x="0" y="7.5" width="3.2" height="4.5" rx="0.7" fill={c}/>
|
||||
<rect x="4.8" y="5" width="3.2" height="7" rx="0.7" fill={c}/>
|
||||
<rect x="9.6" y="2.5" width="3.2" height="9.5" rx="0.7" fill={c}/>
|
||||
<rect x="14.4" y="0" width="3.2" height="12" rx="0.7" fill={c}/>
|
||||
</svg>
|
||||
<svg width="17" height="12" viewBox="0 0 17 12">
|
||||
<path d="M8.5 3.2C10.8 3.2 12.9 4.1 14.4 5.6L15.5 4.5C13.7 2.7 11.2 1.5 8.5 1.5C5.8 1.5 3.3 2.7 1.5 4.5L2.6 5.6C4.1 4.1 6.2 3.2 8.5 3.2Z" fill={c}/>
|
||||
<path d="M8.5 6.8C9.9 6.8 11.1 7.3 12 8.2L13.1 7.1C11.8 5.9 10.2 5.1 8.5 5.1C6.8 5.1 5.2 5.9 3.9 7.1L5 8.2C5.9 7.3 7.1 6.8 8.5 6.8Z" fill={c}/>
|
||||
<circle cx="8.5" cy="10.5" r="1.5" fill={c}/>
|
||||
</svg>
|
||||
<svg width="27" height="13" viewBox="0 0 27 13">
|
||||
<rect x="0.5" y="0.5" width="23" height="12" rx="3.5" stroke={c} strokeOpacity="0.35" fill="none"/>
|
||||
<rect x="2" y="2" width="20" height="9" rx="2" fill={c}/>
|
||||
<path d="M25 4.5V8.5C25.8 8.2 26.5 7.2 26.5 6.5C26.5 5.8 25.8 4.8 25 4.5Z" fill={c} fillOpacity="0.4"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Liquid glass pill — blur + tint + shine
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSGlassPill({ children, dark = false, style = {} }) {
|
||||
return (
|
||||
<div style={{
|
||||
height: 44, minWidth: 44, borderRadius: 9999,
|
||||
position: 'relative', overflow: 'hidden',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: dark
|
||||
? '0 2px 6px rgba(0,0,0,0.35), 0 6px 16px rgba(0,0,0,0.2)'
|
||||
: '0 1px 3px rgba(0,0,0,0.07), 0 3px 10px rgba(0,0,0,0.06)',
|
||||
...style,
|
||||
}}>
|
||||
{/* blur + tint */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 9999,
|
||||
backdropFilter: 'blur(12px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
|
||||
background: dark ? 'rgba(120,120,128,0.28)' : 'rgba(255,255,255,0.5)',
|
||||
}} />
|
||||
{/* shine */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 9999,
|
||||
boxShadow: dark
|
||||
? 'inset 1.5px 1.5px 1px rgba(255,255,255,0.15), inset -1px -1px 1px rgba(255,255,255,0.08)'
|
||||
: 'inset 1.5px 1.5px 1px rgba(255,255,255,0.7), inset -1px -1px 1px rgba(255,255,255,0.4)',
|
||||
border: dark ? '0.5px solid rgba(255,255,255,0.15)' : '0.5px solid rgba(0,0,0,0.06)',
|
||||
}} />
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', alignItems: 'center', padding: '0 4px' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Navigation bar — glass pills + large title
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSNavBar({ title = 'Title', dark = false, trailingIcon = true }) {
|
||||
const muted = dark ? 'rgba(255,255,255,0.6)' : '#404040';
|
||||
const text = dark ? '#fff' : '#000';
|
||||
const pillIcon = (content) => (
|
||||
<IOSGlassPill dark={dark}>
|
||||
<div style={{ width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{content}
|
||||
</div>
|
||||
</IOSGlassPill>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 10,
|
||||
paddingTop: 62, paddingBottom: 10, position: 'relative', zIndex: 5,
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '0 16px',
|
||||
}}>
|
||||
{/* back chevron */}
|
||||
{pillIcon(
|
||||
<svg width="12" height="20" viewBox="0 0 12 20" fill="none" style={{ marginLeft: -1 }}>
|
||||
<path d="M10 2L2 10l8 8" stroke={muted} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
{/* trailing ellipsis */}
|
||||
{trailingIcon && pillIcon(
|
||||
<svg width="22" height="6" viewBox="0 0 22 6">
|
||||
<circle cx="3" cy="3" r="2.5" fill={muted}/>
|
||||
<circle cx="11" cy="3" r="2.5" fill={muted}/>
|
||||
<circle cx="19" cy="3" r="2.5" fill={muted}/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
{/* large title */}
|
||||
<div style={{
|
||||
padding: '0 16px',
|
||||
fontFamily: '-apple-system, system-ui',
|
||||
fontSize: 34, fontWeight: 700, lineHeight: '41px',
|
||||
color: text, letterSpacing: 0.4,
|
||||
}}>{title}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Grouped list (inset card, r:26) + row (52px)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSListRow({ title, detail, icon, chevron = true, isLast = false, dark = false }) {
|
||||
const text = dark ? '#fff' : '#000';
|
||||
const sec = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
|
||||
const ter = dark ? 'rgba(235,235,245,0.3)' : 'rgba(60,60,67,0.3)';
|
||||
const sep = dark ? 'rgba(84,84,88,0.65)' : 'rgba(60,60,67,0.12)';
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', minHeight: 52,
|
||||
padding: '0 16px', position: 'relative',
|
||||
fontFamily: '-apple-system, system-ui', fontSize: 17,
|
||||
letterSpacing: -0.43,
|
||||
}}>
|
||||
{icon && (
|
||||
<div style={{
|
||||
width: 30, height: 30, borderRadius: 7, background: icon,
|
||||
marginRight: 12, flexShrink: 0,
|
||||
}} />
|
||||
)}
|
||||
<div style={{ flex: 1, color: text }}>{title}</div>
|
||||
{detail && <span style={{ color: sec, marginRight: 6 }}>{detail}</span>}
|
||||
{chevron && (
|
||||
<svg width="8" height="14" viewBox="0 0 8 14" style={{ flexShrink: 0 }}>
|
||||
<path d="M1 1l6 6-6 6" stroke={ter} strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
{!isLast && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 0, right: 0,
|
||||
left: icon ? 58 : 16, height: 0.5, background: sep,
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IOSList({ header, children, dark = false }) {
|
||||
const hc = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
|
||||
const bg = dark ? '#1C1C1E' : '#fff';
|
||||
return (
|
||||
<div>
|
||||
{header && (
|
||||
<div style={{
|
||||
fontFamily: '-apple-system, system-ui', fontSize: 13,
|
||||
color: hc, textTransform: 'uppercase',
|
||||
padding: '8px 36px 6px', letterSpacing: -0.08,
|
||||
}}>{header}</div>
|
||||
)}
|
||||
<div style={{
|
||||
background: bg, borderRadius: 26,
|
||||
margin: '0 16px', overflow: 'hidden',
|
||||
}}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Device frame
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSDevice({
|
||||
children, width = 402, height = 874, dark = false,
|
||||
title, keyboard = false,
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
width, height, borderRadius: 48, overflow: 'hidden',
|
||||
position: 'relative', background: dark ? '#000' : '#F2F2F7',
|
||||
boxShadow: '0 40px 80px rgba(0,0,0,0.18), 0 0 0 1px rgba(0,0,0,0.12)',
|
||||
fontFamily: '-apple-system, system-ui, sans-serif',
|
||||
WebkitFontSmoothing: 'antialiased',
|
||||
}}>
|
||||
{/* dynamic island */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 11, left: '50%', transform: 'translateX(-50%)',
|
||||
width: 126, height: 37, borderRadius: 24, background: '#000', zIndex: 50,
|
||||
}} />
|
||||
{/* status bar (absolute) */}
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10 }}>
|
||||
<IOSStatusBar dark={dark} />
|
||||
</div>
|
||||
{/* nav + content */}
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{title !== undefined && <IOSNavBar title={title} dark={dark} />}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>{children}</div>
|
||||
{keyboard && <IOSKeyboard dark={dark} />}
|
||||
</div>
|
||||
{/* home indicator — always on top */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 60,
|
||||
height: 34, display: 'flex', justifyContent: 'center', alignItems: 'flex-end',
|
||||
paddingBottom: 8, pointerEvents: 'none',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 139, height: 5, borderRadius: 100,
|
||||
background: dark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.25)',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Keyboard — iOS 26 liquid glass
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSKeyboard({ dark = false }) {
|
||||
const glyph = dark ? 'rgba(255,255,255,0.7)' : '#595959';
|
||||
const sugg = dark ? 'rgba(255,255,255,0.6)' : '#333';
|
||||
const keyBg = dark ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.85)';
|
||||
|
||||
// special-key icons
|
||||
const icons = {
|
||||
shift: <svg width="19" height="17" viewBox="0 0 19 17"><path d="M9.5 1L1 9.5h4.5V16h8V9.5H18L9.5 1z" fill={glyph}/></svg>,
|
||||
del: <svg width="23" height="17" viewBox="0 0 23 17"><path d="M7 1h13a2 2 0 012 2v11a2 2 0 01-2 2H7l-6-7.5L7 1z" fill="none" stroke={glyph} strokeWidth="1.6" strokeLinejoin="round"/><path d="M10 5l7 7M17 5l-7 7" stroke={glyph} strokeWidth="1.6" strokeLinecap="round"/></svg>,
|
||||
ret: <svg width="20" height="14" viewBox="0 0 20 14"><path d="M18 1v6H4m0 0l4-4M4 7l4 4" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
};
|
||||
|
||||
const key = (content, { w, flex, ret, fs = 25, k } = {}) => (
|
||||
<div key={k} style={{
|
||||
height: 42, borderRadius: 8.5,
|
||||
flex: flex ? 1 : undefined, width: w, minWidth: 0,
|
||||
background: ret ? '#08f' : keyBg,
|
||||
boxShadow: '0 1px 0 rgba(0,0,0,0.075)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontFamily: '-apple-system, "SF Compact", system-ui',
|
||||
fontSize: fs, fontWeight: 458, color: ret ? '#fff' : glyph,
|
||||
}}>{content}</div>
|
||||
);
|
||||
|
||||
const row = (keys, pad = 0) => (
|
||||
<div style={{ display: 'flex', gap: 6.5, justifyContent: 'center', padding: `0 ${pad}px` }}>
|
||||
{keys.map(l => key(l, { flex: true, k: l }))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative', zIndex: 15, borderRadius: 27, overflow: 'hidden',
|
||||
padding: '11px 0 2px',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
boxShadow: dark
|
||||
? '0 -2px 20px rgba(0,0,0,0.09)'
|
||||
: '0 -1px 6px rgba(0,0,0,0.018), 0 -3px 20px rgba(0,0,0,0.012)',
|
||||
}}>
|
||||
{/* liquid glass bg — same recipe as nav pills */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 27,
|
||||
backdropFilter: 'blur(12px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
|
||||
background: dark ? 'rgba(120,120,128,0.14)' : 'rgba(255,255,255,0.25)',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 27,
|
||||
boxShadow: dark
|
||||
? 'inset 1.5px 1.5px 1px rgba(255,255,255,0.15)'
|
||||
: 'inset 1.5px 1.5px 1px rgba(255,255,255,0.7), inset -1px -1px 1px rgba(255,255,255,0.4)',
|
||||
border: dark ? '0.5px solid rgba(255,255,255,0.15)' : '0.5px solid rgba(0,0,0,0.06)',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
|
||||
{/* autocorrect bar */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 20, alignItems: 'center',
|
||||
padding: '8px 22px 13px', width: '100%', boxSizing: 'border-box',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{['"The"', 'the', 'to'].map((w, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <div style={{ width: 1, height: 25, background: '#ccc', opacity: 0.3 }} />}
|
||||
<div style={{
|
||||
flex: 1, textAlign: 'center',
|
||||
fontFamily: '-apple-system, system-ui', fontSize: 17,
|
||||
color: sugg, letterSpacing: -0.43, lineHeight: '22px',
|
||||
}}>{w}</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* key layout */}
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 13,
|
||||
padding: '0 6.5px', width: '100%', boxSizing: 'border-box',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{row(['q','w','e','r','t','y','u','i','o','p'])}
|
||||
{row(['a','s','d','f','g','h','j','k','l'], 20)}
|
||||
<div style={{ display: 'flex', gap: 14.25, alignItems: 'center' }}>
|
||||
{key(icons.shift, { w: 45, k: 'shift' })}
|
||||
<div style={{ display: 'flex', gap: 6.5, flex: 1 }}>
|
||||
{['z','x','c','v','b','n','m'].map(l => key(l, { flex: true, k: l }))}
|
||||
</div>
|
||||
{key(icons.del, { w: 45, k: 'del' })}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
{key('ABC', { w: 92.25, fs: 18, k: 'abc' })}
|
||||
{key('', { flex: true, k: 'space' })}
|
||||
{key(icons.ret, { w: 92.25, ret: true, k: 'ret' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* bottom spacer (emoji+mic area, icons omitted) */}
|
||||
<div style={{ height: 56, width: '100%', position: 'relative' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard,
|
||||
});
|
||||
691
ui_kits/mobile/mobile.css
Normal file
691
ui_kits/mobile/mobile.css
Normal file
|
|
@ -0,0 +1,691 @@
|
|||
/* =========================================================================
|
||||
AKEFIN — Mobile Styles
|
||||
========================================================================= */
|
||||
|
||||
.m-app {
|
||||
display: flex; flex-direction: column;
|
||||
width: 100%; height: 100%;
|
||||
background: var(--bg);
|
||||
background-image:
|
||||
linear-gradient(to right, var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--fg);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.m-app * { box-sizing: border-box; }
|
||||
.m-app button { font-family: inherit; cursor: pointer; }
|
||||
|
||||
.m-app-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: transparent;
|
||||
}
|
||||
.m-screen { padding-bottom: 16px; }
|
||||
|
||||
/* =========================================================================
|
||||
HEADER
|
||||
========================================================================= */
|
||||
.m-header {
|
||||
padding: 56px 16px 14px;
|
||||
background: var(--bg-translucent);
|
||||
backdrop-filter: blur(8px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.m-header-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
height: 32px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.m-header-eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9.5px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--fg-subtle);
|
||||
margin-top: 8px;
|
||||
}
|
||||
.m-header-title {
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 30px;
|
||||
line-height: 1.1;
|
||||
color: var(--fg-strong);
|
||||
letter-spacing: -0.01em;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.m-icon-btn {
|
||||
width: 32px; height: 32px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 2px;
|
||||
color: var(--fg);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.m-icon-btn:active { background: var(--btn-ghost-hover); }
|
||||
|
||||
/* =========================================================================
|
||||
SCOPE STRIP
|
||||
========================================================================= */
|
||||
.m-scope-strip {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 16px;
|
||||
background: var(--paper);
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.m-stripe { width: 4px; height: 16px; border-radius: 1px; }
|
||||
.m-scope-lbl {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.m-scope-name {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-strong);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.m-scope-count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--fg-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
LIST + TRANSACTION ROW (queue)
|
||||
========================================================================= */
|
||||
.m-list {
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.m-tx-row {
|
||||
display: flex; flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.m-tx-row:active { background: var(--btn-ghost-hover); }
|
||||
.m-tx-top {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.m-tx-date {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--fg-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.m-tx-top .m-amount {
|
||||
margin-left: auto;
|
||||
}
|
||||
.m-tx-payee {
|
||||
font-family: var(--font-sans-kr);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-strong);
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.m-tx-bottom {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.m-tx-suggest {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--fg-muted);
|
||||
margin-left: auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 60%;
|
||||
}
|
||||
.m-tx-suggest .mono { color: var(--fg); }
|
||||
|
||||
/* =========================================================================
|
||||
ATOMS — mobile-tuned
|
||||
========================================================================= */
|
||||
.m-amount {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: "tnum";
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.m-amount.big {
|
||||
font-size: 32px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.m-amount.pos { color: var(--conf-rules); }
|
||||
.m-amount.neg { color: var(--fg-strong); }
|
||||
.m-amount.muted { color: var(--fg-subtle); }
|
||||
.m-ccy { color: var(--fg-subtle); font-weight: 400; margin-left: 2px; }
|
||||
|
||||
.m-entity-badge {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
height: 22px;
|
||||
padding: 0 8px 0 5px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.m-entity-badge::before {
|
||||
content: "";
|
||||
width: 3px; height: 14px;
|
||||
border-radius: 1px;
|
||||
}
|
||||
.m-entity-badge.personal { background: var(--entity-personal-bg); color: var(--entity-personal); }
|
||||
.m-entity-badge.personal::before { background: var(--entity-personal); }
|
||||
.m-entity-badge.tfox { background: var(--entity-9tfox-bg); color: var(--entity-9tfox); }
|
||||
.m-entity-badge.tfox::before { background: var(--entity-9tfox); }
|
||||
.m-entity-badge.finacode { background: var(--entity-finacode-bg); color: var(--entity-finacode); }
|
||||
.m-entity-badge.finacode::before { background: var(--entity-finacode); }
|
||||
|
||||
.m-chip {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
height: 22px;
|
||||
padding: 0 9px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--r-pill);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.m-chip.rules { background: var(--conf-rules-bg); color: var(--conf-rules); }
|
||||
.m-chip.high { background: var(--conf-high-bg); color: var(--conf-high); }
|
||||
.m-chip.mid { background: var(--conf-mid-bg); color: var(--conf-mid); }
|
||||
.m-chip.low { background: var(--conf-low-bg); color: var(--conf-low); }
|
||||
|
||||
.m-tier {
|
||||
display: inline-flex; align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.m-tier.rules { color: var(--conf-rules); }
|
||||
.m-tier.llm { color: var(--conf-high); }
|
||||
.m-tier.agent { color: var(--conf-mid); }
|
||||
.m-tier.unmatched { color: var(--conf-low); }
|
||||
|
||||
/* =========================================================================
|
||||
DETAIL SCREEN
|
||||
========================================================================= */
|
||||
.m-detail-hero {
|
||||
padding: 20px 16px 16px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
background: var(--paper);
|
||||
}
|
||||
.m-detail-payee {
|
||||
font-family: var(--font-sans-kr);
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-strong);
|
||||
line-height: 1.25;
|
||||
}
|
||||
.m-detail-note {
|
||||
font-size: 13px;
|
||||
color: var(--fg-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.m-detail-amount {
|
||||
margin-top: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.m-detail-meta {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.m-detail-meta .mono { font-family: var(--font-mono); font-size: 12px; color: var(--fg); }
|
||||
.m-detail-meta .dot { color: var(--fg-faint); }
|
||||
.m-source {
|
||||
font-family: var(--font-sans-kr);
|
||||
font-size: 12px;
|
||||
color: var(--fg);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m-section {
|
||||
padding: 16px 16px 18px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.m-section-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-subtle);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.m-suggestion {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 12px 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--rule-ink);
|
||||
border-radius: var(--r-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--fg-on-ink);
|
||||
}
|
||||
.m-arrow { color: var(--conf-rules); font-weight: 600; }
|
||||
.m-acct-text { word-break: break-all; }
|
||||
|
||||
.m-tier-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.m-tier-text { flex: 1; }
|
||||
|
||||
.m-picker-trigger {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: var(--r-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--fg);
|
||||
text-align: left;
|
||||
}
|
||||
.m-picker-trigger:active { background: var(--btn-ghost-hover); }
|
||||
.m-acct-path { flex: 1; word-break: break-all; }
|
||||
.m-acct-path .muted { color: var(--fg-subtle); font-style: italic; }
|
||||
|
||||
.m-ledger-preview {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--rule-ink);
|
||||
border-radius: var(--r-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--fg-on-ink);
|
||||
}
|
||||
.m-ledger-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 40px;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.m-ledger-acct { color: var(--fg-on-ink); }
|
||||
.m-ledger-amt { text-align: right; }
|
||||
.m-ledger-ccy { color: var(--fg-on-ink-muted); text-align: right; }
|
||||
.m-ledger-foot {
|
||||
padding: 6px 14px;
|
||||
border-top: 1px solid var(--rule-ink);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--conf-rules);
|
||||
}
|
||||
.m-ledger-empty {
|
||||
padding: 14px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--fg-muted);
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--rule-ink);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
|
||||
.m-detail-actions {
|
||||
display: flex; gap: 8px;
|
||||
padding: 16px;
|
||||
position: sticky;
|
||||
bottom: 56px;
|
||||
background: var(--bg-translucent);
|
||||
backdrop-filter: blur(8px);
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
.m-action {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
}
|
||||
.m-action.primary {
|
||||
background: var(--btn-bg);
|
||||
color: var(--btn-fg);
|
||||
border-color: var(--btn-bg);
|
||||
flex: 1.4;
|
||||
}
|
||||
.m-action:active { transform: scale(0.99); }
|
||||
|
||||
/* =========================================================================
|
||||
SHEET (account picker)
|
||||
========================================================================= */
|
||||
.m-sheet-overlay {
|
||||
position: absolute; inset: 0;
|
||||
background: rgba(20, 18, 12, 0.32);
|
||||
z-index: 100;
|
||||
display: flex; flex-direction: column; justify-content: flex-end;
|
||||
animation: m-fade var(--dur-slow) var(--ease);
|
||||
}
|
||||
@keyframes m-fade { from { opacity: 0; } to { opacity: 1; } }
|
||||
.m-sheet {
|
||||
background: var(--paper);
|
||||
border-top-left-radius: 16px;
|
||||
border-top-right-radius: 16px;
|
||||
border: 1px solid var(--rule);
|
||||
max-height: 80%;
|
||||
overflow-y: auto;
|
||||
animation: m-slide var(--dur-slow) var(--ease);
|
||||
}
|
||||
@keyframes m-slide { from { transform: translateY(40px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||
.m-sheet-handle {
|
||||
width: 36px; height: 4px;
|
||||
background: var(--rule-strong);
|
||||
border-radius: 2px;
|
||||
margin: 8px auto 0;
|
||||
}
|
||||
.m-sheet-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 16px 10px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.m-sheet-title {
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-size: 18px;
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.m-link {
|
||||
background: none; border: none;
|
||||
font-size: 13px; color: var(--link);
|
||||
padding: 4px 0;
|
||||
}
|
||||
.m-link.strong { font-weight: 600; }
|
||||
.m-sheet-search {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.m-sheet-search input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none; outline: none;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--fg);
|
||||
}
|
||||
.m-sheet-section { padding: 6px 0; border-bottom: 1px solid var(--rule); }
|
||||
.m-sheet-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--fg-subtle);
|
||||
padding: 8px 16px 4px;
|
||||
}
|
||||
.m-sheet-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--fg);
|
||||
}
|
||||
.m-sheet-row:active { background: var(--btn-ghost-hover); }
|
||||
.m-sheet-row.active { background: var(--btn-ghost-hover); }
|
||||
.m-sheet-row .mono { flex: 1; word-break: break-all; }
|
||||
.m-sheet-row.create { color: var(--fg-muted); }
|
||||
.m-mru {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.16em;
|
||||
background: var(--bg-deep);
|
||||
color: var(--fg-subtle);
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
IMPORT SCREEN
|
||||
========================================================================= */
|
||||
.m-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.m-stat {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px;
|
||||
display: flex; flex-direction: column; gap: 3px;
|
||||
}
|
||||
.m-stat-num {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-strong);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
.m-stat-lbl {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
|
||||
.m-import-row {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.m-import-row-top {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.m-import-when {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.m-import-row-src {
|
||||
font-family: var(--font-sans-kr);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.m-import-bar {
|
||||
display: flex;
|
||||
height: 6px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--rule);
|
||||
}
|
||||
.m-import-row-meta {
|
||||
font-size: 11px;
|
||||
color: var(--fg-muted);
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.m-import-row-meta .mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.m-import-row-meta .dot { color: var(--fg-faint); }
|
||||
|
||||
/* =========================================================================
|
||||
LEDGER SCREEN (mobile)
|
||||
========================================================================= */
|
||||
.m-ledger-entity {
|
||||
margin: 12px 0;
|
||||
}
|
||||
.m-ledger-entity-head {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 16px;
|
||||
background: var(--paper);
|
||||
border-top: 1px solid var(--rule);
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.m-ledger-entity-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.m-ledger-entity-meta {
|
||||
font-size: 11px;
|
||||
color: var(--fg-muted);
|
||||
margin-left: auto;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.m-ledger-acct-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
font-size: 12.5px;
|
||||
align-items: center;
|
||||
}
|
||||
.m-ledger-acct-row.branch {
|
||||
background: var(--paper);
|
||||
font-weight: 600;
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.m-ledger-acct-row.leaf {
|
||||
padding-left: 28px;
|
||||
}
|
||||
.m-ledger-acct-row .m-ledger-acct-full {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
RULES SCREEN (mobile)
|
||||
========================================================================= */
|
||||
.m-rule-card {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.m-rule-top {
|
||||
display: flex; align-items: center; gap: 10px; justify-content: space-between;
|
||||
}
|
||||
.m-rule-pattern {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--rule-ink);
|
||||
padding: 3px 8px;
|
||||
border-radius: 2px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.m-rule-map {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--fg-on-ink);
|
||||
padding: 8px 10px;
|
||||
background: var(--surface);
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--rule-ink);
|
||||
}
|
||||
.m-rule-foot {
|
||||
display: flex; align-items: center; gap: 10px; justify-content: space-between;
|
||||
}
|
||||
.m-rule-occ {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--fg-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.m-rule-occ .num {
|
||||
font-size: 14px;
|
||||
color: var(--fg-strong);
|
||||
margin-right: 4px;
|
||||
}
|
||||
.m-rule-promote {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
background: var(--btn-bg);
|
||||
color: var(--btn-fg);
|
||||
border: 1px solid var(--btn-bg);
|
||||
border-radius: 2px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
TAB BAR
|
||||
========================================================================= */
|
||||
.m-tabbar {
|
||||
display: flex;
|
||||
height: 70px;
|
||||
background: var(--bg-translucent);
|
||||
backdrop-filter: blur(8px);
|
||||
border-top: 1px solid var(--rule);
|
||||
padding: 4px 0 22px;
|
||||
}
|
||||
.m-tab {
|
||||
flex: 1;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--fg-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.m-tab.active { color: var(--fg-strong); }
|
||||
.m-tab.active::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
background: var(--fg-strong);
|
||||
border-radius: 1px;
|
||||
}
|
||||
.m-tab { position: relative; }
|
||||
|
||||
/* scrollbar */
|
||||
.m-app *::-webkit-scrollbar { width: 0; height: 0; }
|
||||
Loading…
Add table
Add a link
Reference in a new issue