first commit
204
README.md
Normal 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.70–0.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
|
|
@ -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, 16–20px. Don't draw new SVG icons.
|
||||
|
||||
If the user gives you a vague request (*"design something for Akefin"*), ask 3–5 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
|
|
@ -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 |
13
assets/akefin-wordmark.svg
Normal 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="'JetBrains Mono','IBM Plex Mono',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
|
|
@ -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
|
|
@ -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
|
|
@ -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.70–0.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
|
|
@ -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);
|
||||
}
|
||||
13
preview/brand-entity-marks.html
Normal 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
|
|
@ -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>
|
||||
18
preview/brand-wordmark.html
Normal 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>
|
||||
54
preview/colors-confidence.html
Normal 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
|
|
@ -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>
|
||||
42
preview/colors-entities.html
Normal 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>
|
||||
49
preview/colors-fg-ramp.html
Normal 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>
|
||||
49
preview/colors-neutrals.html
Normal 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>
|
||||
25
preview/colors-signed-amounts.html
Normal 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
|
|
@ -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>
|
||||
29
preview/comp-confidence-chips.html
Normal 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>
|
||||
30
preview/comp-entity-badges.html
Normal 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>
|
||||
29
preview/comp-iconography.html
Normal 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>
|
||||
38
preview/comp-import-status.html
Normal 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
|
|
@ -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>
|
||||
26
preview/comp-ledger-preview.html
Normal 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>
|
||||
49
preview/comp-rule-suggestion.html
Normal 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>
|
||||
29
preview/comp-terminal-frame.html
Normal 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>
|
||||
18
preview/comp-tier-badges.html
Normal 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>
|
||||
36
preview/comp-transaction-row.html
Normal 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>
|
||||
20
preview/spacing-radii.html
Normal 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
|
|
@ -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>
|
||||
27
preview/spacing-scale.html
Normal 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>
|
||||
19
preview/spacing-shadow.html
Normal 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
|
|
@ -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
|
|
@ -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>
|
||||
24
preview/type-mono-amounts.html
Normal 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>
|
||||
26
preview/type-sans-korean.html
Normal 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>
|
||||
27
preview/type-sans-latin.html
Normal 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>
|
||||
BIN
screenshots/01-v2-csv-fixed.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
screenshots/01-v2-csv-modal.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
screenshots/01-v2-csv.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
screenshots/01-v2-fx-preview-final.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
screenshots/01-v2-fx-preview.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
screenshots/01-v2-palette.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
screenshots/01-v3-csv-modal.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
screenshots/01-v3-dark.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
screenshots/01-v3-dark2.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
screenshots/02-v2-csv-fixed.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
screenshots/02-v2-csv-modal.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
screenshots/02-v2-csv.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
screenshots/02-v2-fx-preview-final.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
screenshots/02-v2-fx-preview.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
screenshots/02-v2-palette.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
screenshots/02-v3-csv-modal.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
screenshots/02-v3-dark.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
screenshots/02-v3-dark2.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
screenshots/03-v2-csv-fixed.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
screenshots/03-v2-palette.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
screenshots/mobile-initial.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
screenshots/v2-debug.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
screenshots/v2-filter-open.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
screenshots/v2-fx-preview.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
screenshots/v2-initial.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
screenshots/web-dashboard-initial.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
72
ui_kits/mobile/MobileApp.jsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// MobileApp — stateful navigation between screens for ONE device frame
|
||||
|
||||
function MobileApp({ initial = "queue", initialTx = null, initialEntity = "all", initialPickerOpen = false }) {
|
||||
const [screen, setScreen] = React.useState(initial);
|
||||
const [entity, setEntity] = React.useState(initialEntity);
|
||||
const [transactions, setTransactions] = React.useState(window.AKEFIN_DATA.transactions);
|
||||
const [activeTxId, setActiveTxId] = React.useState(initialTx);
|
||||
const [pickerOpen, setPickerOpen] = React.useState(initialPickerOpen);
|
||||
const tx = activeTxId ? transactions.find(t => t.id === activeTxId) : null;
|
||||
const [account, setAccount] = React.useState(tx?.suggestedAccount || null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (tx) setAccount(tx.suggestedAccount || null);
|
||||
}, [tx?.id]);
|
||||
|
||||
const openTx = (id) => { setActiveTxId(id); setScreen("detail"); };
|
||||
const back = () => { setScreen("queue"); setActiveTxId(null); };
|
||||
const approve = () => {
|
||||
setTransactions(prev => prev.filter(t => t.id !== activeTxId));
|
||||
back();
|
||||
};
|
||||
const skip = () => {
|
||||
setTransactions(prev => prev.filter(t => t.id !== activeTxId));
|
||||
back();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="m-app">
|
||||
<div className="m-app-body">
|
||||
{screen === "queue" && <MQueueScreen transactions={transactions} entity={entity} onTap={openTx} />}
|
||||
{screen === "detail" && <MDetailScreen tx={tx} onBack={back} onApprove={approve} onSkip={skip} account={account} onOpenPicker={() => setPickerOpen(true)} />}
|
||||
{screen === "rules" && <MRulesScreen entity={entity} />}
|
||||
{screen === "ledger" && <MLedgerScreen entity={entity} />}
|
||||
{screen === "import" && <MImportScreen entity={entity} />}
|
||||
</div>
|
||||
<MTabBar active={screen === "detail" ? "queue" : screen} onChange={(s) => { setActiveTxId(null); setScreen(s); }} />
|
||||
<MAccountPicker open={pickerOpen} onClose={() => setPickerOpen(false)}
|
||||
onPick={(a) => { setAccount(a); setPickerOpen(false); }}
|
||||
current={account} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A tiny rules screen for mobile
|
||||
function MRulesScreen({ entity }) {
|
||||
const rules = window.AKEFIN_DATA.ruleSuggestions;
|
||||
return (
|
||||
<div className="m-screen">
|
||||
<MHeader eyebrow="AI SUGGESTIONS" title="Rules" />
|
||||
<div className="m-list">
|
||||
{rules.map(r => (
|
||||
<div key={r.id} className="m-rule-card">
|
||||
<div className="m-rule-top">
|
||||
<code className="m-rule-pattern ko">"{r.pattern}"</code>
|
||||
<MConfidenceChip score={r.score} tier="llm" />
|
||||
</div>
|
||||
<div className="m-rule-map">
|
||||
<span className="m-arrow">→</span>
|
||||
<span className="mono">{r.target}</span>
|
||||
</div>
|
||||
<div className="m-rule-foot">
|
||||
<span className="m-rule-occ"><span className="num">{r.occurrences}</span> matches · 30d</span>
|
||||
<button className="m-rule-promote">Promote ✓</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { MobileApp, MRulesScreen });
|
||||
68
ui_kits/mobile/MobileAtoms.jsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// Mobile-tuned atoms — larger touch targets, same visual language.
|
||||
// Re-uses .chip / .entity-badge / .tier / .amount from colors_and_type.css.
|
||||
|
||||
function MEntityBadge({ entity, size = "md" }) {
|
||||
const map = {
|
||||
personal: "personal", tfox: "tfox", finacode: "finacode",
|
||||
all: "personal"
|
||||
};
|
||||
const labels = { personal: "Personal", tfox: "9TFox", finacode: "Finacode", all: "All" };
|
||||
return <span className={`m-entity-badge ${map[entity]} sz-${size}`}>{labels[entity]}</span>;
|
||||
}
|
||||
|
||||
function MConfidenceChip({ score, tier }) {
|
||||
let cls = "low";
|
||||
if (tier === "rules" || (score !== null && score >= 1)) cls = "rules";
|
||||
else if (score !== null && score >= 0.85) cls = "high";
|
||||
else if (score !== null && score >= 0.70) cls = "mid";
|
||||
const display = score === null ? "—" : score.toFixed(2);
|
||||
return <span className={`m-chip ${cls}`}>★ {display}</span>;
|
||||
}
|
||||
|
||||
function MTierBadge({ tier }) {
|
||||
const map = { rules: "Rules", llm: "LLM", agent: "Agent", unmatched: "Unmatched" };
|
||||
return <span className={`m-tier ${tier}`}>{map[tier]}</span>;
|
||||
}
|
||||
|
||||
function MAmount({ value, ccy, big = false }) {
|
||||
if (value === null || value === undefined) return <span className="m-amount muted">—</span>;
|
||||
const cls = value > 0 ? "pos" : "neg";
|
||||
const sign = value > 0 ? "+ " : "− ";
|
||||
const abs = Math.abs(value);
|
||||
const formatted = abs.toLocaleString("en-US", { maximumFractionDigits: ccy === "EUR" ? 2 : 0 });
|
||||
return (
|
||||
<span className={`m-amount ${cls} ${big ? "big" : ""}`}>
|
||||
{sign}{formatted}<span className="m-ccy"> {ccy}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MIcon({ name, size = 18, stroke = 1.5, color = "currentColor" }) {
|
||||
const d = window.ICONS_DATA[name];
|
||||
if (!d) return null;
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d={d}/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
window.ICONS_DATA = {
|
||||
check: "M20 6 L9 17 L4 12",
|
||||
x: "M18 6 L6 18 M6 6 L18 18",
|
||||
search: "M11 4 a7 7 0 1 0 0 14 a7 7 0 0 0 0 -14 M21 21 L16.65 16.65",
|
||||
chevDown: "M6 9 L12 15 L18 9",
|
||||
chevRight: "M9 6 L15 12 L9 18",
|
||||
chevLeft: "M15 6 L9 12 L15 18",
|
||||
plus: "M12 5 L12 19 M5 12 L19 12",
|
||||
more: "M5 12 h.01 M12 12 h.01 M19 12 h.01",
|
||||
arrowRight: "M5 12 H19 M13 6 L19 12 L13 18",
|
||||
filter: "M3 5 H21 L14 13 V20 L10 22 V13 L3 5",
|
||||
rule: "M4 4 H20 V20 H4 Z M4 9 H20 M4 14 H20 M9 9 V20",
|
||||
ledger: "M5 3 H17 a2 2 0 0 1 2 2 V21 L12 17 L5 21 Z",
|
||||
activity: "M22 12 H18 L15 21 L9 3 L6 12 H2",
|
||||
import: "M12 2 L12 22 M19 15 L12 22 L5 15",
|
||||
refresh: "M21 12 a9 9 0 1 1 -3 -6.7 M21 4 V10 H15",
|
||||
};
|
||||
|
||||
Object.assign(window, { MEntityBadge, MConfidenceChip, MTierBadge, MAmount, MIcon });
|
||||
311
ui_kits/mobile/MobileScreens.jsx
Normal file
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,105 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=1480">
|
||||
<title>Akefin Mobile</title>
|
||||
<link rel="icon" href="../../assets/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="../../colors_and_type.css">
|
||||
<link rel="stylesheet" href="mobile.css">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; min-height: 100vh; }
|
||||
body {
|
||||
background: #DCD7CC;
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(26,24,20,0.045) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(26,24,20,0.045) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--fg);
|
||||
padding: 48px 32px 64px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.stage {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
max-width: 1480px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.frame-wrap {
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.frame-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.stage-title {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.stage-title .display {
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 36px;
|
||||
color: var(--fg-strong);
|
||||
line-height: 1.1;
|
||||
}
|
||||
.stage-title .sub {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
color: var(--fg-muted);
|
||||
margin-top: 6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="stage-title">
|
||||
<div class="display">Akefin Mobile</div>
|
||||
<div class="sub">Review queue · Transaction detail · Account picker sheet — tap any device to interact</div>
|
||||
</div>
|
||||
<div class="stage" id="stage"></div>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
|
||||
<script src="../web/data.js"></script>
|
||||
<script type="text/babel" src="ios-frame.jsx"></script>
|
||||
<script type="text/babel" src="MobileAtoms.jsx"></script>
|
||||
<script type="text/babel" src="MobileScreens.jsx"></script>
|
||||
<script type="text/babel" src="MobileApp.jsx"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
function Stage() {
|
||||
const frames = [
|
||||
{ id: "queue", label: "01 · Review queue", props: { initial: "queue" } },
|
||||
{ id: "detail", label: "02 · Transaction detail", props: { initial: "detail", initialTx: "t02" } },
|
||||
{ id: "picker", label: "03 · Account picker", props: { initial: "detail", initialTx: "t02", initialPickerOpen: true } },
|
||||
];
|
||||
return (
|
||||
<React.Fragment>
|
||||
{frames.map(f => (
|
||||
<div key={f.id} className="frame-wrap">
|
||||
<IOSDevice width={402} height={874}>
|
||||
<MobileApp {...f.props} />
|
||||
</IOSDevice>
|
||||
<div className="frame-label">{f.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
ReactDOM.createRoot(document.getElementById("stage")).render(<Stage />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
338
ui_kits/mobile/ios-frame.jsx
Normal file
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 });
|
||||
295
ui_kits/web/CommandFilter.jsx
Normal 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 });
|
||||
89
ui_kits/web/ImportScreen.jsx
Normal 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
|
|
@ -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 });
|
||||
86
ui_kits/web/LedgerScreen.jsx
Normal 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
|
|
@ -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
|
|
@ -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 });
|
||||
86
ui_kits/web/RulesScreen.jsx
Normal 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
114
ui_kits/web/data.js
Normal 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
|
|
@ -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
|
After Width: | Height: | Size: 699 KiB |
BIN
uploads/picode_screenshot.png
Normal file
|
After Width: | Height: | Size: 699 KiB |
BIN
uploads/screenshot-2026-05-22_22-25-21.png
Normal file
|
After Width: | Height: | Size: 662 KiB |