first commit

This commit is contained in:
Alkim Ake Gozen 2026-05-23 11:59:45 +09:00
commit 8b790b7601
86 changed files with 6348 additions and 0 deletions

204
README.md Normal file
View file

@ -0,0 +1,204 @@
# Akefin Design System
A precision finance design system for **Akefin** — a multi-entity personal & business accounting platform for a solo consultant operating across South Korea (KRW), Turkey (TRY), and Japan (JPY).
This system is tuned for a domain where **density, trust, and bilingual (Latin + Hangul) text rendering** all matter. It draws aesthetic inspiration from `pi.dev` (the user's stated favourite): a calm, paper-light surface, a grid background, generously-italic serif display type, and crisp monospace data.
---
## Source materials
| Source | Notes |
|---|---|
| `alkimake/akefin-design-system` (private GitHub) | Empty at time of writing — this project IS that design system. Re-pull when populated. https://github.com/alkimake/akefin-design-system |
| `uploads/picode_screenshot.png` | The pi.dev landing page, attached as the aesthetic target |
| `uploads/screenshot-2026-05-22_22-25-21.png` | pi.dev code-edit interaction view |
| Akefin brief (in chat) | Three legal entities (Personal / 9TFox / Finacode), 3-tier AI categorization, double-entry plain-text ledger output |
> **For future readers / agents:** if you have access to the GitHub repo above, pull it before designing — there may be updated components, brand marks, or screenshots not reflected here.
---
## Product context
**Akefin** automates the import of Korean Toss bank exports, categorizes transactions through a 3-tier AI pipeline (**Rules → LLM → Agent**), and routes anything unmatched to a human review queue. Final output: plain-text double-entry ledger files, git-versioned for audit.
Primary surfaces:
- **Web dashboard** — the daily-driver. Review queue, rule management, ledger overview, import status.
- **Mobile (iOS/Android)** — same data, optimised for the review-on-the-go case.
Three independently-tracked entities, always visible as scope:
| Badge | Entity | Scope |
|---|---|---|
| **Personal** | Personal finances | KR (KRW), TR (TRY), JP (JPY) |
| **9TFox** | Korean sole trader | Consulting income/expenses (KRW) |
| **Finacode** | Turkish partnership | Partner-share only, Wise transfers |
Core UI primitives:
- **Confidence chip** — green (rules / 100%) → blue (LLM ≥0.85) → amber (0.700.85) → red (<0.70 or unmatched)
- **Tier badge** — Rules · LLM · Agent · Unmatched
- **Entity badge** — three distinct accent hues, used as scope, never as page tint
- **Account picker** — hierarchical, fuzzy-searchable, MRU-aware
- **Ledger preview** — monospace two-leg posting confirmation
---
## CONTENT FUNDAMENTALS
Akefin is professional financial-operations software. Copy reflects that.
**Voice & tone**
- **Authoritative, calm, technical.** Never marketing-y, never cute. Closer to a Unix man page than a SaaS dashboard.
- **Specific over generic.** "23 transactions staged for review" — not "You have new items".
- **No filler verbs.** "Review", "Categorize", "Post" — not "Let's review", "Time to categorize".
- **Domain language is welcome.** Use *ledger*, *posting*, *leg*, *account*, *entity*, *tier*, *match*, *unmatched*, *staged*. Don't over-translate accounting terms for a user who lives in them.
**Person & address**
- Predominantly **imperative** ("Approve", "Override", "Promote rule").
- Where a subject is needed, **you** (sparingly). Never *we*. Never *your money* — say *the ledger* or *this entity*.
**Casing**
- **Sentence case** for body, descriptions, table headers, menu items, button labels longer than two words.
- **UPPERCASE small-tracked** for tab labels, eyebrow labels, section dividers, status strips — the pi.dev `[ READ THE DOCS ]`-style chrome.
- **Title Case** only for proper nouns (entity names, currency codes, *Toss*).
**Numbers, currency, dates**
- Amounts: always **monospace, right-aligned, signed**. `+ 1,240,000 KRW` for income, ` 38,500 KRW` for expense. Decimal separator follows locale, but the column aligns on the decimal.
- Currency code follows the amount, separated by a thin space. Never use the currency symbol alone (₩, ₺, ¥ are ambiguous at a glance across these three).
- Dates: **ISO `YYYY-MM-DD`** in dense data views, `2 Mar 2026` in headers, never `03/02/26`.
- Confidence: two-decimal fixed-point, `0.92`, never `92%`.
**Korean (한국어) handling**
- Hangul appears natively in payee memos (*적요*) and bank institution names. Don't romanise. Don't translate inline.
- When showing a translated/AI-inferred name, render it secondary: `이마트 트레이더스 · E-Mart Traders (inferred)`.
**Emoji & decoration**
- **No emoji.** Anywhere. This is an accounting tool.
- Decorative iconography is sparse — keep glyphs functional (status, direction, expand/collapse).
**Sample copy**
> Review queue · 23 staged · 4 high-confidence · 19 need attention
>
> 2026-03-02 38,500 KRW 스타벅스 강남역점 Personal ★ 0.94 LLM
>
> [ APPROVE ] [ OVERRIDE ] [ SKIP ]
>
> Promote 17 suggested rules · Last import 4 min ago · 9TFox ledger clean
---
## VISUAL FOUNDATIONS
The system is **paper-quiet, grid-anchored, type-led**. It should feel like a precision instrument from a well-lit study, not a SaaS app.
**Surface & background**
- Primary surface: **warm cream paper** (`--bg`, `#ECE7DC`). The default page background.
- Secondary surface: **cool pale ink-grey** (`--surface`, `#EBEEF1`) for code/data blocks, terminal-style frames, ledger previews. The two-surface contrast is the system's signature.
- Background **grid texture** (1px × 1px, ~6% opacity grey lines, 24px spacing) is applied to the page body. This is a load-bearing motif — do not omit.
**Type**
- **Display serif (italic-leaning):** *Spectral* — substitutes for **Plantin MT Pro** which pi.dev uses. Headings prefer italic at light weight. *(Substitution flagged — please supply Plantin MT Pro woff2 to replace.)*
- **Body / UI sans:** *IBM Plex Sans* — used for tab labels, body text, button chrome.
- **Mono / data:** *JetBrains Mono* — all amounts, account paths, dates in dense views, code blocks, ledger postings. **Tabular figures are mandatory.**
- **Korean (Hangul):** *IBM Plex Sans KR* for chrome, *Pretendard* for body when Hangul is the primary content. Hangul lines up at slightly larger optical size than Latin.
- Type ramp lives in `colors_and_type.css`.
**Color**
- Background neutrals first; saturation is reserved for **semantic meaning** (confidence tiers, entity badges).
- Three entity hues — **Personal** (slate teal), **9TFox** (rust), **Finacode** (indigo) — only as badge accent / 4px scope strip, never as a full UI tint.
- Confidence spectrum: green → blue → amber → red. These are the only "decorative" colors in the chrome.
- Full palette in `colors_and_type.css`.
**Spacing & density**
- Base unit `4px`. Compact density: row height `32px` for review-queue lines, `40px` for primary list rows, `48px` for table headers / page tabs.
- Inner padding ramp `4 · 8 · 12 · 16 · 24 · 40`.
- Type at 14px / 1.45 line-height for chrome, 13px / 1.4 mono for data tables.
**Borders**
- **Hairline borders** are the primary divider — `1px solid var(--rule)` (`#D5CFC2` on cream, `#D7DCE2` on ink-grey). No shadow-as-divider.
- Card borders are 1px, never thicker. Border-radius is **2px**. Rounded-everything is explicitly not the look.
- Terminal-style code frames have a 1px border + an inset 1px hairline on the inner edge, mimicking a window chrome.
**Shadows**
- Reserved. Use only for **popovers, account-picker dropdowns, focus-mode dialogs**. Never on cards or inline rows.
- Single elevation: `0 1px 0 rgba(20,18,12,.04), 0 8px 24px -8px rgba(20,18,12,.16)`. Soft, paper-shadow, not material.
**Corner radii**
- `--r-sm: 2px`, `--r-md: 3px`, `--r-lg: 4px`. **Maximum 4px.** Pills (`--r-pill: 999px`) only on confidence chips and tier badges.
**Borders, hover, press**
- **Hover:** cream surfaces darken by ~3% (use `color-mix`); ink-grey surfaces lighten by ~2%. Text links underline on hover; underline thickness `1px`, offset `2px`.
- **Press:** scale `0.99`, never a color flash. Buttons get a 1px inset shadow.
- **Focus:** 2px outline in `--focus` (`#3A6FB0`), offset 2px. No focus ring removal.
**Transparency & blur**
- Used **only** for modals/popovers' backdrop (`rgba(20,18,12,0.32)`) and the global command-K palette. No frosted-glass elsewhere.
**Animation**
- Minimal and short. Cubic-bezier `(0.2, 0, 0.2, 1)`, durations `80ms` (chrome hover), `140ms` (popovers), `220ms` (modal/tab transitions).
- **No bounces, no springs.** A calm, instrument-like motion language. Confidence chips do not pulse, entity badges do not animate in.
**Layout rules**
- Fixed top chrome: a 56px header strip (entity scope + global search + status).
- Optional 240px left navigation rail with collapse to 56px icon strip.
- Content max-width `1440px`; tables and review queue may extend full-bleed inside the rail.
**Imagery vibe**
- Almost no photography. If product imagery is needed: cool, sober, well-lit, slight grain, no people, mostly objects. Default to *no image* and let type do the work.
**Iconography vibe**
- Light-weight (~1.25px stroke), monoline, geometric. Lucide is the substitute (see ICONOGRAPHY below).
**Dark theme** *(secondary)*
- A direct inversion: ink black background (`#13120F`), warm paper foreground. Same hues, same hierarchy. Not the default for the user, but supported.
---
## ICONOGRAPHY
Akefin has **no proprietary icon set** at time of writing. The system uses **Lucide** (https://lucide.dev) as the substitute — its 1.25px monoline geometric style matches the calm-instrument aesthetic.
- Loaded via CDN: `https://unpkg.com/lucide@latest`
- Used at three sizes only: `14px` (inline chrome), `16px` (default UI), `20px` (page headers)
- **No filled variants.** Stroked only.
- **No emoji.** Anywhere.
- **No unicode glyph icons** except: `↑ ↓ →` (sort + direction), `·` (separator), `✓` (matched), `—` (em-dash, range).
- The **Akefin wordmark** is the only branded glyph — it lives at `assets/akefin-wordmark.svg` and is a small monospaced lockup, see Brand cards in preview/.
- Three entity marks (**Personal**, **9TFox**, **Finacode**) are 2-3 letter monograms in mono type with a 4px coloured stripe — see `assets/entity-marks.svg`.
> **Flagged substitution:** Lucide stands in until an Akefin-native icon set is produced. The closest mono-line set was chosen specifically to match the pi.dev stroke weight.
---
## Index
```
/
├── README.md ← you are here
├── SKILL.md ← Claude/Agent Skill entrypoint
├── colors_and_type.css ← all color + type CSS variables + semantic classes
├── fonts/ ← webfonts (Spectral, IBM Plex Sans, IBM Plex Sans KR, JetBrains Mono, Pretendard)
├── assets/ ← logos, entity marks, sample iconography
├── preview/ ← design-system cards (registered in the Design System tab)
└── ui_kits/
├── web/ ← Akefin web dashboard recreation
│ ├── README.md
│ ├── index.html
│ └── *.jsx
└── mobile/ ← Akefin mobile (iOS frame) recreation
├── README.md
├── index.html
└── *.jsx
```
---
## Known caveats
1. **Plantin MT Pro** (pi.dev's display face) is proprietary — substituted with **Spectral** from Google Fonts. Please supply the original `.woff2` to replace.
2. The akefin-design-system GitHub repo was empty when this was generated — components here are synthesised from the brief + pi.dev aesthetic, not lifted from existing code.
3. No real Akefin screenshots were provided. The UI kits are an interpretation of the brief's component inventory, not a recreation of an existing UI. **Iterate.**
4. Dark theme is sketched but light is the priority per user preference.

69
SKILL.md Normal file
View file

@ -0,0 +1,69 @@
---
name: akefin-design
description: Use this skill to generate well-branded interfaces and assets for Akefin, either for production or throwaway prototypes/mocks/etc. Contains essential design guidelines, colors, type, fonts, assets, and UI kit components for prototyping a precision multi-entity finance dashboard with Korean (Hangul) + Latin bilingual support.
user-invocable: true
---
# Akefin Design Skill
**Akefin** is a multi-entity personal & business accounting platform for a solo consultant across Korea (KRW), Turkey (TRY), and Japan (JPY). It automates Korean Toss bank imports, runs them through a 3-tier AI categorization pipeline (Rules → LLM → Agent), and outputs plain-text double-entry ledger files. The visual language is **paper-quiet, grid-anchored, type-led** — heavily inspired by pi.dev: warm cream surface, italic-serif display type, monospaced data, hairline borders, no shadows on cards, no rounded everything.
## Read first
1. `README.md` — full context: company, content fundamentals, visual foundations, iconography.
2. `colors_and_type.css` — CSS variables for color + type, and ready-made semantic classes (`.chip`, `.entity-badge`, `.tier`, `.amount`, `.term`, etc).
3. `preview/` — small specimen cards showing every primitive. Skim before designing.
4. `ui_kits/web/` and `ui_kits/mobile/` — full reference implementations to copy components from.
## When invoked
If the user gives you a clear request (e.g. *"design the rules promotion modal"*, *"make a 6-slide deck on Akefin's import pipeline"*), proceed and produce HTML artifacts that:
1. **Import the system stylesheet:** `<link rel="stylesheet" href="path/to/colors_and_type.css">`. Always.
2. **Use the cream grid background** on the page body (`.grid-bg` class, or set `background-image` per the variable scheme).
3. **Use the two-surface system:** cream (`--bg`, `--paper`) for chrome, ink-grey (`--surface`) for data containers. The contrast is the signature.
4. **Use the existing atoms** instead of inventing new ones: `.chip` (confidence), `.entity-badge`, `.tier`, `.amount`, `.btn`, `.term`. Look in `ui_kits/web/dashboard.css` if you need more class definitions.
5. **Handle Korean text gracefully**`font-family: var(--font-sans-kr)` (Pretendard) on any element that may contain Hangul. Don't romanise unless explicitly for an inferred-name secondary line.
6. **Amounts:** always mono, right-aligned, signed (`+ 1,240,000 KRW` / ` 38,500 KRW`), currency code follows.
7. **Voice:** authoritative, calm, technical. Imperative verbs. UPPERCASE small-tracked mono labels for tab/chrome. Never emoji.
8. **Iconography:** Lucide via CDN, 1.5px stroke, 1620px. Don't draw new SVG icons.
If the user gives you a vague request (*"design something for Akefin"*), ask 35 questions: what surface (web / mobile / slide), what flow, do they want variants, etc.
## Quick reference — the most-used pieces
| Need | Use |
|---|---|
| Background | `class="grid-bg"` on body |
| Page heading | `<h1 class="display-l">…</h1>` (italic serif) |
| Tab/chrome label | `<span class="eyebrow">REVIEW QUEUE</span>` |
| Confidence chip | `<span class="chip high">★ 0.94</span>` (rules/high/mid/low) |
| Entity badge | `<span class="entity-badge personal">Personal</span>` (personal/tfox/finacode) |
| Tier badge | `<span class="tier llm">LLM</span>` |
| Money | `<span class="amount neg">38,500<span class="ccy">KRW</span></span>` |
| Data block | `<div class="term">…mono content…</div>` with `.term-title` header |
| Primary button | `<button class="btn primary">APPROVE</button>` |
| Bracket button (pi.dev style) | `<button class="btn bracket">READ DOCS</button>` |
## What to AVOID
- Emoji of any kind
- Drop shadows on cards (only on popovers)
- Colored gradients
- Border-radius > 4px (chips/pills excepted)
- Heavy borders > 1px
- Color-tinted whole-page backgrounds based on entity (entity is accent, not tint)
- Inter, Roboto, system-default fonts — use Spectral / IBM Plex Sans / JetBrains Mono / Pretendard
- Page-wide rounded "card with left accent border" — explicit anti-pattern
## Output modes
- **Production code:** Copy `colors_and_type.css` and `assets/` into the target project. Reference variables directly; don't redefine.
- **Throwaway prototype:** Reference the system files via relative paths from the project root. Produce ONE HTML file unless explicitly asked for more.
- **Slides:** Use `deck_stage.js` starter, light theme default. Display headings in italic Spectral. Don't pad with filler content.
## Notes from the field
- Plantin MT Pro is the original target display face; `Spectral` is a Google Fonts substitute — flag this if the user expects exact pi.dev parity.
- The akefin-design-system GitHub repo was empty at time of generation. If it's been populated since, prefer its components over what's here.
- This skill is best when paired with screenshots of the existing Akefin product (none were provided originally).

11
assets/akefin-mark.svg Normal file
View file

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="Akefin mark">
<g fill="#1A1814">
<rect x="6" y="14" width="6" height="28"></rect>
<rect x="12" y="8" width="6" height="6"></rect>
<rect x="18" y="8" width="6" height="6"></rect>
<rect x="24" y="14" width="6" height="28"></rect>
<rect x="12" y="26" width="12" height="6"></rect>
</g>
<rect x="36" y="36" width="6" height="6" fill="#2F7D55"></rect>
</svg>

After

Width:  |  Height:  |  Size: 488 B

View file

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 32" width="160" height="32" role="img" aria-label="Akefin">
<g fill="#1A1814">
<rect x="0" y="6" width="4" height="20"></rect>
<rect x="4" y="2" width="4" height="4"></rect>
<rect x="8" y="2" width="4" height="4"></rect>
<rect x="12" y="6" width="4" height="20"></rect>
<rect x="4" y="14" width="8" height="4"></rect>
</g>
<text x="26" y="22" font-family="&#39;JetBrains Mono&#39;,&#39;IBM Plex Mono&#39;,ui-monospace,monospace" font-size="16" font-weight="600" letter-spacing="0.04em" fill="#1A1814">akefin</text>
</svg>

After

Width:  |  Height:  |  Size: 615 B

24
assets/entity-marks.svg Normal file
View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 96" width="320" height="96">
<g transform="translate(8,12)">
<rect x="0" y="0" width="4" height="48" fill="#3D6E70"></rect>
<text x="14" y="22" class="mono" fill="#1A1814">P/</text>
<text x="14" y="44" class="label" fill="#5A5347">PERSONAL</text>
</g>
<g transform="translate(112,12)">
<rect x="0" y="0" width="4" height="48" fill="#B4541A"></rect>
<text x="14" y="22" class="mono" fill="#1A1814">9T/</text>
<text x="14" y="44" class="label" fill="#5A5347">9TFOX</text>
</g>
<g transform="translate(216,12)">
<rect x="0" y="0" width="4" height="48" fill="#5A4FA3"></rect>
<text x="14" y="22" class="mono" fill="#1A1814">FC/</text>
<text x="14" y="44" class="label" fill="#5A5347">FINACODE</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 827 B

8
assets/favicon.svg Normal file
View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<rect x="0" y="0" width="16" height="16" fill="#1A1814" stroke="none"></rect>
<g stroke="#ECE7DC" fill="none">
<path d="M3 13 L3 5 L6 3 L10 3 L13 5 L13 13"></path>
<path d="M3 9 L13 9"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 406 B

477
colors_and_type.css Normal file
View file

@ -0,0 +1,477 @@
/* =========================================================================
AKEFIN Colors & Type
Light theme primary; dark theme via [data-theme="dark"].
========================================================================= */
/* ---- Fonts ------------------------------------------------------------- */
/* Display serif (italic-leaning) — substitute for Plantin MT Pro */
@import url("https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,300;0,400;0,500;0,600;1,300;1,400;1,500&display=swap");
/* UI sans (Latin) */
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&display=swap");
/* UI sans (Korean) — IBM Plex Sans KR for chrome, Pretendard for body */
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+KR:wght@300;400;500;600;700&display=swap");
@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css");
/* Mono / data */
@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap");
/* =========================================================================
1. COLOR SYSTEM
========================================================================= */
:root {
/* ---- Base neutrals (light) ------------------------------------------- */
--bg: #ECE7DC; /* cream paper — primary page */
--bg-deep: #E2DCCC; /* recessed cream — sidebar / panels */
--surface: #EBEEF1; /* cool pale ink-grey — code/data blocks */
--surface-deep: #DEE3E8; /* recessed ink-grey */
--paper: #F5F1E8; /* lifted paper — cards on cream */
--black: #1A1814; /* primary ink */
/* ---- Foreground ramp ------------------------------------------------- */
--fg: #1A1814; /* primary text */
--fg-strong: #0A0907; /* headings */
--fg-muted: #5A5347; /* secondary text */
--fg-subtle: #8A8377; /* tertiary / metadata */
--fg-faint: #B5AE9F; /* placeholder / disabled */
--fg-on-ink: #2A2E33; /* text on --surface */
--fg-on-ink-muted: #6B7178;
/* ---- Structural ------------------------------------------------------ */
--rule: #D5CFC2; /* hairline on cream */
--rule-ink: #D7DCE2; /* hairline on ink-grey */
--rule-strong: #B5AE9F;
--focus: #3A6FB0; /* focus ring */
--selection: rgba(58, 111, 176, 0.18);
/* ---- Grid texture --------------------------------------------------- */
--grid-line: rgba(26, 24, 20, 0.045);
--grid-step: 24px;
--bg-translucent: rgba(236, 231, 220, 0.92);
/* ---- Confidence spectrum (semantic) --------------------------------- */
--conf-rules: #2F7D55; /* Tier 1, deterministic rules — forest green */
--conf-rules-bg: #DCE9DF;
--conf-high: #2A6FB0; /* Tier 2 LLM ≥0.85 — blue */
--conf-high-bg: #DCE5EE;
--conf-mid: #B5740A; /* Tier 2/3 0.700.85 — amber */
--conf-mid-bg: #EFE2C8;
--conf-low: #B8362B; /* <0.70 or unmatched — red */
--conf-low-bg: #ECD5D1;
/* ---- Entity hues (badge accent ONLY — never page tint) -------------- */
--entity-personal: #3D6E70; /* slate teal */
--entity-personal-bg: #DCE5E5;
--entity-9tfox: #B4541A; /* rust orange */
--entity-9tfox-bg: #EFDDCB;
--entity-finacode: #5A4FA3; /* indigo */
--entity-finacode-bg: #DEDAEE;
/* ---- Signed amounts ------------------------------------------------- */
--amount-pos: #2F7D55; /* income / credit */
--amount-neg: #1A1814; /* expense / debit — neutral ink */
--amount-warn: #B8362B; /* unbalanced / error */
/* ---- Interactive ---------------------------------------------------- */
--link: #2A6FB0;
--link-hover: #1A4F8A;
--btn-bg: #1A1814;
--btn-fg: #ECE7DC;
--btn-bg-hover: #2A2620;
--btn-ghost-hover: rgba(26, 24, 20, 0.06);
/* ---- Shadow --------------------------------------------------------- */
--shadow-pop: 0 1px 0 rgba(20,18,12,.04), 0 8px 24px -8px rgba(20,18,12,.16);
--shadow-inset-press: inset 0 1px 0 rgba(20,18,12,.18);
/* ---- Radius --------------------------------------------------------- */
--r-sm: 2px;
--r-md: 3px;
--r-lg: 4px;
--r-pill: 999px;
/* ---- Spacing scale (4px base) --------------------------------------- */
--s-1: 4px;
--s-2: 8px;
--s-3: 12px;
--s-4: 16px;
--s-5: 24px;
--s-6: 32px;
--s-7: 40px;
--s-8: 56px;
/* ---- Row densities (review queue, tables) --------------------------- */
--row-compact: 32px;
--row-default: 40px;
--row-roomy: 48px;
/* ---- Motion --------------------------------------------------------- */
--ease: cubic-bezier(0.2, 0, 0.2, 1);
--dur-fast: 80ms;
--dur-mid: 140ms;
--dur-slow: 220ms;
}
/* =========================================================================
1b. DARK THEME (inverted; same hues, same hierarchy)
========================================================================= */
[data-theme="dark"] {
--bg: #13120F;
--bg-deep: #0C0B09;
--surface: #1B1E22;
--surface-deep: #14171A;
--paper: #21201C; /* lifted paper on dark — visibly above bg */
--fg: #ECE7DC;
--fg-strong: #F5F1E8;
--fg-muted: #A29B8C;
--fg-subtle: #6F6A5E;
--fg-faint: #4C4940;
--fg-on-ink: #D5DAE0;
--fg-on-ink-muted: #8A9098;
--rule: #2A2620;
--rule-ink: #2A2E33;
--rule-strong: #4C4940;
--grid-line: rgba(236, 231, 220, 0.04);
--bg-translucent: rgba(19, 18, 15, 0.92);
--conf-rules-bg: #1A2B22;
--conf-high-bg: #182434;
--conf-mid-bg: #2D2415;
--conf-low-bg: #2A1714;
--entity-personal-bg: #1B2828;
--entity-9tfox-bg: #2A1B0F;
--entity-finacode-bg: #1F1B30;
--btn-bg: #ECE7DC;
--btn-fg: #1A1814;
--btn-bg-hover: #D5CFC2;
--btn-ghost-hover: rgba(236, 231, 220, 0.06);
}
/* =========================================================================
2. TYPE SYSTEM
========================================================================= */
:root {
--font-display: "Spectral", "Plantin MT Pro", "Source Serif Pro", Georgia, serif;
--font-sans: "IBM Plex Sans", "IBM Plex Sans KR", "Pretendard", system-ui, -apple-system, sans-serif;
--font-sans-kr: "Pretendard", "IBM Plex Sans KR", "IBM Plex Sans", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
/* Latin type ramp */
--t-display-xl: 56px;
--t-display-l: 40px;
--t-display-m: 28px;
--t-h1: 22px;
--t-h2: 18px;
--t-h3: 15px;
--t-body: 14px;
--t-body-sm: 13px;
--t-caption: 12px;
--t-eyebrow: 11px;
/* Mono ramp (data) */
--t-mono-amount: 14px;
--t-mono-table: 13px;
--t-mono-code: 13px;
--t-mono-meta: 12px;
/* Line heights */
--lh-display: 1.1;
--lh-heading: 1.25;
--lh-body: 1.45;
--lh-mono: 1.4;
--lh-tight: 1.2;
}
/* ---- Reset-ish defaults ------------------------------------------------ */
html {
font-family: var(--font-sans);
font-size: var(--t-body);
line-height: var(--lh-body);
color: var(--fg);
background: var(--bg);
font-feature-settings: "ss01", "cv11"; /* IBM Plex stylistic alts */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body { margin: 0; }
::selection { background: var(--selection); }
/* =========================================================================
3. SEMANTIC TYPE CLASSES
========================================================================= */
.display-xl, .display-l, .display-m {
font-family: var(--font-display);
font-weight: 400;
font-style: italic;
color: var(--fg-strong);
line-height: var(--lh-display);
letter-spacing: -0.01em;
margin: 0;
}
.display-xl { font-size: var(--t-display-xl); }
.display-l { font-size: var(--t-display-l); }
.display-m { font-size: var(--t-display-m); }
h1, .h1 {
font-family: var(--font-sans);
font-size: var(--t-h1);
font-weight: 500;
line-height: var(--lh-heading);
color: var(--fg-strong);
margin: 0;
letter-spacing: -0.005em;
}
h2, .h2 {
font-family: var(--font-sans);
font-size: var(--t-h2);
font-weight: 500;
line-height: var(--lh-heading);
color: var(--fg-strong);
margin: 0;
}
h3, .h3 {
font-family: var(--font-sans);
font-size: var(--t-h3);
font-weight: 600;
line-height: var(--lh-heading);
color: var(--fg-strong);
margin: 0;
}
p, .p {
font-family: var(--font-sans);
font-size: var(--t-body);
line-height: var(--lh-body);
color: var(--fg);
margin: 0 0 var(--s-3);
}
.body-sm { font-size: var(--t-body-sm); line-height: var(--lh-body); }
.caption { font-size: var(--t-caption); color: var(--fg-muted); line-height: var(--lh-body); }
.muted { color: var(--fg-muted); }
.subtle { color: var(--fg-subtle); }
/* The pi.dev-style chrome label */
.eyebrow, .label-up {
font-family: var(--font-mono);
font-size: var(--t-eyebrow);
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--fg-muted);
}
/* Mono / data */
code, .mono, .code {
font-family: var(--font-mono);
font-size: var(--t-mono-code);
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum", "zero", "ss02";
}
.amount {
font-family: var(--font-mono);
font-size: var(--t-mono-amount);
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum", "zero";
font-weight: 500;
text-align: right;
white-space: nowrap;
color: var(--fg);
}
.amount.pos { color: var(--amount-pos); }
.amount.pos::before { content: "+ "; }
.amount.neg::before { content: " "; }
.amount .ccy { color: var(--fg-subtle); margin-left: 0.4em; font-weight: 400; }
/* Korean text helper */
.ko, [lang="ko"] {
font-family: var(--font-sans-kr);
font-feature-settings: normal;
letter-spacing: -0.005em;
}
/* Link */
a, .link {
color: var(--link);
text-decoration: none;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
transition: color var(--dur-fast) var(--ease);
}
a:hover, .link:hover { color: var(--link-hover); text-decoration: underline; }
/* =========================================================================
4. GRID BACKGROUND (load-bearing motif apply to body or page wrapper)
========================================================================= */
.grid-bg {
background-color: 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: var(--grid-step) var(--grid-step);
}
/* =========================================================================
5. PRIMITIVES used everywhere
========================================================================= */
/* Hairline rule */
.hr { border: 0; border-top: 1px solid var(--rule); margin: 0; }
.hr-ink { border: 0; border-top: 1px solid var(--rule-ink); margin: 0; }
/* Card (1px border, 2-4px radius, no shadow) */
.card {
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--r-lg);
padding: var(--s-4);
}
/* Terminal / data frame (the pi.dev look) */
.term {
background: var(--surface);
border: 1px solid var(--rule-ink);
border-radius: var(--r-sm);
font-family: var(--font-mono);
font-size: var(--t-mono-code);
color: var(--fg-on-ink);
padding: var(--s-3) var(--s-4);
position: relative;
}
.term-title {
font-family: var(--font-mono);
font-size: var(--t-eyebrow);
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--fg-on-ink-muted);
padding: var(--s-2) var(--s-4);
border-bottom: 1px solid var(--rule-ink);
display: flex;
align-items: center;
gap: var(--s-2);
}
.term-title::after {
content: "";
width: 6px; height: 6px;
border-radius: 50%;
background: var(--conf-rules);
margin-left: auto;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: var(--s-2);
height: 32px;
padding: 0 var(--s-3);
font-family: var(--font-mono);
font-size: var(--t-eyebrow);
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
border: 1px solid var(--rule-strong);
border-radius: var(--r-sm);
background: transparent;
color: var(--fg);
cursor: pointer;
transition: background var(--dur-fast) var(--ease), transform var(--dur-fast) var(--ease);
user-select: none;
}
.btn:hover { background: var(--btn-ghost-hover); }
.btn:active { transform: scale(0.99); box-shadow: var(--shadow-inset-press); }
.btn.primary {
background: var(--btn-bg);
color: var(--btn-fg);
border-color: var(--btn-bg);
}
.btn.primary:hover { background: var(--btn-bg-hover); }
.btn.bracket::before { content: "[ "; }
.btn.bracket::after { content: " ]"; }
.btn.bracket { border: none; padding: 0; height: auto; background: transparent; }
.btn.bracket:hover { background: transparent; color: var(--fg-strong); }
/* Chips */
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 20px;
padding: 0 8px;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.04em;
border-radius: var(--r-pill);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.chip.rules { background: var(--conf-rules-bg); color: var(--conf-rules); }
.chip.high { background: var(--conf-high-bg); color: var(--conf-high); }
.chip.mid { background: var(--conf-mid-bg); color: var(--conf-mid); }
.chip.low { background: var(--conf-low-bg); color: var(--conf-low); }
/* Entity badge */
.entity-badge {
display: inline-flex;
align-items: center;
gap: 6px;
height: 20px;
padding: 0 8px 0 6px;
font-family: var(--font-sans);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
border-radius: var(--r-sm);
border: 1px solid transparent;
background: transparent;
color: var(--fg);
}
.entity-badge::before {
content: "";
width: 4px;
height: 12px;
border-radius: 1px;
background: var(--entity-personal);
}
.entity-badge.personal { background: var(--entity-personal-bg); color: var(--entity-personal); }
.entity-badge.personal::before { background: var(--entity-personal); }
.entity-badge.tfox { background: var(--entity-9tfox-bg); color: var(--entity-9tfox); }
.entity-badge.tfox::before { background: var(--entity-9tfox); }
.entity-badge.finacode { background: var(--entity-finacode-bg); color: var(--entity-finacode); }
.entity-badge.finacode::before { background: var(--entity-finacode); }
/* Tier badge */
.tier {
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 6px;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
border-radius: var(--r-sm);
border: 1px solid currentColor;
background: transparent;
}
.tier.rules { color: var(--conf-rules); }
.tier.llm { color: var(--conf-high); }
.tier.agent { color: var(--conf-mid); }
.tier.unmatched { color: var(--conf-low); }
/* Inputs */
.input {
height: 32px;
padding: 0 10px;
font-family: var(--font-sans);
font-size: var(--t-body-sm);
background: var(--bg);
border: 1px solid var(--rule-strong);
border-radius: var(--r-sm);
color: var(--fg);
transition: border-color var(--dur-fast) var(--ease);
}
.input:focus { outline: 2px solid var(--focus); outline-offset: 1px; border-color: var(--focus); }
.input.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }

44
preview/_card.css Normal file
View file

@ -0,0 +1,44 @@
/* Shared preview-card scaffolding — keep cards minimal, no titles inside */
html, body { margin: 0; padding: 0; }
body {
font-family: "IBM Plex Sans", system-ui, sans-serif;
color: var(--fg);
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: var(--grid-step) var(--grid-step);
padding: 24px;
box-sizing: border-box;
min-height: 100vh;
}
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.col { display: flex; flex-direction: column; gap: 8px; }
.swatch {
display: flex; flex-direction: column; gap: 4px;
min-width: 88px;
}
.swatch .chip-color {
width: 100%; height: 56px;
border: 1px solid var(--rule);
border-radius: 2px;
}
.swatch .name {
font-family: "JetBrains Mono", monospace;
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-muted);
}
.swatch .val {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
color: var(--fg);
}
.legend {
font-family: "JetBrains Mono", monospace;
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--fg-muted);
}

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { display:flex; align-items:center; padding:32px; }
</style>
</head>
<body>
<img src="../assets/entity-marks.svg" width="320" height="96" alt="Entity marks">
</body></html>

20
preview/brand-mark.html Normal file
View file

@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { display:flex; align-items:center; gap:40px; padding:32px; }
.stack { display:flex; flex-direction:column; gap:8px; align-items:center; }
.lbl { font-family:"JetBrains Mono",monospace; font-size:10px; letter-spacing:0.14em; text-transform:uppercase; color:var(--fg-muted); }
.mark-box { display:flex; align-items:center; justify-content:center; width:88px; height:88px; background:var(--paper); border:1px solid var(--rule); }
.mark-box.inverse { background:#1A1814; }
</style>
</head>
<body>
<div class="stack"><div class="mark-box"><img src="../assets/akefin-mark.svg" width="48" height="48" alt=""></div><div class="lbl">mark · on paper</div></div>
<div class="stack"><div class="mark-box inverse"><svg viewBox="0 0 48 48" width="48" height="48"><g fill="#ECE7DC"><rect x="6" y="14" width="6" height="28"/><rect x="12" y="8" width="6" height="6"/><rect x="18" y="8" width="6" height="6"/><rect x="24" y="14" width="6" height="28"/><rect x="12" y="26" width="12" height="6"/></g><rect x="36" y="36" width="6" height="6" fill="#2F7D55"/></svg></div><div class="lbl">mark · on ink</div></div>
<div class="stack"><div class="mark-box" style="width:32px;height:32px"><img src="../assets/favicon.svg" width="16" height="16" alt=""></div><div class="lbl">favicon · 16</div></div>
<div class="stack"><div class="mark-box" style="background:var(--surface)"><img src="../assets/akefin-mark.svg" width="48" height="48" alt=""></div><div class="lbl">on ink-grey</div></div>
</body></html>

View file

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { display:flex; align-items:center; padding: 40px; }
.lockup { display:flex; flex-direction:column; gap:24px; }
.big { transform: scale(2); transform-origin: left center; }
</style>
</head>
<body>
<div class="lockup">
<div class="big"><img src="../assets/akefin-wordmark.svg" alt="Akefin"></div>
<div class="legend">primary lockup · monospaced wordmark · pixel monogram</div>
</div>
</body></html>

View file

@ -0,0 +1,54 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
.scale-bar { display:flex; height:10px; border-radius:999px; overflow:hidden; margin-top:8px; border:1px solid var(--rule); }
.scale-bar > div { flex:1; }
.chips { display:flex; gap:8px; flex-wrap:wrap; margin-top:14px; }
</style>
</head>
<body>
<div class="row">
<div class="swatch">
<div class="chip-color" style="background:#2F7D55;"></div>
<div class="name">rules · 1.00</div>
<div class="val">#2F7D55</div>
</div>
<div class="swatch">
<div class="chip-color" style="background:#2A6FB0;"></div>
<div class="name">high · ≥0.85</div>
<div class="val">#2A6FB0</div>
</div>
<div class="swatch">
<div class="chip-color" style="background:#B5740A;"></div>
<div class="name">mid · 0.70-0.85</div>
<div class="val">#B5740A</div>
</div>
<div class="swatch">
<div class="chip-color" style="background:#B8362B;"></div>
<div class="name">low · <0.70</div>
<div class="val">#B8362B</div>
</div>
</div>
<div class="chips">
<span class="chip rules">★ 1.00 RULES</span>
<span class="chip high">★ 0.92 LLM</span>
<span class="chip mid">★ 0.78 LLM</span>
<span class="chip low">★ 0.41 UNMATCHED</span>
</div>
<div class="legend" style="margin-top:14px">confidence spectrum · the only "decorative" colour in chrome</div>
<style>
.chip { display:inline-flex; align-items:center; gap:6px; height:20px; padding:0 8px; font-family:"JetBrains Mono",monospace; font-size:11px; font-weight:500; letter-spacing:0.04em; border-radius:999px; font-variant-numeric:tabular-nums; }
.chip.rules { background:#DCE9DF; color:#2F7D55; }
.chip.high { background:#DCE5EE; color:#2A6FB0; }
.chip.mid { background:#EFE2C8; color:#B5740A; }
.chip.low { background:#ECD5D1; color:#B8362B; }
</style>
</body></html>

56
preview/colors-dark.html Normal file
View file

@ -0,0 +1,56 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { background:#13120F; background-image:
linear-gradient(to right, rgba(236,231,220,0.04) 1px, transparent 1px),
linear-gradient(to bottom, rgba(236,231,220,0.04) 1px, transparent 1px);
background-size:24px 24px; padding:24px; }
.swatch .name, .swatch .val, .legend { color:#A29B8C !important; }
.swatch .chip-color { border-color:#2A2620; }
</style>
</head>
<body>
<div class="row">
<div class="swatch">
<div class="chip-color" style="background:#13120F;"></div>
<div class="name">--bg</div>
<div class="val">#13120F</div>
</div>
<div class="swatch">
<div class="chip-color" style="background:#1B1E22;"></div>
<div class="name">--surface</div>
<div class="val">#1B1E22</div>
</div>
<div class="swatch">
<div class="chip-color" style="background:#1C1A16;"></div>
<div class="name">--paper</div>
<div class="val">#1C1A16</div>
</div>
<div class="swatch">
<div class="chip-color" style="background:#ECE7DC;"></div>
<div class="name">--fg</div>
<div class="val">#ECE7DC</div>
</div>
<div class="swatch">
<div class="chip-color" style="background:#A29B8C;"></div>
<div class="name">--fg-muted</div>
<div class="val">#A29B8C</div>
</div>
<div class="swatch">
<div class="chip-color" style="background:#2A2620;"></div>
<div class="name">--rule</div>
<div class="val">#2A2620</div>
</div>
</div>
<div class="legend" style="margin-top:14px">dark theme · inverted · same hierarchy</div>
</body></html>

View file

@ -0,0 +1,42 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
.badge { display:inline-flex; align-items:center; gap:6px; height:22px; padding:0 8px 0 6px; font-family:"IBM Plex Sans",sans-serif; font-size:11px; font-weight:600; border-radius:2px; }
.badge::before { content:""; width:4px; height:13px; border-radius:1px; }
.b1 { background:#DCE5E5; color:#3D6E70; } .b1::before { background:#3D6E70; }
.b2 { background:#EFDDCB; color:#B4541A; } .b2::before { background:#B4541A; }
.b3 { background:#DEDAEE; color:#5A4FA3; } .b3::before { background:#5A4FA3; }
</style>
</head>
<body>
<div class="row">
<div class="swatch">
<div class="chip-color" style="background:#3D6E70;"></div>
<div class="name">personal</div>
<div class="val">#3D6E70</div>
</div>
<div class="swatch">
<div class="chip-color" style="background:#B4541A;"></div>
<div class="name">9tfox</div>
<div class="val">#B4541A</div>
</div>
<div class="swatch">
<div class="chip-color" style="background:#5A4FA3;"></div>
<div class="name">finacode</div>
<div class="val">#5A4FA3</div>
</div>
</div>
<div class="row" style="margin-top:14px; gap:8px;">
<span class="badge b1">Personal</span>
<span class="badge b2">9TFox</span>
<span class="badge b3">Finacode</span>
</div>
<div class="legend" style="margin-top:12px">accent only · never page tint · 4px stripe + tinted bg</div>
</body></html>

View file

@ -0,0 +1,49 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style></style>
</head>
<body>
<div class="row">
<div class="swatch">
<div class="chip-color" style="background:#0A0907;"></div>
<div class="name">--fg-strong</div>
<div class="val">#0A0907</div>
</div>
<div class="swatch">
<div class="chip-color" style="background:#1A1814;"></div>
<div class="name">--fg</div>
<div class="val">#1A1814</div>
</div>
<div class="swatch">
<div class="chip-color" style="background:#5A5347;"></div>
<div class="name">--fg-muted</div>
<div class="val">#5A5347</div>
</div>
<div class="swatch">
<div class="chip-color" style="background:#8A8377;"></div>
<div class="name">--fg-subtle</div>
<div class="val">#8A8377</div>
</div>
<div class="swatch">
<div class="chip-color" style="background:#B5AE9F;"></div>
<div class="name">--fg-faint</div>
<div class="val">#B5AE9F</div>
</div>
<div class="swatch">
<div class="chip-color" style="background:#D5CFC2;"></div>
<div class="name">--rule</div>
<div class="val">#D5CFC2</div>
</div>
</div>
<div class="legend" style="margin-top:14px">foreground ramp on cream · 6-step warm grey</div>
</body></html>

View file

@ -0,0 +1,49 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style></style>
</head>
<body>
<div class="row">
<div class="swatch">
<div class="chip-color" style="background:#ECE7DC;"></div>
<div class="name">--bg</div>
<div class="val">#ECE7DC</div>
</div>
<div class="swatch">
<div class="chip-color" style="background:#E2DCCC;"></div>
<div class="name">--bg-deep</div>
<div class="val">#E2DCCC</div>
</div>
<div class="swatch">
<div class="chip-color" style="background:#F5F1E8;"></div>
<div class="name">--paper</div>
<div class="val">#F5F1E8</div>
</div>
<div class="swatch">
<div class="chip-color" style="background:#EBEEF1;"></div>
<div class="name">--surface</div>
<div class="val">#EBEEF1</div>
</div>
<div class="swatch">
<div class="chip-color" style="background:#DEE3E8;"></div>
<div class="name">--surface-deep</div>
<div class="val">#DEE3E8</div>
</div>
<div class="swatch">
<div class="chip-color" style="background:#1A1814;"></div>
<div class="name">--black</div>
<div class="val">#1A1814</div>
</div>
</div>
<div class="legend" style="margin-top:14px">two-surface system · cream (paper) + ink-grey (data) · signature</div>
</body></html>

View file

@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
.row-data { display:grid; grid-template-columns: auto auto auto; gap:6px 32px; align-items:center; margin-top:6px; }
.lbl { font-family:"JetBrains Mono",monospace; font-size:10px; letter-spacing:0.12em; text-transform:uppercase; color:var(--fg-muted); }
.amt { font-family:"JetBrains Mono",monospace; font-variant-numeric:tabular-nums; font-size:16px; font-weight:500; text-align:right; }
.pos { color:#2F7D55; }
.neg { color:#1A1814; }
.warn { color:#B8362B; }
.ccy { color:#8A8377; font-weight:400; margin-left:6px; }
</style>
</head>
<body>
<div class="row-data">
<div class="lbl">income</div><div class="amt pos">+ 1,240,000<span class="ccy">KRW</span></div><div class="lbl">--amount-pos</div>
<div class="lbl">expense</div><div class="amt neg"> 38,500<span class="ccy">KRW</span></div><div class="lbl">--amount-neg</div>
<div class="lbl">unbalanced</div><div class="amt warn">! 12,300<span class="ccy">TRY</span></div><div class="lbl">--amount-warn</div>
<div class="lbl">multi-ccy</div><div class="amt neg"> 7,820<span class="ccy">JPY</span></div><div class="lbl">tabular nums</div>
</div>
<div class="legend" style="margin-top:14px">amounts · always right-aligned · always mono · signed · code follows</div>
</body></html>

30
preview/comp-buttons.html Normal file
View file

@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { padding:28px; display:flex; flex-direction:column; gap:18px; }
.row { display:flex; gap:12px; flex-wrap:wrap; align-items:center; }
.btn { display:inline-flex; align-items:center; gap:8px; height:32px; padding:0 12px; font-family:"JetBrains Mono",monospace; font-size:11px; font-weight:500; letter-spacing:0.12em; text-transform:uppercase; border:1px solid #B5AE9F; border-radius:2px; background:transparent; color:#1A1814; cursor:pointer; }
.btn:hover { background:rgba(26,24,20,0.06); }
.btn.primary { background:#1A1814; color:#ECE7DC; border-color:#1A1814; }
.btn.danger { color:#B8362B; border-color:#B8362B; }
.btn.bracket { border:none; padding:0; background:transparent; color:#1A1814; }
</style>
</head>
<body>
<div class="row">
<button class="btn primary">Approve</button>
<button class="btn">Override</button>
<button class="btn">Skip</button>
<button class="btn danger">Reject</button>
</div>
<div class="row">
<span class="btn bracket">[ APPROVE ]</span>
<span class="btn bracket">[ OVERRIDE ]</span>
<span class="btn bracket">[ SKIP ]</span>
</div>
<div class="legend">solid primary · ghost · bracket (pi.dev style) · danger</div>
</body></html>

View file

@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { padding:28px; }
.row { display:flex; gap:10px; flex-wrap:wrap; }
.chip { display:inline-flex; align-items:center; gap:6px; height:22px; padding:0 10px; font-family:"JetBrains Mono",monospace; font-size:11px; font-weight:500; border-radius:999px; font-variant-numeric:tabular-nums; }
.chip.rules { background:#DCE9DF; color:#2F7D55; }
.chip.high { background:#DCE5EE; color:#2A6FB0; }
.chip.mid { background:#EFE2C8; color:#B5740A; }
.chip.low { background:#ECD5D1; color:#B8362B; }
.chip::before { content:"★"; }
</style>
</head>
<body>
<div class="row">
<span class="chip rules">1.00 RULES</span>
<span class="chip high">0.94 LLM</span>
<span class="chip high">0.87 LLM</span>
<span class="chip mid">0.78 LLM</span>
<span class="chip mid">0.71 AGENT</span>
<span class="chip low">0.41 AGENT</span>
<span class="chip low">— UNMATCHED</span>
</div>
<div class="legend" style="margin-top:14px">score + tier in one chip · star precedes value</div>
</body></html>

View file

@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { padding:28px; }
.row { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.badge { display:inline-flex; align-items:center; gap:6px; height:22px; padding:0 8px 0 6px; font-family:"IBM Plex Sans",sans-serif; font-size:12px; font-weight:600; border-radius:2px; }
.badge::before { content:""; width:4px; height:13px; border-radius:1px; }
.b1 { background:#DCE5E5; color:#3D6E70; } .b1::before { background:#3D6E70; }
.b2 { background:#EFDDCB; color:#B4541A; } .b2::before { background:#B4541A; }
.b3 { background:#DEDAEE; color:#5A4FA3; } .b3::before { background:#5A4FA3; }
.strip { display:inline-flex; align-items:center; gap:8px; padding:6px 10px; background:#F5F1E8; border:1px solid #D5CFC2; border-left:4px solid #3D6E70; font-family:"JetBrains Mono",monospace; font-size:11px; letter-spacing:0.12em; text-transform:uppercase; }
</style>
</head>
<body>
<div class="row">
<span class="badge b1">Personal</span>
<span class="badge b2">9TFox</span>
<span class="badge b3">Finacode</span>
</div>
<div class="row" style="margin-top:14px">
<span class="strip">SCOPE · PERSONAL</span>
<span class="strip" style="border-left-color:#B4541A">SCOPE · 9TFOX</span>
<span class="strip" style="border-left-color:#5A4FA3">SCOPE · FINACODE</span>
</div>
<div class="legend" style="margin-top:12px">badge form · scope-strip form (top of page)</div>
</body></html>

View file

@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { padding:28px; }
.row { display:flex; gap:24px; flex-wrap:wrap; }
.item { display:flex; flex-direction:column; align-items:center; gap:6px; min-width:64px; }
.lbl { font-family:"JetBrains Mono",monospace; font-size:10px; letter-spacing:0.1em; color:#5A5347; }
svg.icon { color:#1A1814; }
</style>
</head>
<body>
<div class="row">
<div class="item"><svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg><span class="lbl">check</span></div>
<div class="item"><svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg><span class="lbl">x</span></div>
<div class="item"><svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg><span class="lbl">search</span></div>
<div class="item"><svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg><span class="lbl">chevron</span></div>
<div class="item"><svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg><span class="lbl">plus</span></div>
<div class="item"><svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6"></path></svg><span class="lbl">trash</span></div>
<div class="item"><svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z"></path><path d="M12 7v5l3 2"></path></svg><span class="lbl">clock</span></div>
<div class="item"><svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"></rect><line x1="3" y1="10" x2="21" y2="10"></line></svg><span class="lbl">card</span></div>
<div class="item"><svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="2" x2="12" y2="22"></line><polyline points="19 15 12 22 5 15"></polyline></svg><span class="lbl">import</span></div>
<div class="item"><svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg><span class="lbl">activity</span></div>
</div>
<div class="legend" style="margin-top:14px">Lucide · 1.5px stroke · 20px chrome size · substitute for Akefin-native set</div>
</body></html>

View file

@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { padding:20px; }
.bar { display:flex; height:14px; border-radius:2px; overflow:hidden; border:1px solid #D5CFC2; max-width:620px; }
.bar div { transition:all .2s; }
.legend { font-family:"JetBrains Mono",monospace; font-size:10px; letter-spacing:0.14em; text-transform:uppercase; color:#5A5347; display:flex; gap:18px; margin-top:10px; flex-wrap:wrap; }
.legend .dot { display:inline-block; width:8px; height:8px; margin-right:6px; vertical-align:1px; }
.row { display:flex; gap:20px; font-family:"JetBrains Mono",monospace; font-size:13px; margin-top:12px; }
.row .n { font-weight:600; color:#1A1814; }
.row .l { color:#5A5347; font-size:10px; letter-spacing:0.14em; text-transform:uppercase; margin-left:6px; }
</style>
</head>
<body>
<div class="bar">
<div style="background:#2F7D55; width:62%"></div>
<div style="background:#2A6FB0; width:18%"></div>
<div style="background:#B5740A; width:14%"></div>
<div style="background:#B8362B; width:6%"></div>
</div>
<div class="legend">
<span><span class="dot" style="background:#2F7D55"></span>auto-accepted</span>
<span><span class="dot" style="background:#2A6FB0"></span>high</span>
<span><span class="dot" style="background:#B5740A"></span>review</span>
<span><span class="dot" style="background:#B8362B"></span>failed</span>
</div>
<div class="row">
<span><span class="n">247</span><span class="l">total</span></span>
<span><span class="n">153</span><span class="l">auto</span></span>
<span><span class="n">44</span><span class="l">high</span></span>
<span><span class="n">35</span><span class="l">review</span></span>
<span><span class="n">15</span><span class="l">failed</span></span>
</div>
</body></html>

24
preview/comp-inputs.html Normal file
View file

@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { padding:24px; display:flex; flex-direction:column; gap:14px; max-width:560px; }
.field { display:flex; flex-direction:column; gap:4px; }
.lbl { font-family:"JetBrains Mono",monospace; font-size:10px; letter-spacing:0.14em; text-transform:uppercase; color:#5A5347; }
.input { height:32px; padding:0 10px; font-family:"IBM Plex Sans",sans-serif; font-size:13px; background:#ECE7DC; border:1px solid #B5AE9F; border-radius:2px; color:#1A1814; outline:none; }
.input.mono { font-family:"JetBrains Mono",monospace; font-variant-numeric:tabular-nums; }
.input:focus { border-color:#3A6FB0; outline:2px solid #3A6FB0; outline-offset:1px; }
.row { display:flex; gap:10px; }
</style>
</head>
<body>
<div class="field"><span class="lbl">Search payee</span><input class="input" placeholder="스타벅스, E-Mart, Toss…"></div>
<div class="row">
<div class="field" style="flex:1"><span class="lbl">Amount</span><input class="input mono" value="38,500" style="text-align:right"></div>
<div class="field" style="width:90px"><span class="lbl">Ccy</span><input class="input mono" value="KRW"></div>
</div>
<div class="field"><span class="lbl">Account · focus state</span><input class="input mono" value="Personal:Expenses:Food:Coffee" style="border-color:#3A6FB0; outline:2px solid #3A6FB0; outline-offset:1px;"></div>
</body></html>

View file

@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { padding:24px; }
.preview { background:#EBEEF1; border:1px solid #D7DCE2; border-radius:2px; padding:14px 18px; font-family:"JetBrains Mono",monospace; font-size:13px; color:#2A2E33; font-variant-numeric:tabular-nums; }
.head { display:flex; gap:14px; font-size:10px; letter-spacing:0.14em; text-transform:uppercase; color:#6B7178; padding-bottom:6px; border-bottom:1px dashed #D7DCE2; margin-bottom:8px; }
.leg { display:grid; grid-template-columns:84px 1fr 110px 50px; gap:14px; line-height:1.7; }
.pos { color:#2F7D55; text-align:right; }
.neg { color:#1A1814; text-align:right; }
.ccy { color:#8A8377; }
</style>
</head>
<body>
<div class="preview">
<div class="head">2026-03-02 · "스타벅스 강남역점" · ★ 0.94 LLM</div>
<div class="leg">
<span>Personal</span><span>:Expenses:Food:Coffee</span><span class="neg">38,500</span><span class="ccy">KRW</span>
<span>Personal</span><span>:Assets:Bank:Toss</span><span class="pos">-38,500</span><span class="ccy">KRW</span>
</div>
</div>
<div class="legend" style="margin-top:12px">LedgerPreview · two-leg posting · monospace · debit / credit balance</div>
</body></html>

View file

@ -0,0 +1,49 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { padding:20px; }
.card { background:#F5F1E8; border:1px solid #D5CFC2; border-radius:3px; padding:14px 16px; max-width:620px; }
.top { display:flex; align-items:baseline; gap:10px; margin-bottom:8px; }
.top .pattern { font-family:"JetBrains Mono",monospace; font-size:13px; }
.top .occ { font-family:"JetBrains Mono",monospace; font-size:11px; color:#5A5347; letter-spacing:0.06em; margin-left:auto; }
.map { display:flex; align-items:center; gap:8px; font-family:"JetBrains Mono",monospace; font-size:13px; padding:8px 10px; background:#EBEEF1; border:1px solid #D7DCE2; border-radius:2px; }
.arrow { color:#5A5347; }
.acct { color:#1A1814; }
.examples { display:flex; flex-direction:column; gap:2px; margin-top:8px; font-family:"JetBrains Mono",monospace; font-size:11px; color:#5A5347; }
.examples .pos { color:#1A1814; }
.row-btn { display:flex; gap:8px; margin-top:12px; align-items:center; }
.chip { display:inline-flex; align-items:center; gap:5px; height:20px; padding:0 8px; font-family:"JetBrains Mono",monospace; font-size:11px; font-weight:500; border-radius:999px; background:#DCE5EE; color:#2A6FB0; }
.btn { font-family:"JetBrains Mono",monospace; font-size:11px; font-weight:500; letter-spacing:0.12em; text-transform:uppercase; padding:6px 10px; border:1px solid #B5AE9F; border-radius:2px; background:transparent; cursor:pointer; }
.btn.primary { background:#1A1814; color:#ECE7DC; border-color:#1A1814; }
.check { display:flex; align-items:center; gap:6px; margin-left:auto; font-size:12px; color:#5A5347; }
.ko { font-family:"Pretendard",sans-serif; color:#1A1814; }
</style>
</head>
<body>
<div class="card">
<div class="top">
<span class="pattern">payee ~= <span class="ko">"스타벅스 *"</span></span>
<span class="occ">14 OCCURRENCES · LAST 30d</span>
</div>
<div class="map">
<span class="ko">스타벅스 *</span>
<span class="arrow"></span>
<span class="acct">Personal:Expenses:Food:Coffee</span>
</div>
<div class="examples">
<div class="pos">2026-03-02 스타벅스 강남역점 38,500 KRW</div>
<div class="pos">2026-02-28 스타벅스 판교점 6,300 KRW</div>
<div>+ 12 more</div>
</div>
<div class="row-btn">
<span class="chip">★ 0.97 suggested</span>
<label class="check"><input type="checkbox" checked> promote</label>
<button class="btn primary">Apply rule</button>
<button class="btn">Dismiss</button>
</div>
</div>
</body></html>

View file

@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { padding:24px; }
.term { background:#EBEEF1; border:1px solid #D7DCE2; border-radius:2px; font-family:"JetBrains Mono",monospace; font-size:12px; color:#2A2E33; }
.title { display:flex; align-items:center; gap:8px; padding:6px 12px; border-bottom:1px solid #D7DCE2; font-size:10px; letter-spacing:0.14em; text-transform:uppercase; color:#6B7178; }
.title::after { content:""; width:6px; height:6px; border-radius:50%; background:#2F7D55; margin-left:auto; }
.body { padding:12px 14px; line-height:1.5; }
.k { color:#B5740A; }
.s { color:#2F7D55; }
.m { color:#8A8377; }
</style>
</head>
<body>
<div class="term">
<div class="title">AKEFIN — IMPORT 2026-03-02-001 <span style="margin-left:auto"></span></div>
<div class="body">
<div><span class="m">$</span> akefin import toss-export.csv --entity 9TFox</div>
<div class="m">read 247 rows · staged 23 · auto-accepted 220 · failed 4</div>
<div><span class="k">tier</span> rules <span class="s">218</span> · llm <span class="s">21</span> · agent <span class="s">4</span> · unmatched <span style="color:#B8362B">4</span></div>
<div class="m">→ ledger committed to git · 9tfox-2026-03.ldgr · 247 lines</div>
</div>
</div>
<div class="legend" style="margin-top:12px">terminal frame · the pi.dev-style data container</div>
</body></html>

View file

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { padding:28px; display:flex; gap:10px; align-items:center; }
.tier { display:inline-flex; align-items:center; height:20px; padding:0 8px; font-family:"JetBrains Mono",monospace; font-size:10px; font-weight:600; letter-spacing:0.16em; text-transform:uppercase; border:1px solid currentColor; border-radius:2px; }
.t1 { color:#2F7D55; } .t2 { color:#2A6FB0; } .t3 { color:#B5740A; } .t4 { color:#B8362B; }
</style>
</head>
<body>
<span class="tier t1">Rules</span>
<span class="tier t2">LLM</span>
<span class="tier t3">Agent</span>
<span class="tier t4">Unmatched</span>
</body></html>

View file

@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { padding:24px; }
.list { display:flex; flex-direction:column; gap:1px; background:#D5CFC2; border:1px solid #D5CFC2; border-radius:2px; }
.tx { display:grid; grid-template-columns: 92px 1fr auto auto auto auto; align-items:center; gap:14px; height:40px; padding:0 14px; background:#F5F1E8; font-size:13px; }
.when { font-family:"JetBrains Mono",monospace; font-size:12px; color:#5A5347; }
.payee { font-family:"Pretendard","IBM Plex Sans KR",sans-serif; color:#1A1814; }
.amt { font-family:"JetBrains Mono",monospace; font-size:13px; font-variant-numeric:tabular-nums; }
.ccy { color:#8A8377; margin-left:4px; }
.badge { display:inline-flex; align-items:center; gap:6px; height:20px; padding:0 8px 0 6px; font-family:"IBM Plex Sans",sans-serif; font-size:11px; font-weight:600; border-radius:2px; }
.badge::before { content:""; width:4px; height:12px; border-radius:1px; }
.b1 { background:#DCE5E5; color:#3D6E70; } .b1::before { background:#3D6E70; }
.b2 { background:#EFDDCB; color:#B4541A; } .b2::before { background:#B4541A; }
.chip { display:inline-flex; align-items:center; gap:5px; height:18px; padding:0 8px; font-family:"JetBrains Mono",monospace; font-size:10px; font-weight:500; border-radius:999px; font-variant-numeric:tabular-nums; }
.c-high { background:#DCE5EE; color:#2A6FB0; }
.c-mid { background:#EFE2C8; color:#B5740A; }
.c-low { background:#ECD5D1; color:#B8362B; }
.tier { display:inline-flex; height:18px; padding:0 6px; font-family:"JetBrains Mono",monospace; font-size:9px; font-weight:600; letter-spacing:0.14em; text-transform:uppercase; border:1px solid currentColor; border-radius:2px; align-items:center; }
.t-llm { color:#2A6FB0; }
.t-agent { color:#B5740A; }
.t-unm { color:#B8362B; }
</style>
</head>
<body>
<div class="list">
<div class="tx"><span class="when">2026-03-02</span><span class="payee">스타벅스 강남역점</span><span class="amt"> 38,500<span class="ccy">KRW</span></span><span class="badge b1">Personal</span><span class="chip c-high">★ 0.94</span><span class="tier t-llm">LLM</span></div>
<div class="tx"><span class="when">2026-03-02</span><span class="payee">이마트 트레이더스</span><span class="amt"> 124,300<span class="ccy">KRW</span></span><span class="badge b1">Personal</span><span class="chip c-mid">★ 0.78</span><span class="tier t-agent">Agent</span></div>
<div class="tx"><span class="when">2026-03-03</span><span class="payee">Wise · Finacode partner</span><span class="amt">+ 1,240<span class="ccy">EUR</span></span><span class="badge b2">9TFox</span><span class="chip c-low">★ —</span><span class="tier t-unm">Unmatched</span></div>
</div>
<div class="legend" style="margin-top:12px">TransactionRow · date · payee (KO) · amount · entity · chip · tier</div>
</body></html>

View file

@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { padding:32px; display:flex; gap:32px; align-items:flex-end; }
.stack { display:flex; flex-direction:column; gap:6px; align-items:center; }
.box { width:64px; height:64px; background:#1A1814; }
.lbl { font-family:"JetBrains Mono",monospace; font-size:10px; letter-spacing:0.12em; text-transform:uppercase; color:#5A5347; }
.val { font-family:"JetBrains Mono",monospace; font-size:11px; color:#1A1814; }
</style>
</head>
<body>
<div class="stack"><div class="box" style="border-radius:2px"></div><div class="lbl">r-sm</div><div class="val">2px</div></div>
<div class="stack"><div class="box" style="border-radius:3px"></div><div class="lbl">r-md</div><div class="val">3px</div></div>
<div class="stack"><div class="box" style="border-radius:4px"></div><div class="lbl">r-lg</div><div class="val">4px</div></div>
<div class="stack"><div class="box" style="border-radius:999px;width:96px;height:24px"></div><div class="lbl">r-pill</div><div class="val">chips only</div></div>
</body></html>

26
preview/spacing-rows.html Normal file
View file

@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { padding:24px; }
.stack { display:flex; flex-direction:column; gap:8px; }
.row { display:flex; align-items:center; gap:16px; border:1px solid #D5CFC2; background:#F5F1E8; padding:0 14px; font-family:"IBM Plex Sans",sans-serif; font-size:13px; }
.row .when { font-family:"JetBrains Mono",monospace; font-size:12px; color:#5A5347; }
.row .amt { margin-left:auto; font-family:"JetBrains Mono",monospace; font-size:13px; }
.row .lbl { color:#5A5347; }
.r-compact { height:32px; }
.r-default { height:40px; }
.r-roomy { height:48px; }
.tag { font-family:"JetBrains Mono",monospace; font-size:10px; letter-spacing:0.14em; text-transform:uppercase; color:#8A8377; min-width:88px; }
</style>
</head>
<body>
<div class="stack">
<div class="row r-compact"><span class="tag">32 · compact</span><span class="when">2026-03-02</span><span>review queue rows</span><span class="amt"> 38,500 KRW</span></div>
<div class="row r-default"><span class="tag">40 · default</span><span class="when">2026-03-02</span><span>primary list rows</span><span class="amt">+ 2,400,000 KRW</span></div>
<div class="row r-roomy"><span class="tag">48 · roomy</span><span class="when">2026-03-02</span><span>table headers / page tabs</span><span class="amt">— —</span></div>
</div>
</body></html>

View file

@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { padding:24px; }
.scale { display:flex; flex-direction:column; gap:6px; }
.item { display:flex; align-items:center; gap:14px; font-family:"JetBrains Mono",monospace; font-size:11px; }
.bar { background:#1A1814; height:14px; }
.label { color:#5A5347; letter-spacing:0.08em; min-width:90px; }
.px { color:#1A1814; min-width:42px; }
</style>
</head>
<body>
<div class="scale">
<div class="item"><span class="label">--s-1</span><span class="px">4px</span><span class="bar" style="width:4px"></span></div>
<div class="item"><span class="label">--s-2</span><span class="px">8px</span><span class="bar" style="width:8px"></span></div>
<div class="item"><span class="label">--s-3</span><span class="px">12px</span><span class="bar" style="width:12px"></span></div>
<div class="item"><span class="label">--s-4</span><span class="px">16px</span><span class="bar" style="width:16px"></span></div>
<div class="item"><span class="label">--s-5</span><span class="px">24px</span><span class="bar" style="width:24px"></span></div>
<div class="item"><span class="label">--s-6</span><span class="px">32px</span><span class="bar" style="width:32px"></span></div>
<div class="item"><span class="label">--s-7</span><span class="px">40px</span><span class="bar" style="width:40px"></span></div>
<div class="item"><span class="label">--s-8</span><span class="px">56px</span><span class="bar" style="width:56px"></span></div>
</div>
</body></html>

View file

@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { padding:48px; display:flex; gap:48px; align-items:center; }
.stack { display:flex; flex-direction:column; gap:8px; align-items:flex-start; }
.box { width:140px; height:80px; background:#F5F1E8; border:1px solid #D5CFC2; border-radius:3px; }
.pop { box-shadow: 0 1px 0 rgba(20,18,12,.04), 0 8px 24px -8px rgba(20,18,12,.16); }
.lbl { font-family:"JetBrains Mono",monospace; font-size:10px; letter-spacing:0.12em; text-transform:uppercase; color:#5A5347; }
</style>
</head>
<body>
<div class="stack"><div class="box"></div><div class="lbl">flat · default</div></div>
<div class="stack"><div class="box pop"></div><div class="lbl">popover · 8 24 -8</div></div>
<div class="stack"><div class="box" style="box-shadow:inset 0 1px 0 rgba(20,18,12,.18)"></div><div class="lbl">inset · press state</div></div>
</body></html>

22
preview/type-display.html Normal file
View file

@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { padding:32px; }
.stack { display:flex; flex-direction:column; gap:14px; }
.row-spec { display:flex; align-items:baseline; justify-content:space-between; gap:24px; }
.spec { font-family:"JetBrains Mono",monospace; font-size:10px; letter-spacing:0.14em; text-transform:uppercase; color:var(--fg-muted); white-space:nowrap; }
.d1 { font-family:"Spectral",serif; font-style:italic; font-weight:400; font-size:48px; line-height:1.1; color:#0A0907; letter-spacing:-0.01em; }
.d2 { font-family:"Spectral",serif; font-style:italic; font-weight:400; font-size:32px; line-height:1.1; color:#0A0907; }
</style>
</head>
<body>
<div class="stack">
<div class="row-spec"><span class="d1">Review the ledger.</span><span class="spec">Spectral 48 / italic / 400</span></div>
<div class="row-spec"><span class="d2">Why Akefin?</span><span class="spec">Spectral 32 / italic / 400</span></div>
</div>
<div class="legend" style="margin-top:14px">display serif · Spectral (substitute for Plantin MT Pro)</div>
</body></html>

20
preview/type-eyebrow.html Normal file
View file

@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { padding:32px; display:flex; align-items:center; gap:32px; flex-wrap:wrap; }
.eyebrow { font-family:"JetBrains Mono",monospace; font-size:11px; font-weight:500; letter-spacing:0.12em; text-transform:uppercase; color:#5A5347; }
.term-title { display:inline-flex; align-items:center; gap:8px; padding:6px 12px; border:1px solid #D7DCE2; background:#EBEEF1; }
.term-title::after { content:""; width:6px; height:6px; border-radius:50%; background:#2F7D55; margin-left:8px; }
</style>
</head>
<body>
<div class="eyebrow">HOME</div>
<div class="eyebrow">DOCS</div>
<div class="eyebrow">REVIEW QUEUE</div>
<div class="term-title eyebrow">PI - MANIPULATE THE WEBSITE</div>
<div class="eyebrow" style="color:#1A1814">[ READ THE DOCS ]</div>
</body></html>

View file

@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { padding:28px; }
.ledger { background:#EBEEF1; border:1px solid #D7DCE2; border-radius:2px; padding:14px 16px; font-family:"JetBrains Mono",monospace; font-size:13px; line-height:1.5; color:#2A2E33; font-variant-numeric:tabular-nums; }
.ledger .pos { color:#2F7D55; }
.ledger .neg { color:#1A1814; }
.ledger .ccy { color:#6B7178; }
.ledger .acc { color:#1A1814; }
</style>
</head>
<body>
<div class="ledger">
<div>2026-03-02 Personal:Expenses:Food:Coffee <span class="neg">38,500</span> <span class="ccy">KRW</span></div>
<div>2026-03-02 Personal:Assets:Bank:Toss <span class="pos">-38,500</span> <span class="ccy">KRW</span></div>
<div>2026-03-03 9TFox:Income:Consulting <span class="pos">+2,400,000</span> <span class="ccy">KRW</span></div>
<div>2026-03-03 9TFox:Assets:Bank:Kakao <span class="neg">-2,400,000</span> <span class="ccy">KRW</span></div>
</div>
<div class="legend" style="margin-top:12px">mono · tabular nums · right-aligned amounts · ccy code, never symbol</div>
</body></html>

View file

@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { padding:28px; }
.stack { display:flex; flex-direction:column; gap:10px; }
.row-spec { display:flex; align-items:baseline; justify-content:space-between; gap:24px; }
.spec { font-family:"JetBrains Mono",monospace; font-size:10px; letter-spacing:0.14em; text-transform:uppercase; color:var(--fg-muted); }
.ko { font-family:"Pretendard","IBM Plex Sans KR",sans-serif; }
.h1 { font-size:22px; font-weight:500; }
.body { font-size:14px; }
.data { font-family:"JetBrains Mono",monospace; font-size:13px; }
</style>
</head>
<body>
<div class="stack">
<div class="row-spec"><span class="ko h1">한국어 거래내역 검토</span><span class="spec">Pretendard 22 / 500</span></div>
<div class="row-spec"><span class="ko body">적요 · 스타벅스 강남역점</span><span class="spec">Pretendard 14 / 400</span></div>
<div class="row-spec"><span class="ko body">신한은행 입출금 통장 · KRW</span><span class="spec">14 / 400</span></div>
<div class="row-spec"><span class="data">이마트 트레이더스 · E-Mart Traders</span><span class="spec">mono inline label</span></div>
</div>
<div class="legend" style="margin-top:12px">Hangul · Pretendard · CJK + Latin coexistence</div>
</body></html>

View file

@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<link rel="stylesheet" href="_card.css">
<style>
body { padding:28px; }
.stack { display:flex; flex-direction:column; gap:10px; }
.row-spec { display:flex; align-items:baseline; justify-content:space-between; gap:24px; }
.spec { font-family:"JetBrains Mono",monospace; font-size:10px; letter-spacing:0.14em; text-transform:uppercase; color:var(--fg-muted); }
.h1 { font-family:"IBM Plex Sans",sans-serif; font-size:22px; font-weight:500; }
.h2 { font-family:"IBM Plex Sans",sans-serif; font-size:18px; font-weight:500; }
.h3 { font-family:"IBM Plex Sans",sans-serif; font-size:15px; font-weight:600; }
.body { font-family:"IBM Plex Sans",sans-serif; font-size:14px; font-weight:400; }
.cap { font-family:"IBM Plex Sans",sans-serif; font-size:12px; color:#5A5347; }
</style>
</head>
<body>
<div class="stack">
<div class="row-spec"><span class="h1">Review queue</span><span class="spec">H1 · 22 / 500</span></div>
<div class="row-spec"><span class="h2">23 staged transactions</span><span class="spec">H2 · 18 / 500</span></div>
<div class="row-spec"><span class="h3">Auto-accepted</span><span class="spec">H3 · 15 / 600</span></div>
<div class="row-spec"><span class="body">A categorization rule was promoted.</span><span class="spec">Body · 14 / 400</span></div>
<div class="row-spec"><span class="cap">Last import 4 minutes ago</span><span class="spec">Caption · 12 / 400</span></div>
</div>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
screenshots/01-v2-csv.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
screenshots/01-v3-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
screenshots/01-v3-dark2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
screenshots/02-v2-csv.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
screenshots/02-v3-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
screenshots/02-v3-dark2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

BIN
screenshots/v2-debug.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
screenshots/v2-initial.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View 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 });

View 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 });

View 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
View 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
View 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>

View 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
View 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; }

154
ui_kits/web/App.jsx Normal file
View file

@ -0,0 +1,154 @@
// App composition root
function App() {
const [entity, setEntity] = React.useState("all");
const [screen, setScreen] = React.useState("review");
const [query, setQuery] = React.useState("");
const [sort, setSort] = React.useState("date-desc");
const [transactions, setTransactions] = React.useState(window.AKEFIN_DATA.transactions);
const [selectedId, setSelectedId] = React.useState(window.AKEFIN_DATA.transactions[0].id);
const [toast, setToast] = React.useState(null);
const [paletteOpen, setPaletteOpen] = React.useState(false);
const [csvOpen, setCsvOpen] = React.useState(false);
const [theme, setTheme] = React.useState("light");
const [filters, setFilters] = React.useState({
dateRange: "all",
dateFrom: "",
dateTo: "",
categories: new Set(),
tiers: new Set(),
confidenceMin: 0,
ccy: new Set(),
});
// Set theme on root
React.useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
}, [theme]);
const showToast = (msg) => {
setToast(msg);
setTimeout(() => setToast(null), 2400);
};
// K to open command palette
React.useEffect(() => {
const onKey = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setPaletteOpen(o => !o);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const approve = (id, account) => {
setTransactions(prev => prev.filter(t => t.id !== id));
const next = transactions.find(t => t.id !== id);
if (next) setSelectedId(next.id);
showToast(`Approved · posted to ${account || "—"}`);
};
const skip = (id) => {
setTransactions(prev => prev.filter(t => t.id !== id));
const next = transactions.find(t => t.id !== id);
if (next) setSelectedId(next.id);
showToast(`Skipped · t${id.slice(1)} held for re-review`);
};
const runCommand = (cmd) => {
setPaletteOpen(false);
switch (cmd.id) {
case "approve":
if (selected) approve(selected.id, selected.suggestedAccount);
break;
case "skip":
if (selected) skip(selected.id);
break;
case "approve-all-high":
const ids = transactions.filter(t => (t.score ?? 0) >= 0.85).map(t => t.id);
setTransactions(prev => prev.filter(t => !ids.includes(t.id)));
showToast(`Approved ${ids.length} transactions at ≥ 0.85`);
break;
case "import-csv":
setCsvOpen(true);
break;
case "poll-toss":
showToast("Polling Toss · ETA 4 seconds");
break;
case "promote-rules":
setScreen("rules");
break;
case "commit-ledger":
showToast("Committed · e4a82c1 on main · pushed");
break;
case "switch-personal": setEntity("personal"); break;
case "switch-9tfox": setEntity("tfox"); break;
case "switch-finacode": setEntity("finacode"); break;
case "go-review": setScreen("review"); break;
case "go-rules": setScreen("rules"); break;
case "go-ledger": setScreen("ledger"); break;
case "go-import": setScreen("import"); break;
case "toggle-theme": setTheme(t => t === "light" ? "dark" : "light"); break;
default: showToast(`${cmd.label}`);
}
};
const visibleTx = transactions.filter(t => entity === "all" || t.entity === entity);
const selected = visibleTx.find(t => t.id === selectedId) || visibleTx[0] || null;
React.useEffect(() => {
if (visibleTx.length && !visibleTx.find(t => t.id === selectedId)) {
setSelectedId(visibleTx[0].id);
}
}, [entity, transactions]);
const counts = {
review: transactions.filter(t => entity === "all" || t.entity === entity).length,
rules: window.AKEFIN_DATA.ruleSuggestions.length,
import: window.AKEFIN_DATA.importRuns.length,
};
const statusLine = `${counts.review} staged · ledger clean · last import 4m ago`;
const handleImport = ({ entity: e, file, password }) => {
setCsvOpen(false);
showToast(`Importing · decrypting with password · ${e} entity`);
setTimeout(() => showToast(`Import complete · 247 rows · 35 staged for review`), 1800);
};
return (
<div className="ak-app">
<Header entity={entity} onEntityChange={setEntity}
query={query} setQuery={setQuery}
statusLine={statusLine}
onOpenPalette={() => setPaletteOpen(true)}
onImport={() => setCsvOpen(true)} />
<div className="ak-body">
<Sidebar screen={screen} setScreen={setScreen} counts={counts} />
<main className="ak-main">
{screen === "review" && (
<div className="review-screen">
<ReviewQueue entity={entity} transactions={transactions}
selectedId={selected?.id} onSelect={setSelectedId}
sort={sort} setSort={setSort}
filters={filters} setFilters={setFilters}
onOpenPalette={() => setPaletteOpen(true)} />
<Inspector tx={selected} onApprove={approve} onSkip={skip} />
</div>
)}
{screen === "rules" && <RulesScreen entity={entity} />}
{screen === "ledger" && <LedgerScreen entity={entity} />}
{screen === "import" && <ImportScreen entity={entity} onOpenImport={() => setCsvOpen(true)} />}
</main>
</div>
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} onRun={runCommand} />
<CsvImportModal open={csvOpen} onClose={() => setCsvOpen(false)} onSubmit={handleImport} />
{toast && <div className="ak-toast">{toast}</div>}
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);

128
ui_kits/web/Atoms.jsx Normal file
View file

@ -0,0 +1,128 @@
// Atoms buttons, chips, badges, inputs, terminal frame
// Exported to window for cross-script use.
const fmt = (n, ccy) => {
if (n === null || n === undefined) return "—";
const abs = Math.abs(n);
const formatted = abs.toLocaleString("en-US", { maximumFractionDigits: ccy === "EUR" ? 2 : 0 });
return formatted;
};
const sign = (n) => (n > 0 ? "+ " : n < 0 ? " " : "");
// --- ENTITY BADGE ---
function EntityBadge({ entity, size = "md" }) {
const map = {
personal: { label: "Personal", cls: "personal" },
tfox: { label: "9TFox", cls: "tfox" },
finacode: { label: "Finacode", cls: "finacode" },
};
const e = map[entity];
if (!e) return null;
return <span className={`entity-badge ${e.cls} sz-${size}`}>{e.label}</span>;
}
// --- CONFIDENCE CHIP ---
function ConfidenceChip({ 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={`chip ${cls}`}>
<span className="star"></span>
<span className="val">{display}</span>
</span>
);
}
// --- TIER BADGE ---
function TierBadge({ tier }) {
const map = { rules: "Rules", llm: "LLM", agent: "Agent", unmatched: "Unmatched" };
return <span className={`tier ${tier}`}>{map[tier]}</span>;
}
// --- AMOUNT ---
function Amount({ value, ccy, alignRight = true }) {
if (value === null || value === undefined) return <span className="amount muted"></span>;
const cls = value > 0 ? "pos" : value < 0 ? "neg" : "zero";
return (
<span className={`amount ${cls}`} style={{ textAlign: alignRight ? "right" : "left" }}>
{sign(value)}{fmt(value, ccy)}<span className="ccy"> {ccy}</span>
</span>
);
}
// --- BUTTONS ---
function Btn({ children, variant = "ghost", onClick, type = "button", bracket = false, disabled = false }) {
const cls = bracket ? "btn bracket" : `btn ${variant}`;
return (
<button className={cls} onClick={onClick} type={type} disabled={disabled}>
{bracket ? `[ ${children} ]` : children}
</button>
);
}
// --- INPUT ---
function TextInput({ value, onChange, placeholder, mono = false, align = "left", icon = null }) {
return (
<div className={`text-input ${mono ? "mono" : ""}`}>
{icon && <span className="ti-icon">{icon}</span>}
<input
type="text"
value={value || ""}
onChange={(e) => onChange && onChange(e.target.value)}
placeholder={placeholder}
style={{ textAlign: align }}
/>
</div>
);
}
// --- TERMINAL FRAME ---
function TermFrame({ title, status = "ok", children, scrollable = false }) {
const dot = status === "ok" ? "var(--conf-rules)" : status === "warn" ? "var(--conf-mid)" : "var(--conf-low)";
return (
<div className="term-frame">
<div className="term-frame-title">
<span>{title}</span>
<span className="dot" style={{ background: dot }}></span>
</div>
<div className={`term-frame-body ${scrollable ? "scrollable" : ""}`}>{children}</div>
</div>
);
}
// --- ICON (lucide subset, inlined) ---
const ICONS = {
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",
plus: "M12 5 L12 19 M5 12 L19 12",
trash: "M3 6 L21 6 M19 6 L17 20 a2 2 0 0 1 -2 2 H9 a2 2 0 0 1 -2 -2 L5 6 M10 11 L10 17 M14 11 L14 17",
clock: "M21 12 a9 9 0 1 1 -18 0 a9 9 0 0 1 18 0 M12 7 L12 12 L15 14",
card: "M3 4 H21 V20 H3 Z M3 10 L21 10",
import: "M12 2 L12 22 M19 15 L12 22 L5 15",
activity: "M22 12 H18 L15 21 L9 3 L6 12 H2",
filter: "M3 5 H21 L14 13 V20 L10 22 V13 L3 5",
sort: "M3 6 H21 M6 12 H18 M9 18 H15",
ellipsis: "M5 12 h.01 M12 12 h.01 M19 12 h.01",
arrowRight: "M5 12 H19 M13 6 L19 12 L13 18",
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",
refresh: "M21 12 a9 9 0 1 1 -3 -6.7 M21 4 V10 H15",
spark: "M5 12 L9 8 L13 14 L15 11 L19 12",
};
function Icon({ name, size = 16, stroke = 1.5, color = "currentColor" }) {
const d = ICONS[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" style={{ flexShrink: 0 }}>
<path d={d}></path>
</svg>
);
}
Object.assign(window, { Btn, TextInput, EntityBadge, ConfidenceChip, TierBadge, Amount, TermFrame, Icon, fmt, sign });

108
ui_kits/web/Chrome.jsx Normal file
View file

@ -0,0 +1,108 @@
// Chrome top header strip + left navigation rail
function EntityScopeSwitcher({ value, onChange }) {
const entities = window.AKEFIN_DATA.entities;
const [open, setOpen] = React.useState(false);
const current = entities.find(e => e.id === value);
return (
<div className={`scope-switcher ${open ? "open" : ""}`}>
<button className="scope-trigger" onClick={() => setOpen(!open)}>
<span className="scope-stripe" style={{ background: current.color || "#1A1814" }}></span>
<span className="scope-label">SCOPE</span>
<span className="scope-name">{current.label}</span>
<Icon name="chevDown" size={14} />
</button>
{open && (
<div className="scope-menu">
{entities.map(e => (
<button key={e.id} className={`scope-item ${e.id === value ? "active" : ""}`}
onClick={() => { onChange(e.id); setOpen(false); }}>
<span className="scope-stripe" style={{ background: e.color || "#B5AE9F" }}></span>
<span>{e.label}</span>
{e.id === value && <Icon name="check" size={12} />}
</button>
))}
</div>
)}
</div>
);
}
function Header({ entity, onEntityChange, query, setQuery, statusLine, onOpenPalette, onImport }) {
return (
<header className="ak-header">
<div className="ak-logo">
<svg viewBox="0 0 48 48" width="22" height="22" aria-hidden="true">
<g fill="currentColor">
<rect x="6" y="14" width="6" height="28"/>
<rect x="12" y="8" width="6" height="6"/>
<rect x="18" y="8" width="6" height="6"/>
<rect x="24" y="14" width="6" height="28"/>
<rect x="12" y="26" width="12" height="6"/>
</g>
<rect x="36" y="36" width="6" height="6" fill="#2F7D55"/>
</svg>
<span className="ak-wordmark">akefin</span>
</div>
<EntityScopeSwitcher value={entity} onChange={onEntityChange} />
<button className="ak-search ak-search-btn" onClick={onOpenPalette}>
<Icon name="search" size={14} color="var(--fg-muted)" />
<span className="ak-search-placeholder">Search · run command · jump to anything</span>
<span className="ak-search-kbd">K</span>
</button>
<div className="ak-header-actions">
<button className="ak-header-btn" onClick={onImport}>
<Icon name="import" size={13} />
<span>IMPORT CSV</span>
</button>
</div>
<div className="ak-status">
<span className="dot" style={{ background: "var(--conf-rules)" }}></span>
<span className="ak-status-text">{statusLine}</span>
</div>
</header>
);
}
function Sidebar({ screen, setScreen, counts }) {
const items = [
{ id: "review", label: "Review queue", icon: "activity", count: counts.review },
{ id: "rules", label: "Rules", icon: "rule", count: counts.rules },
{ id: "ledger", label: "Ledger", icon: "ledger", count: null },
{ id: "import", label: "Import", icon: "import", count: counts.import },
];
return (
<nav className="ak-sidebar">
<div className="ak-sb-section">PIPELINE</div>
{items.map(it => (
<button key={it.id}
className={`ak-sb-item ${screen === it.id ? "active" : ""}`}
onClick={() => setScreen(it.id)}>
<Icon name={it.icon} size={16} />
<span className="ak-sb-label">{it.label}</span>
{it.count !== null && <span className="ak-sb-count">{it.count}</span>}
</button>
))}
<div className="ak-sb-section" style={{ marginTop: 16 }}>SETTINGS</div>
<button className="ak-sb-item"><Icon name="card" size={16} /><span className="ak-sb-label">Accounts</span></button>
<button className="ak-sb-item"><Icon name="clock" size={16} /><span className="ak-sb-label">History</span></button>
<div style={{ flex: 1 }}></div>
<div className="ak-sb-foot">
<div className="ak-sb-foot-row">
<span className="lbl">Ledger</span>
<span className="val mono">9tfox-2026-03.ldgr</span>
</div>
<div className="ak-sb-foot-row">
<span className="lbl">Branch</span>
<span className="val mono">main · clean</span>
</div>
<div className="ak-sb-foot-row">
<span className="lbl">Last push</span>
<span className="val mono">4m ago</span>
</div>
</div>
</nav>
);
}
Object.assign(window, { Header, Sidebar });

View file

@ -0,0 +1,295 @@
// CommandPalette + FilterPanel + CSV password modal
//
// COMMAND PALETTE
//
function CommandPalette({ open, onClose, onRun }) {
const [q, setQ] = React.useState("");
const [active, setActive] = React.useState(0);
const inputRef = React.useRef(null);
const commands = window.AKEFIN_DATA.commands;
// Filter
const filtered = q
? commands.filter(c => c.label.toLowerCase().includes(q.toLowerCase()) || c.id.includes(q.toLowerCase()))
: commands;
// Group by kind
const groups = {};
filtered.forEach(c => {
if (!groups[c.kind]) groups[c.kind] = [];
groups[c.kind].push(c);
});
const groupOrder = ["action", "batch", "nav", "search", "setting"];
const groupLabels = { action: "ACTIONS", batch: "BATCH", nav: "NAVIGATE", search: "SEARCH", setting: "SETTINGS" };
// Flat ordered list for keyboard nav
const flat = [];
groupOrder.forEach(g => { if (groups[g]) flat.push(...groups[g]); });
React.useEffect(() => { setActive(0); }, [q]);
React.useEffect(() => {
if (open) {
setQ("");
setTimeout(() => inputRef.current?.focus(), 10);
}
}, [open]);
React.useEffect(() => {
if (!open) return;
const onKey = (e) => {
if (e.key === "Escape") { onClose(); }
else if (e.key === "ArrowDown") { e.preventDefault(); setActive(a => Math.min(flat.length - 1, a + 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActive(a => Math.max(0, a - 1)); }
else if (e.key === "Enter") { e.preventDefault(); if (flat[active]) onRun(flat[active]); }
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, flat, active, onClose, onRun]);
if (!open) return null;
let idx = -1;
return (
<div className="cp-overlay" onClick={onClose}>
<div className="cp-panel" onClick={e => e.stopPropagation()}>
<div className="cp-head">
<Icon name="search" size={14} color="var(--fg-muted)" />
<input ref={inputRef} type="text" value={q} onChange={e => setQ(e.target.value)}
placeholder="Search commands · accounts · payees…" />
<span className="cp-kbd">ESC</span>
</div>
<div className="cp-body">
{groupOrder.filter(g => groups[g]?.length).map(g => (
<div key={g} className="cp-group">
<div className="cp-group-label">{groupLabels[g]}</div>
{groups[g].map(c => {
idx++;
const isActive = idx === active;
return (
<button key={c.id}
className={`cp-item ${isActive ? "active" : ""}`}
onMouseEnter={() => setActive(idx)}
onClick={() => onRun(c)}>
<span className="cp-icon"><Icon name={c.icon} size={14} color="currentColor" /></span>
<span className="cp-label">{c.label}</span>
<span className="cp-hint">{c.hint}</span>
</button>
);
})}
</div>
))}
{flat.length === 0 && (
<div className="cp-empty">No commands match · try "approve" or "import"</div>
)}
</div>
<div className="cp-foot">
<span><span className="cp-key"></span> navigate</span>
<span><span className="cp-key"></span> run</span>
<span><span className="cp-key">esc</span> close</span>
<span style={{ marginLeft: "auto" }}>{flat.length} commands</span>
</div>
</div>
</div>
);
}
//
// FILTER PANEL for review queue
//
const DEFAULT_FILTERS = {
dateRange: "all",
dateFrom: "",
dateTo: "",
categories: new Set(),
tiers: new Set(),
confidenceMin: 0,
ccy: new Set(),
};
function FilterPanel({ open, filters, setFilters, onClose, onApply, activeCount }) {
const cats = window.AKEFIN_DATA.categories;
if (!open) return null;
const toggleSet = (key, val) => {
setFilters(prev => {
const s = new Set(prev[key]);
s.has(val) ? s.delete(val) : s.add(val);
return { ...prev, [key]: s };
});
};
return (
<div className="filter-panel">
<div className="filter-row">
<div className="filter-eyebrow">DATE</div>
<div className="filter-options">
{["today", "7d", "30d", "this-month", "all", "custom"].map(d => (
<button key={d}
className={`filter-pill ${filters.dateRange === d ? "active" : ""}`}
onClick={() => setFilters(f => ({ ...f, dateRange: d }))}>
{d === "7d" ? "LAST 7d" : d === "30d" ? "LAST 30d" : d.replace("-", " ").toUpperCase()}
</button>
))}
{filters.dateRange === "custom" && (
<span className="filter-custom-dates">
<input className="filter-date" type="date" value={filters.dateFrom} onChange={e => setFilters(f => ({...f, dateFrom: e.target.value}))} />
<span className="muted"></span>
<input className="filter-date" type="date" value={filters.dateTo} onChange={e => setFilters(f => ({...f, dateTo: e.target.value}))} />
</span>
)}
</div>
</div>
<div className="filter-row">
<div className="filter-eyebrow">CATEGORY</div>
<div className="filter-options">
{cats.map(c => (
<button key={c}
className={`filter-pill ${filters.categories.has(c) ? "active" : ""}`}
onClick={() => toggleSet("categories", c)}>
{c}
</button>
))}
</div>
</div>
<div className="filter-row">
<div className="filter-eyebrow">TIER</div>
<div className="filter-options">
{["rules", "llm", "agent", "unmatched"].map(t => (
<button key={t}
className={`filter-pill tier-${t} ${filters.tiers.has(t) ? "active" : ""}`}
onClick={() => toggleSet("tiers", t)}>
{t.toUpperCase()}
</button>
))}
</div>
</div>
<div className="filter-row">
<div className="filter-eyebrow">CCY</div>
<div className="filter-options">
{["KRW", "TRY", "JPY", "EUR", "USD"].map(c => (
<button key={c}
className={`filter-pill ${filters.ccy.has(c) ? "active" : ""}`}
onClick={() => toggleSet("ccy", c)}>
{c}
</button>
))}
</div>
</div>
<div className="filter-row">
<div className="filter-eyebrow">MIN CONFIDENCE</div>
<div className="filter-slider-wrap">
<input className="filter-slider" type="range" min="0" max="1" step="0.05"
value={filters.confidenceMin}
onChange={e => setFilters(f => ({...f, confidenceMin: parseFloat(e.target.value)}))} />
<span className="filter-conf-val mono">{filters.confidenceMin.toFixed(2)}</span>
</div>
</div>
<div className="filter-foot">
<span className="filter-active-count">
{activeCount > 0 ? `${activeCount} filter${activeCount > 1 ? "s" : ""} active` : "No active filters"}
</span>
<div style={{ flex: 1 }}></div>
<button className="btn" onClick={() => setFilters({ ...DEFAULT_FILTERS, categories: new Set(), tiers: new Set(), ccy: new Set() })}>CLEAR</button>
<button className="btn primary" onClick={onClose}>DONE</button>
</div>
</div>
);
}
//
// CSV IMPORT MODAL with password
//
function CsvImportModal({ open, onClose, onSubmit }) {
const [file, setFile] = React.useState(null);
const [password, setPassword] = React.useState("");
const [entity, setEntity] = React.useState("personal");
const [showPw, setShowPw] = React.useState(false);
if (!open) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div className="modal-eyebrow">IMPORT · TIER 0</div>
<h2 className="modal-title">Import Toss CSV</h2>
<button className="modal-close" onClick={onClose}><Icon name="x" size={14} /></button>
</div>
<div className="modal-body">
<div className="form-field">
<label className="form-lbl">CSV FILE</label>
<div className="file-drop">
<Icon name="import" size={16} color="var(--fg-muted)" />
<span className="file-drop-text">
{file
? <span className="mono">{file}</span>
: <>Drop CSV here, or <button className="file-pick" onClick={() => setFile("toss-export-2026-03.csv")}>choose file</button></>}
</span>
<span className="file-meta">.csv · UTF-8</span>
</div>
</div>
<div className="form-field">
<label className="form-lbl">PASSWORD <span className="form-lbl-note"> Toss exports are usually encrypted with your account password</span></label>
<div className="pw-input">
<input
type={showPw ? "text" : "password"}
placeholder="Required for encrypted exports"
value={password}
onChange={e => setPassword(e.target.value)}
/>
<button className="pw-toggle" onClick={() => setShowPw(s => !s)}>{showPw ? "HIDE" : "SHOW"}</button>
</div>
<div className="form-help">Password is held in memory only · never written to disk or git · cleared on import complete</div>
</div>
<div className="form-field">
<label className="form-lbl">ENTITY</label>
<div className="entity-radio">
{[
{ id: "personal", label: "Personal", color: "var(--entity-personal)" },
{ id: "tfox", label: "9TFox", color: "var(--entity-9tfox)" },
{ id: "finacode", label: "Finacode", color: "var(--entity-finacode)" },
].map(e => (
<label key={e.id} className={`entity-radio-item ${entity === e.id ? "active" : ""}`}>
<input type="radio" name="entity" checked={entity === e.id} onChange={() => setEntity(e.id)} />
<span className="stripe" style={{ background: e.color }}></span>
<span>{e.label}</span>
</label>
))}
</div>
</div>
<div className="form-field">
<label className="form-lbl">PIPELINE</label>
<div className="pipeline-stack term-frame" style={{ marginBottom: 0 }}>
<div className="term-frame-body" style={{ paddingTop: 8, paddingBottom: 8 }}>
<span className="m"></span> decrypt (password)
{"\n"}<span className="m"></span> parse 247 rows estimated
{"\n"}<span className="m"></span> tier 1: 47 active rules
{"\n"}<span className="m"></span> tier 2: LLM (gpt-4o-mini)
{"\n"}<span className="m"></span> tier 3: agent (claude-haiku)
{"\n"}<span className="m"></span> stage unmatched for review
</div>
</div>
</div>
</div>
<div className="modal-foot">
<Btn onClick={onClose}>CANCEL</Btn>
<div style={{ flex: 1 }}></div>
<Btn variant="primary" onClick={() => { onSubmit({ file, password, entity }); }}>
<Icon name="import" size={13} /> RUN IMPORT
</Btn>
</div>
</div>
</div>
);
}
Object.assign(window, { CommandPalette, FilterPanel, CsvImportModal, DEFAULT_FILTERS });

View file

@ -0,0 +1,89 @@
// ImportScreen recent import runs and status
function ImportBar({ run }) {
const total = run.rows;
const pct = (n) => (n / total) * 100;
return (
<div className="import-bar">
<div style={{ width: `${pct(run.auto)}%`, background: "var(--conf-rules)" }} title={`auto ${run.auto}`}></div>
<div style={{ width: `${pct(run.high)}%`, background: "var(--conf-high)" }} title={`high ${run.high}`}></div>
<div style={{ width: `${pct(run.review)}%`, background: "var(--conf-mid)" }} title={`review ${run.review}`}></div>
<div style={{ width: `${pct(run.failed)}%`, background: "var(--conf-low)" }} title={`failed ${run.failed}`}></div>
</div>
);
}
function ImportScreen({ entity, onOpenImport }) {
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="import-screen">
<div className="screen-head">
<div>
<h1 className="screen-title">Import status</h1>
<div className="screen-sub">{runs.length} recent runs · {total.rows} rows · {total.failed} failed</div>
</div>
<div className="screen-actions">
<Btn><Icon name="refresh" size={13} /> POLL TOSS</Btn>
<Btn variant="primary" onClick={onOpenImport}><Icon name="import" size={13} /> IMPORT FILE</Btn>
</div>
</div>
<div className="import-stats">
<div className="stat"><span className="stat-num">{total.rows}</span><span className="stat-lbl">TOTAL ROWS</span></div>
<div className="stat"><span className="stat-num" style={{ color: "var(--conf-rules)" }}>{total.auto}</span><span className="stat-lbl">AUTO · TIER 1</span></div>
<div className="stat"><span className="stat-num" style={{ color: "var(--conf-high)" }}>{total.high}</span><span className="stat-lbl">HIGH · TIER 2</span></div>
<div className="stat"><span className="stat-num" style={{ color: "var(--conf-mid)" }}>{total.review}</span><span className="stat-lbl">REVIEW · TIER 3</span></div>
<div className="stat"><span className="stat-num" style={{ color: "var(--conf-low)" }}>{total.failed}</span><span className="stat-lbl">FAILED</span></div>
</div>
<TermFrame title="AKEFIN — IMPORT PIPELINE" status="ok">
<span className="m">$</span> akefin import --watch{"\n"}
<span className="m">polling 4 sources · last poll 4 min ago · next in 11 min</span>{"\n"}
<span className="m">tier 1 rules:</span> 47 active <span className="m">·</span> tier 2 llm: gpt-4o-mini <span className="m">·</span> tier 3 agent: claude-haiku
</TermFrame>
<div className="import-list">
<div className="import-list-head">
<span>RUN AT</span>
<span>SOURCE</span>
<span>ENTITY</span>
<span style={{textAlign:"right"}}>ROWS</span>
<span>BREAKDOWN</span>
<span></span>
</div>
{runs.map(r => (
<div key={r.id} className="import-row">
<span className="mono">{r.at}</span>
<span>{r.source}</span>
<span><EntityBadge entity={r.entity} /></span>
<span className="mono right">{r.rows}</span>
<span className="import-bar-cell">
<ImportBar run={r} />
<span className="import-counts">
<span style={{color:"var(--conf-rules)"}}>{r.auto}</span>
<span> · </span>
<span style={{color:"var(--conf-high)"}}>{r.high}</span>
<span> · </span>
<span style={{color:"var(--conf-mid)"}}>{r.review}</span>
<span> · </span>
<span style={{color:"var(--conf-low)"}}>{r.failed}</span>
</span>
</span>
<span><Btn bracket>OPEN</Btn></span>
</div>
))}
</div>
</div>
);
}
Object.assign(window, { ImportScreen });

190
ui_kits/web/Inspector.jsx Normal file
View file

@ -0,0 +1,190 @@
// Inspector right pane for selected transaction
// Shows: source detail, account picker, ledger preview, approve/override/skip
function AccountPicker({ value, onChange, mru = [] }) {
const accounts = window.AKEFIN_DATA.allAccounts;
const [open, setOpen] = React.useState(false);
const [q, setQ] = React.useState("");
const matches = q
? accounts.filter(a => a.toLowerCase().includes(q.toLowerCase())).slice(0, 8)
: accounts.slice(0, 8);
return (
<div className={`acct-picker ${open ? "open" : ""}`}>
<button className="acct-trigger" onClick={() => setOpen(!open)}>
<span className="acct-path">
{value ? value.split(":").map((seg, i, arr) => (
<React.Fragment key={i}>
<span className={i === arr.length - 1 ? "leaf" : "branch"}>{seg}</span>
{i < arr.length - 1 && <span className="sep">:</span>}
</React.Fragment>
)) : <span className="muted">Choose account</span>}
</span>
<Icon name="chevDown" size={14} color="var(--fg-muted)"/>
</button>
{open && (
<div className="acct-menu">
<div className="acct-search">
<Icon name="search" size={13} color="var(--fg-muted)" />
<input autoFocus type="text" value={q} onChange={(e)=>setQ(e.target.value)} placeholder="Search accounts…" />
</div>
{mru.length > 0 && !q && (
<div className="acct-section">
<div className="acct-label">RECENT</div>
{mru.map(a => (
<button key={a} className="acct-item" onClick={()=>{onChange(a); setOpen(false); setQ("");}}>
<span>{a}</span>
<span className="mru-mark">MRU</span>
</button>
))}
</div>
)}
<div className="acct-section">
<div className="acct-label">{q ? "MATCHES" : "ALL ACCOUNTS"}</div>
{matches.map(a => (
<button key={a} className="acct-item" onClick={()=>{onChange(a); setOpen(false); setQ("");}}>
<span>{a}</span>
</button>
))}
</div>
<div className="acct-section">
<button className="acct-item new">
<Icon name="plus" size={12} />
<span>Create new account</span>
</button>
</div>
</div>
)}
</div>
);
}
function LedgerPreview({ tx, account }) {
if (!tx || !account) return (
<div className="ledger-empty">No posting to preview · choose an account</div>
);
const entityHomeCcy = { personal: "KRW", tfox: "KRW", finacode: "EUR" };
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);
const homeCcy = entityHomeCcy[tx.entity];
const needsFx = tx.ccy !== homeCcy;
const fxRate = needsFx ? window.AKEFIN_DATA.fx[tx.ccy] / window.AKEFIN_DATA.fx[homeCcy] : null;
const converted = needsFx ? Math.round(window.AKEFIN_DATA.convert(abs, tx.ccy, homeCcy)) : null;
return (
<div className="ledger-preview">
<div className="lp-head">
<span>{tx.date}</span>
<span className="lp-payee ko">"{tx.payee}"</span>
<span className="lp-conf"><ConfidenceChip score={tx.score} tier={tx.tier} /></span>
</div>
<div className="lp-body">
<div className="lp-leg">
<span className="lp-acct">{debit}</span>
<span className="lp-amt"> {abs.toLocaleString()}</span>
<span className="lp-ccy">{tx.ccy}</span>
</div>
{needsFx && (
<div className="lp-fx-row">
<span className="lp-fx-arrow"></span>
<span className="lp-fx-text">@ <span className="lp-fx-rate">{fxRate.toFixed(2)}</span> {homeCcy} · FX from <span className="lp-fx-meta">ECB · 2026-03-08</span></span>
</div>
)}
<div className="lp-leg">
<span className="lp-acct">{credit}</span>
<span className="lp-amt">-{(needsFx ? converted : abs).toLocaleString()}</span>
<span className="lp-ccy">{needsFx ? homeCcy : tx.ccy}</span>
</div>
</div>
<div className="lp-foot">
{needsFx ? `balanced · 2 legs · FX-converted to ${homeCcy}` : "balanced · 2 legs"} · will commit to {tx.entity === "personal" ? "personal" : tx.entity}-2026-03.ldgr
</div>
</div>
);
}
function Inspector({ tx, onApprove, onSkip }) {
const [account, setAccount] = React.useState(tx?.suggestedAccount || null);
React.useEffect(() => { setAccount(tx?.suggestedAccount || null); }, [tx?.id]);
const mru = ["Personal:Expenses:Food:Coffee", "Personal:Expenses:Groceries", "9TFox:Expenses:Software"];
if (!tx) {
return (
<aside className="inspector empty">
<div className="ins-empty">
<Icon name="activity" size={28} color="var(--fg-faint)" />
<div>Select a transaction to review</div>
</div>
</aside>
);
}
return (
<aside className="inspector">
<div className="ins-section">
<div className="ins-eyebrow">
<span>TRANSACTION · {tx.id.toUpperCase()}</span>
<button className="ins-more"><Icon name="ellipsis" size={14}/></button>
</div>
<div className="ins-payee ko">{tx.payee}</div>
{tx.payeeNote && <div className="ins-payee-note">{tx.payeeNote}</div>}
<div className="ins-meta-grid">
<div className="ins-meta">
<span className="lbl">DATE</span>
<span className="val mono">{tx.date}</span>
</div>
<div className="ins-meta">
<span className="lbl">AMOUNT</span>
<span className="val"><Amount value={tx.amount} ccy={tx.ccy} alignRight={false} /></span>
</div>
<div className="ins-meta">
<span className="lbl">SOURCE</span>
<span className="val mono">{tx.sourceAccount}</span>
</div>
<div className="ins-meta">
<span className="lbl">ENTITY</span>
<span className="val"><EntityBadge entity={tx.entity} /></span>
</div>
</div>
</div>
<div className="ins-section">
<div className="ins-eyebrow"><span>AI SUGGESTION</span><ConfidenceChip score={tx.score} tier={tx.tier} /></div>
<div className="ins-suggestion">
<span className="ins-arrow"></span>
<span className="ins-acct">{tx.suggestedAccount || <span className="muted">No suggestion · tier 3 unmatched</span>}</span>
</div>
<div className="ins-tier-line"><TierBadge tier={tx.tier} /> · matched by {tx.tier === "rules" ? "pattern rule" : tx.tier === "llm" ? "LLM (gpt-4o-mini)" : tx.tier === "agent" ? "agent · 3 tool calls" : "no tier"}</div>
</div>
<div className="ins-section">
<div className="ins-eyebrow"><span>CATEGORIZE TO</span></div>
<AccountPicker value={account} onChange={setAccount} mru={mru} />
</div>
<div className="ins-section">
<div className="ins-eyebrow"><span>LEDGER PREVIEW</span></div>
<LedgerPreview tx={tx} account={account} />
</div>
<div className="ins-actions">
<Btn variant="primary" onClick={() => onApprove(tx.id, account)}>
<Icon name="check" size={13} /> APPROVE
</Btn>
<Btn>OVERRIDE</Btn>
<Btn onClick={() => onSkip(tx.id)}>SKIP</Btn>
<span className="ins-kbd"></span>
</div>
</aside>
);
}
Object.assign(window, { Inspector, LedgerPreview, AccountPicker });

View file

@ -0,0 +1,86 @@
// LedgerScreen per-entity account tree with balances
function TreeNode({ node, depth, expanded, onToggle }) {
const indent = depth * 18;
const isBranch = node.kind === "branch";
return (
<div className={`tree-node ${isBranch ? "branch" : "leaf"}`}
onClick={() => isBranch && onToggle(node.path)}>
<span style={{ width: indent }}></span>
<span className="tree-chevron">
{isBranch ? <Icon name={expanded ? "chevDown" : "chevRight"} size={12} color="var(--fg-muted)"/> : <span className="dot"></span>}
</span>
<span className="tree-path">
{node.path.split(":").slice(-1)[0]}
</span>
<span className="tree-full">{node.path}</span>
<span className="tree-amount"><Amount value={node.balance} ccy={node.ccy} /></span>
</div>
);
}
function LedgerScreen({ entity }) {
const data = window.AKEFIN_DATA.accountsByEntity;
const [expanded, setExpanded] = React.useState(new Set(["Personal:Assets", "Personal:Expenses", "9TFox:Assets", "9TFox:Income"]));
const toggle = (path) => setExpanded(prev => {
const next = new Set(prev);
next.has(path) ? next.delete(path) : next.add(path);
return next;
});
const entities = entity === "all" ? ["personal", "tfox", "finacode"] : [entity];
return (
<div className="ledger-screen">
<div className="screen-head">
<div>
<h1 className="screen-title">Ledger</h1>
<div className="screen-sub">Read-only · synced from <code>~/akefin/ledger/</code> · git rev <code>e4a82c1</code></div>
</div>
<div className="screen-actions">
<Btn><Icon name="refresh" size={13} /> PULL</Btn>
<Btn variant="primary"><Icon name="import" size={13} /> EXPORT</Btn>
</div>
</div>
{entities.map(e => {
const map = window.AKEFIN_DATA.entities.find(x => x.id === e);
const accounts = data[e] || [];
return (
<div key={e} className="ledger-entity">
<div className="ledger-entity-head">
<span className="stripe" style={{ background: map.color }}></span>
<span className="ledger-entity-name">{map.label}</span>
<span className="ledger-entity-meta">{accounts.length} accounts · {e}-2026-03.ldgr</span>
</div>
<div className="tree">
<div className="tree-head">
<span></span>
<span></span>
<span>ACCOUNT</span>
<span>FULL PATH</span>
<span style={{textAlign:"right"}}>BALANCE</span>
</div>
{accounts.map(node => {
if (node.kind === "leaf") {
const parent = node.path.split(":").slice(0, -1).join(":");
if (parent && parent.includes(":") && !expanded.has(parent.split(":").slice(0,2).join(":"))) {
return null;
}
}
const depth = node.path.split(":").length - 1;
return (
<TreeNode key={node.path} node={node} depth={depth}
expanded={expanded.has(node.path)}
onToggle={toggle} />
);
})}
</div>
</div>
);
})}
</div>
);
}
Object.assign(window, { LedgerScreen });

32
ui_kits/web/README.md Normal file
View file

@ -0,0 +1,32 @@
# Akefin Web Dashboard — UI Kit
A pixel-faithful recreation of the **Akefin web dashboard** following the design system in this project. Built from the brief's component inventory (the canonical akefin-design-system repo was empty at the time of generation — see root `README.md`).
## What's in here
| File | Purpose |
|---|---|
| `index.html` | Live, clickable prototype. Open this to see it. |
| `App.jsx` | Top-level composition · routes between Review · Rules · Ledger · Import |
| `Chrome.jsx` | Header (entity scope · search · status) + left rail navigation |
| `ReviewQueue.jsx` | Compact list of staged transactions, sortable, selectable |
| `Inspector.jsx` | Right-pane detail: account picker, ledger preview, approve/override/skip |
| `RulesScreen.jsx` | Promote-suggested-rules view |
| `LedgerScreen.jsx` | Per-entity account tree with balances |
| `ImportScreen.jsx` | Import run status & history |
| `Atoms.jsx` | Buttons, chips, badges, inputs, terminal frame |
| `data.js` | Sample bilingual transactions, rules, accounts |
## Screens
1. **Review queue** *(default)* — list of staged transactions on the left, inspector on the right.
2. **Rules** — promote-suggested-rule cards.
3. **Ledger** — account tree.
4. **Import** — recent import runs and status.
Switch screens via the left rail. The entity scope (top-left of the header) filters everything below.
## Caveats
- Recreated from the brief, not from existing UI source. **Iterate.**
- Korean payee strings are sample data, not a real Toss export.

125
ui_kits/web/ReviewQueue.jsx Normal file
View file

@ -0,0 +1,125 @@
// ReviewQueue compact list of staged transactions
function ReviewQueue({ entity, transactions, selectedId, onSelect, sort, setSort, filters, setFilters, onOpenPalette }) {
const [filterOpen, setFilterOpen] = React.useState(false);
// Apply filters
const cutoffDays = { today: 0, "7d": 7, "30d": 30, "this-month": 30 };
const filtered = transactions.filter(t => {
if (entity !== "all" && t.entity !== entity) return false;
// Date
if (filters.dateRange !== "all" && filters.dateRange !== "custom") {
const days = cutoffDays[filters.dateRange];
const cutoff = new Date("2026-03-08"); // anchor "now"
cutoff.setDate(cutoff.getDate() - days);
if (new Date(t.date) < cutoff) return false;
} else if (filters.dateRange === "custom") {
if (filters.dateFrom && t.date < filters.dateFrom) return false;
if (filters.dateTo && t.date > filters.dateTo) return false;
}
// Categories
if (filters.categories.size > 0) {
const cat = (t.suggestedAccount || "").split(":").slice(2).join(":");
const hit = [...filters.categories].some(c => cat.includes(c));
if (!hit) return false;
}
// Tiers
if (filters.tiers.size > 0 && !filters.tiers.has(t.tier)) return false;
// Confidence
if ((t.score ?? 0) < filters.confidenceMin) return false;
// Currency
if (filters.ccy.size > 0 && !filters.ccy.has(t.ccy)) return false;
return true;
});
const activeCount =
(filters.dateRange !== "all" ? 1 : 0) +
(filters.categories.size > 0 ? 1 : 0) +
(filters.tiers.size > 0 ? 1 : 0) +
(filters.ccy.size > 0 ? 1 : 0) +
(filters.confidenceMin > 0 ? 1 : 0);
const sorted = [...filtered].sort((a, b) => {
if (sort === "date-desc") return b.date.localeCompare(a.date);
if (sort === "date-asc") return a.date.localeCompare(b.date);
if (sort === "conf-asc") return (a.score ?? -1) - (b.score ?? -1);
if (sort === "conf-desc") return (b.score ?? -1) - (a.score ?? -1);
if (sort === "entity") return a.entity.localeCompare(b.entity);
return 0;
});
return (
<div className="review-pane">
<div className="rq-head">
<div className="rq-tabs">
<button className="rq-tab active">Staged <span className="rq-count">{filtered.length}</span></button>
<button className="rq-tab">Approved <span className="rq-count">312</span></button>
<button className="rq-tab">Skipped <span className="rq-count">4</span></button>
</div>
<div className="rq-controls">
<button className={`rq-ctrl ${activeCount > 0 ? "active" : ""}`} onClick={() => setFilterOpen(o => !o)}>
<Icon name="filter" size={13} /> FILTER
{activeCount > 0 && <span className="rq-ctrl-dot">{activeCount}</span>}
</button>
<button className="rq-ctrl" onClick={onOpenPalette}>
<Icon name="spark" size={13} /> K
</button>
<div className="rq-sort">
<Icon name="sort" size={13} />
<select value={sort} onChange={e => setSort(e.target.value)}>
<option value="date-desc">Date · newest</option>
<option value="date-asc">Date · oldest</option>
<option value="conf-asc">Confidence · lowest</option>
<option value="conf-desc">Confidence · highest</option>
<option value="entity">Entity</option>
</select>
<Icon name="chevDown" size={12} />
</div>
</div>
</div>
<FilterPanel open={filterOpen} filters={filters} setFilters={setFilters}
onClose={() => setFilterOpen(false)} activeCount={activeCount} />
<div className="rq-list-head">
<span>DATE</span>
<span>PAYEE</span>
<span style={{textAlign:"right"}}>AMOUNT</span>
<span>ENTITY</span>
<span>CONFIDENCE</span>
<span>TIER</span>
</div>
<div className="rq-list">
{sorted.map(t => (
<button key={t.id}
className={`tx-row ${selectedId === t.id ? "selected" : ""}`}
onClick={() => onSelect(t.id)}>
<span className="tx-date">{t.date.slice(5)}</span>
<span className="tx-payee">
<span className="ko">{t.payee}</span>
{t.payeeNote && <span className="tx-payee-note">{t.payeeNote}</span>}
</span>
<span className="tx-amount-cell"><Amount value={t.amount} ccy={t.ccy} /></span>
<span><EntityBadge entity={t.entity} /></span>
<span><ConfidenceChip score={t.score} tier={t.tier} /></span>
<span><TierBadge tier={t.tier} /></span>
</button>
))}
{sorted.length === 0 && (
<div className="rq-empty">
<div className="rq-empty-title">No transactions match</div>
<div className="rq-empty-sub">Clear filters or expand the date range</div>
</div>
)}
</div>
<div className="rq-foot">
<span className="rq-foot-meta">{sorted.length} of {transactions.filter(t => entity === "all" || t.entity === entity).length} · sorted by {sort.replace("-", " ")}{activeCount > 0 ? ` · ${activeCount} filter${activeCount > 1 ? "s" : ""}` : ""}</span>
<span className="rq-foot-meta"> approve · skip · E override · K commands</span>
</div>
</div>
);
}
Object.assign(window, { ReviewQueue });

View file

@ -0,0 +1,86 @@
// RulesScreen promote-suggested-rules view
function RuleCard({ rule, checked, onToggle, onApply }) {
return (
<div className="rule-card">
<div className="rule-head">
<div className="rule-pattern">
<span className="pattern-prefix">payee ~=</span>
<code className="ko">"{rule.pattern}"</code>
</div>
<div className="rule-occ">
<span className="rule-num">{rule.occurrences}</span>
<span className="rule-occ-lbl">OCCURRENCES · LAST 30d</span>
</div>
<ConfidenceChip score={rule.score} tier="llm" />
</div>
<div className="rule-map">
<span className="ko">{rule.pattern}</span>
<Icon name="arrowRight" size={14} color="var(--fg-muted)" />
<span className="rule-target">{rule.target}</span>
</div>
<div className="rule-examples">
<div className="rule-ex-label">MATCHING TRANSACTIONS</div>
{rule.examples.map((ex, i) => <div key={i} className="rule-ex">{ex}</div>)}
</div>
<div className="rule-actions">
<label className="rule-promote">
<input type="checkbox" checked={checked} onChange={onToggle} />
<span>Promote on next run</span>
</label>
<div style={{ flex: 1 }}></div>
<Btn variant="primary" onClick={onApply}>APPLY RULE</Btn>
<Btn>DISMISS</Btn>
</div>
</div>
);
}
function RulesScreen({ entity }) {
const data = window.AKEFIN_DATA.ruleSuggestions;
const [promoted, setPromoted] = React.useState(new Set(data.map(r => r.id)));
const [applied, setApplied] = React.useState(new Set());
const toggle = (id) => {
setPromoted(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const apply = (id) => setApplied(prev => new Set([...prev, id]));
const visible = data.filter(r => !applied.has(r.id));
return (
<div className="rules-screen">
<div className="screen-head">
<div>
<h1 className="screen-title">Rule suggestions</h1>
<div className="screen-sub">{visible.length} suggested · {promoted.size} promoted on next run · last AI pass 12 minutes ago</div>
</div>
<div className="screen-actions">
<Btn><Icon name="refresh" size={13} /> RE-RUN AI PASS</Btn>
<Btn variant="primary">PROMOTE ALL</Btn>
</div>
</div>
<TermFrame title="AKEFIN — RULE PIPELINE · TIER 1" status="ok">
<span className="m">$</span> akefin rules:suggest --entity {entity === "all" ? "*" : entity} --since 30d{"\n"}
<span className="m">analysed 1,247 transactions · 5 high-frequency patterns matched ·</span>
<span style={{color:"var(--conf-rules)"}}> 218 future rows would auto-categorize</span>
</TermFrame>
<div className="rule-grid">
{visible.map(r => (
<RuleCard key={r.id} rule={r}
checked={promoted.has(r.id)}
onToggle={() => toggle(r.id)}
onApply={() => apply(r.id)} />
))}
</div>
</div>
);
}
Object.assign(window, { RulesScreen });

1577
ui_kits/web/dashboard.css Normal file

File diff suppressed because it is too large Load diff

114
ui_kits/web/data.js Normal file
View file

@ -0,0 +1,114 @@
// Sample bilingual data for the Akefin web dashboard prototype.
window.AKEFIN_DATA = (() => {
const transactions = [
{ id: "t01", date: "2026-03-02", payee: "스타벅스 강남역점", payeeNote: "Starbucks Gangnam", amount: -38500, ccy: "KRW", entity: "personal", suggestedAccount: "Personal:Expenses:Food:Coffee", score: 0.94, tier: "llm", sourceAccount: "Toss · 신한 입출금", status: "staged" },
{ id: "t02", date: "2026-03-02", payee: "이마트 트레이더스", payeeNote: "E-Mart Traders", amount: -124300, ccy: "KRW", entity: "personal", suggestedAccount: "Personal:Expenses:Groceries", score: 0.78, tier: "agent", sourceAccount: "Toss · 신한 입출금", status: "staged" },
{ id: "t03", date: "2026-03-03", payee: "Wise · Finacode partner", payeeNote: null, amount: 1240, ccy: "EUR", entity: "finacode", suggestedAccount: null, score: null, tier: "unmatched",sourceAccount: "Wise · personal", status: "staged" },
{ id: "t04", date: "2026-03-03", payee: "9TFox 컨설팅 — 클라이언트 A", payeeNote: "Consulting income", amount: 2400000, ccy: "KRW", entity: "tfox", suggestedAccount: "9TFox:Income:Consulting", score: 1.00, tier: "rules", sourceAccount: "Kakao Bank · 9TFox", status: "staged" },
{ id: "t05", date: "2026-03-04", payee: "GS25 역삼점", payeeNote: "GS25 convenience", amount: -4800, ccy: "KRW", entity: "personal", suggestedAccount: "Personal:Expenses:Convenience", score: 0.92, tier: "llm", sourceAccount: "Toss · 신한 입출금", status: "staged" },
{ id: "t06", date: "2026-03-04", payee: "관리비 — 서초 오피스텔", payeeNote: "Apartment maintenance", amount: -286000, ccy: "KRW", entity: "personal", suggestedAccount: "Personal:Expenses:Housing:Maint", score: 0.71, tier: "agent", sourceAccount: "Toss · 신한 입출금", status: "staged" },
{ id: "t07", date: "2026-03-05", payee: "Migros · İstanbul Beşiktaş", payeeNote: null, amount: -892, ccy: "TRY", entity: "personal", suggestedAccount: "Personal:Expenses:Groceries", score: 0.88, tier: "llm", sourceAccount: "Garanti BBVA", status: "staged" },
{ id: "t08", date: "2026-03-05", payee: "JR 동일본 — Suica 충전", payeeNote: "JR East · Suica", amount: -3000, ccy: "JPY", entity: "personal", suggestedAccount: "Personal:Expenses:Transit", score: 0.81, tier: "llm", sourceAccount: "Wise · multi-currency", status: "staged" },
{ id: "t09", date: "2026-03-06", payee: "배달의민족", payeeNote: "Baemin · food delivery", amount: -22600, ccy: "KRW", entity: "personal", suggestedAccount: "Personal:Expenses:Food:Delivery", score: 0.96, tier: "rules", sourceAccount: "Toss · 신한 입출금", status: "staged" },
{ id: "t10", date: "2026-03-06", payee: "노션 — 월 구독", payeeNote: "Notion subscription", amount: -16000, ccy: "KRW", entity: "tfox", suggestedAccount: "9TFox:Expenses:Software", score: 0.97, tier: "rules", sourceAccount: "Kakao Bank · 9TFox", status: "staged" },
{ id: "t11", date: "2026-03-07", payee: "MAVI Jeans · Levent", payeeNote: null, amount: -1840, ccy: "TRY", entity: "personal", suggestedAccount: "Personal:Expenses:Clothing", score: 0.68, tier: "agent", sourceAccount: "Garanti BBVA", status: "staged" },
{ id: "t12", date: "2026-03-07", payee: "Hetzner Online GmbH", payeeNote: "Server hosting", amount: -29.40, ccy: "EUR", entity: "tfox", suggestedAccount: "9TFox:Expenses:Infrastructure", score: 0.99, tier: "rules", sourceAccount: "Wise · 9TFox", status: "staged" },
{ id: "t13", date: "2026-03-08", payee: "ATM 출금 — 강남역", payeeNote: "Cash withdrawal", amount: -100000, ccy: "KRW", entity: "personal", suggestedAccount: null, score: 0.42, tier: "unmatched",sourceAccount: "Toss · 신한 입출금", status: "staged" },
];
const ruleSuggestions = [
{ id: "r01", pattern: '스타벅스 *', target: "Personal:Expenses:Food:Coffee", occurrences: 14, score: 0.97, examples: ["2026-03-02 스타벅스 강남역점 38,500 KRW", "2026-02-28 스타벅스 판교점 6,300 KRW", "2026-02-24 스타벅스 양재점 7,100 KRW"] },
{ id: "r02", pattern: '배달의민족', target: "Personal:Expenses:Food:Delivery", occurrences: 22, score: 0.99, examples: ["2026-03-06 배달의민족 22,600 KRW", "2026-02-19 배달의민족 18,400 KRW"] },
{ id: "r03", pattern: 'Hetzner *', target: "9TFox:Expenses:Infrastructure", occurrences: 6, score: 0.98, examples: ["2026-03-07 Hetzner Online GmbH 29.40 EUR", "2026-02-07 Hetzner Online GmbH 29.40 EUR"] },
{ id: "r04", pattern: 'GS25 *', target: "Personal:Expenses:Convenience", occurrences: 19, score: 0.88, examples: ["2026-03-04 GS25 역삼점 4,800 KRW", "2026-03-01 GS25 강남점 3,200 KRW"] },
{ id: "r05", pattern: 'Wise · *partner', target: "Finacode:Income:PartnerShare", occurrences: 3, score: 0.74, examples: ["2026-03-03 Wise · Finacode partner +1,240 EUR", "2026-02-03 Wise · Finacode partner +1,180 EUR"] },
];
const accountsByEntity = {
personal: [
{ path: "Personal:Assets", balance: 24830000, ccy: "KRW", kind: "branch" },
{ path: "Personal:Assets:Bank:Toss", balance: 14200000, ccy: "KRW", kind: "leaf" },
{ path: "Personal:Assets:Bank:Garanti", balance: 62400, ccy: "TRY", kind: "leaf" },
{ path: "Personal:Assets:Bank:Wise", balance: 2840, ccy: "EUR", kind: "leaf" },
{ path: "Personal:Expenses", balance: -942000, ccy: "KRW", kind: "branch" },
{ path: "Personal:Expenses:Food:Coffee", balance: -84600, ccy: "KRW", kind: "leaf" },
{ path: "Personal:Expenses:Food:Delivery", balance: -224000, ccy: "KRW", kind: "leaf" },
{ path: "Personal:Expenses:Groceries", balance: -481600, ccy: "KRW", kind: "leaf" },
{ path: "Personal:Expenses:Transit", balance: -38000, ccy: "KRW", kind: "leaf" },
],
tfox: [
{ path: "9TFox:Assets", balance: 18400000, ccy: "KRW", kind: "branch" },
{ path: "9TFox:Assets:Bank:Kakao", balance: 18400000, ccy: "KRW", kind: "leaf" },
{ path: "9TFox:Income:Consulting", balance: -7200000, ccy: "KRW", kind: "leaf" },
{ path: "9TFox:Expenses:Software", balance: 96000, ccy: "KRW", kind: "leaf" },
{ path: "9TFox:Expenses:Infrastructure", balance: 176.4, ccy: "EUR", kind: "leaf" },
],
finacode: [
{ path: "Finacode:Assets", balance: 6320, ccy: "EUR", kind: "branch" },
{ path: "Finacode:Income:PartnerShare", balance: -3700, ccy: "EUR", kind: "leaf" },
],
};
const importRuns = [
{ id: "i01", at: "2026-03-08 09:14", source: "Toss · 신한 입출금", rows: 247, auto: 153, high: 44, review: 35, failed: 15, entity: "personal" },
{ id: "i02", at: "2026-03-07 18:02", source: "Kakao Bank · 9TFox", rows: 62, auto: 58, high: 3, review: 1, failed: 0, entity: "tfox" },
{ id: "i03", at: "2026-03-07 12:48", source: "Wise · 9TFox", rows: 14, auto: 12, high: 2, review: 0, failed: 0, entity: "tfox" },
{ id: "i04", at: "2026-03-06 22:01", source: "Garanti BBVA", rows: 89, auto: 61, high: 14, review: 11, failed: 3, entity: "personal" },
];
const entities = [
{ id: "all", label: "All entities", short: "ALL", short2: "—" },
{ id: "personal", label: "Personal", short: "P/", color: "#3D6E70" },
{ id: "tfox", label: "9TFox", short: "9T/", color: "#B4541A" },
{ id: "finacode", label: "Finacode", short: "FC/", color: "#5A4FA3" },
];
// Common chart-of-accounts for the picker (sorted)
const allAccounts = [
...accountsByEntity.personal, ...accountsByEntity.tfox, ...accountsByEntity.finacode
].map(a => a.path).sort();
// FX rates (against KRW, the home currency) — sampled rates as of 2026-03-08
const fx = {
KRW: 1,
EUR: 1456.20,
TRY: 41.32,
JPY: 9.21,
USD: 1339.50,
};
const convert = (amount, fromCcy, toCcy) => {
if (fromCcy === toCcy) return amount;
return (amount * fx[fromCcy]) / fx[toCcy];
};
// Commands for the ⌘K palette
const commands = [
{ id: "approve", label: "Approve current", kind: "action", hint: "⏎", icon: "check" },
{ id: "override", label: "Override category", kind: "action", hint: "⌘E", icon: "rule" },
{ id: "skip", label: "Skip and re-queue", kind: "action", hint: "⌫", icon: "x" },
{ id: "approve-all-high", label: "Approve all ≥ 0.85 confidence", kind: "batch", hint: "⌘⇧A", icon: "check" },
{ id: "import-csv", label: "Import Toss CSV…", kind: "action", hint: "⌘I", icon: "import" },
{ id: "poll-toss", label: "Poll Toss for new transactions", kind: "action", hint: "⌘⇧I", icon: "refresh" },
{ id: "promote-rules", label: "Promote suggested rules", kind: "action", hint: "⌘R", icon: "rule" },
{ id: "commit-ledger", label: "Commit ledger to git", kind: "action", hint: "⌘⇧G", icon: "ledger" },
{ id: "switch-personal", label: "Switch scope: Personal", kind: "nav", hint: "⌘1", icon: "card" },
{ id: "switch-9tfox", label: "Switch scope: 9TFox", kind: "nav", hint: "⌘2", icon: "card" },
{ id: "switch-finacode", label: "Switch scope: Finacode", kind: "nav", hint: "⌘3", icon: "card" },
{ id: "go-review", label: "Go to Review queue", kind: "nav", hint: "G R", icon: "activity" },
{ id: "go-rules", label: "Go to Rules", kind: "nav", hint: "G U", icon: "rule" },
{ id: "go-ledger", label: "Go to Ledger", kind: "nav", hint: "G L", icon: "ledger" },
{ id: "go-import", label: "Go to Import status", kind: "nav", hint: "G I", icon: "import" },
{ id: "toggle-theme", label: "Toggle dark theme", kind: "setting", hint: "⌘\\", icon: "card" },
{ id: "search-payee", label: "Search payee or 적요…", kind: "search", hint: "/", icon: "search" },
];
// Categories (for filter)
const categories = [
"Food:Coffee", "Food:Delivery", "Food:Restaurant", "Groceries", "Convenience",
"Transit", "Housing:Maint", "Clothing", "Software", "Infrastructure",
"Income:Consulting", "Income:PartnerShare", "Cash",
];
return { transactions, ruleSuggestions, accountsByEntity, importRuns, entities, allAccounts, fx, convert, commands, categories };
})();

29
ui_kits/web/index.html Normal file
View file

@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=1440">
<title>Akefin — Review Queue</title>
<link rel="icon" href="../../assets/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="../../colors_and_type.css">
<link rel="stylesheet" href="dashboard.css">
</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 src="data.js"></script>
<script type="text/babel" src="Atoms.jsx"></script>
<script type="text/babel" src="Chrome.jsx"></script>
<script type="text/babel" src="CommandFilter.jsx"></script>
<script type="text/babel" src="ReviewQueue.jsx"></script>
<script type="text/babel" src="Inspector.jsx"></script>
<script type="text/babel" src="RulesScreen.jsx"></script>
<script type="text/babel" src="LedgerScreen.jsx"></script>
<script type="text/babel" src="ImportScreen.jsx"></script>
<script type="text/babel" src="App.jsx"></script>
</body>
</html>

BIN
uploads/picode web page Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 699 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 699 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 KiB