chore: refactored structure
46739c2f
8 file(s) · +630 −251
| 1 | + | # Keystroke Dynamics: Full Implementation Plan |
|
| 2 | + | ||
| 3 | + | ## Context |
|
| 4 | + | ||
| 5 | + | The existing Svelte app captures raw digraph timing (6 metrics) and displays averages + a table. It has no persistence, no profile building, no comparison logic, and no human-ness analysis. The goal is to build a complete keystroke dynamics research tool that can: |
|
| 6 | + | 1. Build typing signatures from free-text sessions |
|
| 7 | + | 2. Store and manage user profiles |
|
| 8 | + | 3. Compare sessions against profiles (same-user verification) |
|
| 9 | + | 4. Score whether a typing session appears human or bot-generated |
|
| 10 | + | ||
| 11 | + | Based on research from the keystroke dynamics survey (arxiv 2303.04605v2), the approach uses digraph aggregation, Scaled Manhattan Distance for comparison, and multi-factor human-ness scoring. |
|
| 12 | + | ||
| 13 | + | **Decisions:** |
|
| 14 | + | - Keep legacy Svelte `$:` reactive syntax (no runes migration) |
|
| 15 | + | - Build all 4 tabs from the start with placeholders for unbuilt phases |
|
| 16 | + | - Track paste events and factor into human-ness analysis |
|
| 17 | + | ||
| 18 | + | --- |
|
| 19 | + | ||
| 20 | + | ## Step 0: Refactor & Extract Components |
|
| 21 | + | ||
| 22 | + | Extract the monolithic `App.svelte` into a component architecture. Keystroke capture stays at the window level (always active regardless of tab). |
|
| 23 | + | ||
| 24 | + | **Files to create:** |
|
| 25 | + | - `src/lib/types.ts` — all TypeScript interfaces (RawDigraph, DigraphAggregation, MetricStats, Profile, ComparisonResult, HumannessResult, etc.) |
|
| 26 | + | - `src/lib/utils.ts` — extract `round()`, `avg()`, add `std()` helper |
|
| 27 | + | - `src/lib/components/CapturePanel.svelte` — textarea + raw digraph table + JSON export (extracted from App.svelte) |
|
| 28 | + | - `src/lib/components/StatsBar.svelte` — the 6-metric averages bar |
|
| 29 | + | - `src/lib/components/TabBar.svelte` — tab navigation (Capture | Profile | Compare | Human-ness) |
|
| 30 | + | ||
| 31 | + | **Refactor `App.svelte`** into orchestrator: holds canonical `digraphs[]` array and `sessionMetadata`, manages tab state, keeps `svelte:window` keydown/keyup handlers so capture works on all tabs. |
|
| 32 | + | ||
| 33 | + | **Tab setup:** All 4 tabs visible from the start. Non-implemented tabs show a placeholder until their phase is built. |
|
| 34 | + | ||
| 35 | + | **Key gotcha:** Don't conditionally render CapturePanel with `{#if}` — either always render it (hide with CSS) or keep the `svelte:window` bindings in App.svelte. Recommended: keep handlers in App.svelte. |
|
| 36 | + | ||
| 37 | + | --- |
|
| 38 | + | ||
| 39 | + | ## Step 1: Per-Digraph Aggregation & Profile Building |
|
| 40 | + | ||
| 41 | + | **New file: `src/lib/aggregation.ts`** |
|
| 42 | + | - `groupDigraphs(digraphs)` — group by normalized key pair (lowercase key1 + key2) |
|
| 43 | + | - `computeMetricStats(values[])` — returns `{ mean, std, min, max, count }` for a number array |
|
| 44 | + | - `aggregateDigraphs(digraphs)` — for each group, compute MetricStats for all 6 metrics, sort by count descending |
|
| 45 | + | ||
| 46 | + | **Session metadata tracking (add to App.svelte handlers):** |
|
| 47 | + | - `totalKeystrokes` — increment on every keydown (not just digraph-forming) |
|
| 48 | + | - `backspaceCount` — increment when `e.key === 'Backspace'` |
|
| 49 | + | - `pauseCount` — gaps > 500ms between consecutive keydowns (track `lastKeydownTime`) |
|
| 50 | + | - `avgTypingSpeed` — chars/min from total keystrokes and session duration |
|
| 51 | + | - `sessionDurationMs` — first keydown to last keyup |
|
| 52 | + | ||
| 53 | + | **New component: `src/lib/components/ProfileSummary.svelte`** |
|
| 54 | + | - Table of top 15 digraphs by frequency: `keys | count | HT1 (mean +/- std) | ... all 6 metrics` |
|
| 55 | + | - "Show all" toggle |
|
| 56 | + | - Session metadata summary row |
|
| 57 | + | ||
| 58 | + | **Case normalization:** Treat `'A'` and `'a'` as the same key when grouping digraphs. Include Backspace and Enter in aggregation (they carry signature), exclude arrow/navigation keys. |
|
| 59 | + | ||
| 60 | + | --- |
|
| 61 | + | ||
| 62 | + | ## Step 2: Profile Storage & Management |
|
| 63 | + | ||
| 64 | + | **New file: `src/lib/storage.ts`** |
|
| 65 | + | - localStorage key: `"kd_profiles"` |
|
| 66 | + | - `saveProfile(profile: Profile)` — JSON stringify, append to stored array |
|
| 67 | + | - `loadProfiles(): Profile[]` — parse with try/catch, validate shape |
|
| 68 | + | - `deleteProfile(id: string)` — filter and re-save |
|
| 69 | + | - `exportProfile(profile: Profile)` — Blob + URL.createObjectURL for JSON file download |
|
| 70 | + | - `importProfile(file: File): Profile` — FileReader, JSON.parse, validate |
|
| 71 | + | ||
| 72 | + | **Profile interface stores aggregations (not raw digraphs)** to keep localStorage size manageable. |
|
| 73 | + | ||
| 74 | + | **New component: `src/lib/components/ProfileManager.svelte`** |
|
| 75 | + | - Text input + "Save Profile" button (default name: "Session - {date}") |
|
| 76 | + | - List of saved profiles: name, date, digraph count, delete button |
|
| 77 | + | - Export button per profile (JSON file download) |
|
| 78 | + | - Import button (hidden file input) |
|
| 79 | + | ||
| 80 | + | --- |
|
| 81 | + | ||
| 82 | + | ## Step 3: Session Comparison (Scaled Manhattan Distance) |
|
| 83 | + | ||
| 84 | + | **New file: `src/lib/comparison.ts`** |
|
| 85 | + | ||
| 86 | + | **Algorithm: Scaled Manhattan Distance** |
|
| 87 | + | 1. Build lookup maps from both session and profile aggregations |
|
| 88 | + | 2. Find shared digraph types (by normalized key pair) |
|
| 89 | + | 3. For each shared digraph, for each of 6 metrics: |
|
| 90 | + | - `distance = |mean_session - mean_profile| / std_profile` |
|
| 91 | + | - If `std_profile < epsilon`: use `|mean_session - mean_profile| / max(mean_profile * 0.1, 1.0)` as fallback |
|
| 92 | + | 4. `overallDistance = sum(all distances) / (sharedCount * 6)` |
|
| 93 | + | 5. `similarityPercent = max(0, 100 * (1 - overallDistance / 3.0))` — distance of 3 std devs = 0% similar |
|
| 94 | + | 6. Confidence: `shared >= 15` high, `>= 10` medium, `>= 5` low, `< 5` insufficient |
|
| 95 | + | ||
| 96 | + | **New component: `src/lib/components/ComparisonView.svelte`** |
|
| 97 | + | - Dropdown to select a saved profile |
|
| 98 | + | - "Compare" button (disabled if insufficient overlap) |
|
| 99 | + | - Results: large similarity %, confidence badge, raw distance score |
|
| 100 | + | - Per-digraph breakdown table sorted by distance (worst first), with color-coded match quality bars |
|
| 101 | + | - Note in UI: comparing prose vs code sessions for the same user will produce poor matches |
|
| 102 | + | ||
| 103 | + | --- |
|
| 104 | + | ||
| 105 | + | ## Step 4: Human-ness Detection |
|
| 106 | + | ||
| 107 | + | **New file: `src/lib/humanness.ts`** |
|
| 108 | + | ||
| 109 | + | Six sub-scores (0-100 each), combined via weighted average: |
|
| 110 | + | ||
| 111 | + | | Sub-score | Weight | What it measures | Human signal | |
|
| 112 | + | |-----------|--------|-----------------|-------------| |
|
| 113 | + | | Timing Variance | 0.20 | CV (std/mean) of pressPress per digraph | Humans CV > 0.15, bots < 0.05 | |
|
| 114 | + | | Correction Rate | 0.15 | backspaceCount / totalKeystrokes | Humans 5-15%, zero is suspicious | |
|
| 115 | + | | Pause Distribution | 0.20 | Presence/variance of gaps > 500ms, > 1s | Humans have thinking pauses | |
|
| 116 | + | | Distribution Shape | 0.15 | Skewness of timing arrays | Human timing is right-skewed (log-normal) | |
|
| 117 | + | | Flight Time Negativity | 0.15 | % of digraphs with releasePress < 0 | Humans overlap keys, bots don't | |
|
| 118 | + | | Burst Patterns | 0.15 | Variance of "burst" lengths (consecutive digraphs < 300ms gap) | Humans type in bursts of 3-15 keys | |
|
| 119 | + | ||
| 120 | + | **Composite verdict:** score >= 60 "likely human", 40-60 "uncertain", < 40 "likely bot" |
|
| 121 | + | ||
| 122 | + | **Minimum requirement:** 20 digraphs before computing. Show "keep typing..." below that. |
|
| 123 | + | ||
| 124 | + | **New component: `src/lib/components/HumannessView.svelte`** |
|
| 125 | + | - Large score display (0-100) with verdict text and color |
|
| 126 | + | - 6 horizontal bars showing each sub-score with label |
|
| 127 | + | - Brief explanation under each bar |
|
| 128 | + | ||
| 129 | + | **Paste detection:** Add `on:paste` handler to textarea. Track `pasteCount` and `pastedCharCount` in session metadata. Factor into human-ness score — a session where most content was pasted (high paste ratio vs keystrokes) scores lower. Not inherently disqualifying, but a signal. |
|
| 130 | + | ||
| 131 | + | --- |
|
| 132 | + | ||
| 133 | + | ## Implementation Order |
|
| 134 | + | ||
| 135 | + | ``` |
|
| 136 | + | Step 0 (refactor) --> Step 1 (aggregation) --> Step 2 (storage) |
|
| 137 | + | | |
|
| 138 | + | Step 3 (comparison) |
|
| 139 | + | | |
|
| 140 | + | Step 4 (human-ness) |
|
| 141 | + | ``` |
|
| 142 | + | ||
| 143 | + | Steps 3 and 4 are independent of each other (both depend on Step 1's aggregation). Step 2 must come before Step 3 (comparison needs saved profiles to compare against). |
|
| 144 | + | ||
| 145 | + | --- |
|
| 146 | + | ||
| 147 | + | ## File Summary |
|
| 148 | + | ||
| 149 | + | | File | Action | |
|
| 150 | + | |------|--------| |
|
| 151 | + | | `src/App.svelte` | Refactor into orchestrator | |
|
| 152 | + | | `src/lib/types.ts` | New — all interfaces | |
|
| 153 | + | | `src/lib/utils.ts` | New — shared math helpers | |
|
| 154 | + | | `src/lib/aggregation.ts` | New — digraph grouping + stats | |
|
| 155 | + | | `src/lib/storage.ts` | New — localStorage profile CRUD | |
|
| 156 | + | | `src/lib/comparison.ts` | New — Scaled Manhattan Distance | |
|
| 157 | + | | `src/lib/humanness.ts` | New — 6-factor human-ness scoring | |
|
| 158 | + | | `src/lib/components/CapturePanel.svelte` | New — extracted capture UI | |
|
| 159 | + | | `src/lib/components/StatsBar.svelte` | New — extracted stats bar | |
|
| 160 | + | | `src/lib/components/TabBar.svelte` | New — tab navigation | |
|
| 161 | + | | `src/lib/components/ProfileSummary.svelte` | New — aggregated digraph view | |
|
| 162 | + | | `src/lib/components/ProfileManager.svelte` | New — save/load/export profiles | |
|
| 163 | + | | `src/lib/components/ComparisonView.svelte` | New — comparison results | |
|
| 164 | + | | `src/lib/components/HumannessView.svelte` | New — human-ness score display | |
|
| 165 | + | | `src/app.css` | Minor additions for tabs, score displays | |
|
| 166 | + | ||
| 167 | + | --- |
|
| 168 | + | ||
| 169 | + | ## Verification |
|
| 170 | + | ||
| 171 | + | After each step: |
|
| 172 | + | 1. `bun run dev` — app loads without errors |
|
| 173 | + | 2. Type in textarea — digraphs still captured correctly |
|
| 174 | + | 3. Step 1: Verify aggregated stats appear in Profile tab, digraph grouping works with repeated pairs |
|
| 175 | + | 4. Step 2: Save a profile, reload page, profile persists. Export/import round-trips correctly. |
|
| 176 | + | 5. Step 3: Save a profile, type new session, compare — similarity score and per-digraph breakdown appear. Test with same content (should be high similarity) and different content (lower but non-zero for same user). |
|
| 177 | + | 6. Step 4: Type naturally — should score > 60. Test edge case: very short session shows "keep typing" message. |
| 1 | 1 | <script> |
|
| 2 | + | import { round } from './lib/utils'; |
|
| 3 | + | import TabBar from './lib/components/TabBar.svelte'; |
|
| 4 | + | import CapturePanel from './lib/components/CapturePanel.svelte'; |
|
| 5 | + | ||
| 2 | 6 | let pendingKeys = new Map(); |
|
| 3 | 7 | let lastCompleted = null; |
|
| 4 | 8 | let digraphs = []; |
|
| 5 | 9 | let typedText = ''; |
|
| 6 | - | let copied = false; |
|
| 10 | + | let activeTab = 'capture'; |
|
| 7 | 11 | ||
| 8 | 12 | const MODIFIER_KEYS = new Set(['Shift', 'Control', 'Alt', 'Meta', 'CapsLock']); |
|
| 9 | 13 | ||
| 32 | 36 | const pressRelease = round(k2.releaseTime - k1.pressTime); |
|
| 33 | 37 | const releasePress = round(k2.pressTime - k1.releaseTime); |
|
| 34 | 38 | ||
| 35 | - | const label = (k) => (k === ' ' ? '␣' : k); |
|
| 39 | + | const label = (k) => (k === ' ' ? '\u2423' : k); |
|
| 36 | 40 | digraphs = [ |
|
| 37 | 41 | { |
|
| 38 | 42 | id: Date.now(), |
|
| 39 | - | keys: `${label(k1.key)} → ${label(k2.key)}`, |
|
| 43 | + | keys: `${label(k1.key)} \u2192 ${label(k2.key)}`, |
|
| 40 | 44 | key1: k1.key, |
|
| 41 | 45 | key2: k2.key, |
|
| 42 | 46 | holdTime1, |
|
| 53 | 57 | lastCompleted = currentEvent; |
|
| 54 | 58 | } |
|
| 55 | 59 | ||
| 56 | - | function round(v) { |
|
| 57 | - | return Math.round(v * 10) / 10; |
|
| 58 | - | } |
|
| 59 | - | ||
| 60 | - | function avg(arr, fn) { |
|
| 61 | - | if (arr.length === 0) return '—'; |
|
| 62 | - | return (arr.reduce((s, d) => s + fn(d), 0) / arr.length).toFixed(1); |
|
| 63 | - | } |
|
| 64 | - | ||
| 65 | 60 | function clear() { |
|
| 66 | 61 | digraphs = []; |
|
| 67 | 62 | lastCompleted = null; |
|
| 68 | 63 | pendingKeys = new Map(); |
|
| 69 | 64 | typedText = ''; |
|
| 70 | - | copied = false; |
|
| 71 | 65 | } |
|
| 72 | - | ||
| 73 | - | function copyJson() { |
|
| 74 | - | navigator.clipboard.writeText(jsonData); |
|
| 75 | - | copied = true; |
|
| 76 | - | setTimeout(() => (copied = false), 2000); |
|
| 77 | - | } |
|
| 78 | - | ||
| 79 | - | $: avgHT1 = avg(digraphs, (d) => d.holdTime1); |
|
| 80 | - | $: avgHT2 = avg(digraphs, (d) => d.holdTime2); |
|
| 81 | - | $: avgPP = avg(digraphs, (d) => d.pressPress); |
|
| 82 | - | $: avgRR = avg(digraphs, (d) => d.releaseRelease); |
|
| 83 | - | $: avgPR = avg(digraphs, (d) => d.pressRelease); |
|
| 84 | - | $: avgRP = avg(digraphs, (d) => d.releasePress); |
|
| 85 | - | ||
| 86 | - | $: jsonData = JSON.stringify( |
|
| 87 | - | digraphs.map((d) => ({ |
|
| 88 | - | keys: d.keys, |
|
| 89 | - | key_1: d.key1, |
|
| 90 | - | key_2: d.key2, |
|
| 91 | - | hold_time_1_ms: d.holdTime1, |
|
| 92 | - | hold_time_2_ms: d.holdTime2, |
|
| 93 | - | press_press_ms: d.pressPress, |
|
| 94 | - | release_release_ms: d.releaseRelease, |
|
| 95 | - | press_release_ms: d.pressRelease, |
|
| 96 | - | release_press_ms: d.releasePress, |
|
| 97 | - | })), |
|
| 98 | - | null, |
|
| 99 | - | 2 |
|
| 100 | - | ); |
|
| 101 | 66 | </script> |
|
| 102 | 67 | ||
| 103 | 68 | <svelte:window on:keydown={handleKeyDown} on:keyup={handleKeyUp} /> |
|
| 108 | 73 | <p class="sub">type in the textarea to capture digraph timing features</p> |
|
| 109 | 74 | </div> |
|
| 110 | 75 | ||
| 111 | - | <textarea |
|
| 112 | - | bind:value={typedText} |
|
| 113 | - | placeholder="start typing..." |
|
| 114 | - | rows="4" |
|
| 115 | - | ></textarea> |
|
| 76 | + | <TabBar bind:activeTab /> |
|
| 116 | 77 | ||
| 117 | - | {#if digraphs.length > 0} |
|
| 118 | - | <div class="stats-bar"> |
|
| 119 | - | <div class="stat"> |
|
| 120 | - | <span class="stat-val">{avgHT1}<span class="unit-sm">ms</span></span> |
|
| 121 | - | <span class="stat-label">hold time 1</span> |
|
| 122 | - | </div> |
|
| 123 | - | <div class="stat"> |
|
| 124 | - | <span class="stat-val">{avgHT2}<span class="unit-sm">ms</span></span> |
|
| 125 | - | <span class="stat-label">hold time 2</span> |
|
| 126 | - | </div> |
|
| 127 | - | <div class="stat"> |
|
| 128 | - | <span class="stat-val">{avgPP}<span class="unit-sm">ms</span></span> |
|
| 129 | - | <span class="stat-label">press-press</span> |
|
| 130 | - | </div> |
|
| 131 | - | <div class="stat"> |
|
| 132 | - | <span class="stat-val">{avgRR}<span class="unit-sm">ms</span></span> |
|
| 133 | - | <span class="stat-label">release-release</span> |
|
| 134 | - | </div> |
|
| 135 | - | <div class="stat"> |
|
| 136 | - | <span class="stat-val">{avgPR}<span class="unit-sm">ms</span></span> |
|
| 137 | - | <span class="stat-label">press-release</span> |
|
| 138 | - | </div> |
|
| 139 | - | <div class="stat"> |
|
| 140 | - | <span class="stat-val">{avgRP}<span class="unit-sm">ms</span></span> |
|
| 141 | - | <span class="stat-label">release-press</span> |
|
| 142 | - | </div> |
|
| 78 | + | {#if activeTab === 'capture'} |
|
| 79 | + | <CapturePanel bind:digraphs bind:typedText /> |
|
| 80 | + | {#if digraphs.length > 0} |
|
| 81 | + | <button class="clear-btn" on:click={clear}>clear session</button> |
|
| 82 | + | {/if} |
|
| 83 | + | {:else if activeTab === 'profile'} |
|
| 84 | + | <div class="placeholder"> |
|
| 85 | + | <p>profile builder</p> |
|
| 86 | + | <span class="placeholder-sub">coming in step 1</span> |
|
| 143 | 87 | </div> |
|
| 144 | - | ||
| 145 | - | <div class="digraph-table-wrap"> |
|
| 146 | - | <table class="digraph-table"> |
|
| 147 | - | <thead> |
|
| 148 | - | <tr> |
|
| 149 | - | <th>keys</th> |
|
| 150 | - | <th>HT1</th> |
|
| 151 | - | <th>HT2</th> |
|
| 152 | - | <th>PP</th> |
|
| 153 | - | <th>RR</th> |
|
| 154 | - | <th>PR</th> |
|
| 155 | - | <th>RP</th> |
|
| 156 | - | </tr> |
|
| 157 | - | </thead> |
|
| 158 | - | <tbody> |
|
| 159 | - | {#each digraphs as d (d.id)} |
|
| 160 | - | <tr> |
|
| 161 | - | <td class="keys-cell">{d.keys}</td> |
|
| 162 | - | <td>{d.holdTime1}</td> |
|
| 163 | - | <td>{d.holdTime2}</td> |
|
| 164 | - | <td>{d.pressPress}</td> |
|
| 165 | - | <td>{d.releaseRelease}</td> |
|
| 166 | - | <td>{d.pressRelease}</td> |
|
| 167 | - | <td>{d.releasePress}</td> |
|
| 168 | - | </tr> |
|
| 169 | - | {/each} |
|
| 170 | - | </tbody> |
|
| 171 | - | </table> |
|
| 88 | + | {:else if activeTab === 'compare'} |
|
| 89 | + | <div class="placeholder"> |
|
| 90 | + | <p>session comparison</p> |
|
| 91 | + | <span class="placeholder-sub">coming in step 3</span> |
|
| 172 | 92 | </div> |
|
| 173 | - | ||
| 174 | - | <div class="json-section"> |
|
| 175 | - | <div class="json-header"> |
|
| 176 | - | <span>data</span> |
|
| 177 | - | <div class="json-actions"> |
|
| 178 | - | <button on:click={copyJson}>{copied ? 'copied!' : 'copy'}</button> |
|
| 179 | - | <button on:click={clear}>clear</button> |
|
| 180 | - | </div> |
|
| 181 | - | </div> |
|
| 182 | - | <pre class="json-output">{jsonData}</pre> |
|
| 93 | + | {:else if activeTab === 'humanness'} |
|
| 94 | + | <div class="placeholder"> |
|
| 95 | + | <p>human-ness analysis</p> |
|
| 96 | + | <span class="placeholder-sub">coming in step 4</span> |
|
| 183 | 97 | </div> |
|
| 184 | 98 | {/if} |
|
| 185 | 99 | </main> |
|
| 211 | 125 | color: #888; |
|
| 212 | 126 | } |
|
| 213 | 127 | ||
| 214 | - | textarea { |
|
| 215 | - | width: 100%; |
|
| 216 | - | border: 1px solid #333; |
|
| 217 | - | background: transparent; |
|
| 218 | - | color: inherit; |
|
| 219 | - | font-family: inherit; |
|
| 220 | - | font-size: 14px; |
|
| 221 | - | padding: 1rem; |
|
| 222 | - | resize: vertical; |
|
| 223 | - | outline: none; |
|
| 224 | - | box-sizing: border-box; |
|
| 128 | + | .clear-btn { |
|
| 129 | + | align-self: flex-end; |
|
| 130 | + | font-size: 12px; |
|
| 131 | + | padding: 4px 8px; |
|
| 225 | 132 | } |
|
| 226 | 133 | ||
| 227 | - | textarea::placeholder { |
|
| 228 | - | color: #888; |
|
| 229 | - | } |
|
| 230 | - | ||
| 231 | - | textarea:focus { |
|
| 232 | - | border-color: #888; |
|
| 233 | - | } |
|
| 234 | - | ||
| 235 | - | .stats-bar { |
|
| 236 | - | display: grid; |
|
| 237 | - | grid-template-columns: repeat(6, 1fr); |
|
| 238 | - | border: 1px solid #333; |
|
| 239 | - | } |
|
| 240 | - | ||
| 241 | - | .stat { |
|
| 134 | + | .placeholder { |
|
| 242 | 135 | display: flex; |
|
| 243 | 136 | flex-direction: column; |
|
| 244 | 137 | align-items: center; |
|
| 245 | - | padding: 0.75rem 0.5rem; |
|
| 246 | - | border-right: 1px solid #333; |
|
| 247 | - | } |
|
| 248 | - | ||
| 249 | - | .stat:last-child { |
|
| 250 | - | border-right: none; |
|
| 138 | + | justify-content: center; |
|
| 139 | + | padding: 4rem 2rem; |
|
| 140 | + | border: 1px dashed #333; |
|
| 141 | + | gap: 0.5rem; |
|
| 251 | 142 | } |
|
| 252 | 143 | ||
| 253 | - | .stat-val { |
|
| 144 | + | .placeholder p { |
|
| 254 | 145 | font-size: 14px; |
|
| 255 | - | font-weight: 700; |
|
| 256 | - | } |
|
| 257 | - | ||
| 258 | - | .stat-label { |
|
| 259 | - | font-size: 10px; |
|
| 260 | 146 | color: #888; |
|
| 261 | - | margin-top: 0.15rem; |
|
| 262 | 147 | } |
|
| 263 | 148 | ||
| 264 | - | .unit-sm { |
|
| 265 | - | font-size: 10px; |
|
| 266 | - | color: #888; |
|
| 267 | - | font-weight: 400; |
|
| 268 | - | } |
|
| 269 | - | ||
| 270 | - | .digraph-table-wrap { |
|
| 271 | - | border: 1px solid #333; |
|
| 272 | - | max-height: 300px; |
|
| 273 | - | overflow-y: auto; |
|
| 274 | - | } |
|
| 275 | - | ||
| 276 | - | .digraph-table { |
|
| 277 | - | width: 100%; |
|
| 278 | - | border-collapse: collapse; |
|
| 279 | - | font-size: 12px; |
|
| 280 | - | } |
|
| 281 | - | ||
| 282 | - | .digraph-table th, |
|
| 283 | - | .digraph-table td { |
|
| 284 | - | padding: 0.4rem 0.5rem; |
|
| 285 | - | text-align: center; |
|
| 286 | - | border-bottom: 1px solid #333; |
|
| 287 | - | } |
|
| 288 | - | ||
| 289 | - | .digraph-table th { |
|
| 290 | - | position: sticky; |
|
| 291 | - | top: 0; |
|
| 292 | - | background: #0a0a0a; |
|
| 293 | - | color: #888; |
|
| 294 | - | font-weight: 400; |
|
| 295 | - | font-size: 10px; |
|
| 296 | - | text-transform: uppercase; |
|
| 297 | - | letter-spacing: 0.05em; |
|
| 298 | - | } |
|
| 299 | - | ||
| 300 | - | .digraph-table .keys-cell { |
|
| 301 | - | text-align: left; |
|
| 302 | - | font-weight: 700; |
|
| 303 | - | } |
|
| 304 | - | ||
| 305 | - | .json-section { |
|
| 306 | - | display: flex; |
|
| 307 | - | flex-direction: column; |
|
| 308 | - | gap: 0.5rem; |
|
| 309 | - | } |
|
| 310 | - | ||
| 311 | - | .json-header { |
|
| 312 | - | display: flex; |
|
| 313 | - | justify-content: space-between; |
|
| 314 | - | align-items: center; |
|
| 315 | - | font-size: 12px; |
|
| 316 | - | color: #888; |
|
| 317 | - | } |
|
| 318 | - | ||
| 319 | - | .json-actions { |
|
| 320 | - | display: flex; |
|
| 321 | - | gap: 0.5rem; |
|
| 322 | - | } |
|
| 323 | - | ||
| 324 | - | .json-actions button { |
|
| 325 | - | font-size: 12px; |
|
| 326 | - | padding: 4px 8px; |
|
| 327 | - | } |
|
| 328 | - | ||
| 329 | - | .json-output { |
|
| 330 | - | font-size: 12px; |
|
| 331 | - | line-height: 1.5; |
|
| 332 | - | color: #ffffff; |
|
| 333 | - | border: 1px solid #333; |
|
| 334 | - | padding: 1rem; |
|
| 335 | - | overflow-x: auto; |
|
| 336 | - | white-space: pre; |
|
| 337 | - | max-height: 400px; |
|
| 338 | - | overflow-y: auto; |
|
| 339 | - | } |
|
| 340 | - | ||
| 341 | - | @media (max-width: 480px) { |
|
| 342 | - | .stats-bar { |
|
| 343 | - | grid-template-columns: repeat(3, 1fr); |
|
| 344 | - | } |
|
| 345 | - | ||
| 346 | - | .stat:nth-child(3) { |
|
| 347 | - | border-right: none; |
|
| 348 | - | } |
|
| 349 | - | ||
| 350 | - | .stat:nth-child(1), |
|
| 351 | - | .stat:nth-child(2), |
|
| 352 | - | .stat:nth-child(3) { |
|
| 353 | - | border-bottom: 1px solid #333; |
|
| 354 | - | } |
|
| 149 | + | .placeholder-sub { |
|
| 150 | + | font-size: 11px; |
|
| 151 | + | color: #555; |
|
| 355 | 152 | } |
|
| 356 | 153 | </style> |
|
| 1 | - | <script lang="ts"> |
|
| 2 | - | let count: number = $state(0) |
|
| 3 | - | const increment = () => { |
|
| 4 | - | count += 1 |
|
| 5 | - | } |
|
| 6 | - | </script> |
|
| 7 | - | ||
| 8 | - | <button class="counter" onclick={increment}> |
|
| 9 | - | Count is {count} |
|
| 10 | - | </button> |
| 1 | + | <script> |
|
| 2 | + | import StatsBar from './StatsBar.svelte'; |
|
| 3 | + | ||
| 4 | + | export let digraphs = []; |
|
| 5 | + | export let typedText = ''; |
|
| 6 | + | ||
| 7 | + | let copied = false; |
|
| 8 | + | ||
| 9 | + | function copyJson() { |
|
| 10 | + | navigator.clipboard.writeText(jsonData); |
|
| 11 | + | copied = true; |
|
| 12 | + | setTimeout(() => (copied = false), 2000); |
|
| 13 | + | } |
|
| 14 | + | ||
| 15 | + | $: jsonData = JSON.stringify( |
|
| 16 | + | digraphs.map((d) => ({ |
|
| 17 | + | keys: d.keys, |
|
| 18 | + | key_1: d.key1, |
|
| 19 | + | key_2: d.key2, |
|
| 20 | + | hold_time_1_ms: d.holdTime1, |
|
| 21 | + | hold_time_2_ms: d.holdTime2, |
|
| 22 | + | press_press_ms: d.pressPress, |
|
| 23 | + | release_release_ms: d.releaseRelease, |
|
| 24 | + | press_release_ms: d.pressRelease, |
|
| 25 | + | release_press_ms: d.releasePress, |
|
| 26 | + | })), |
|
| 27 | + | null, |
|
| 28 | + | 2 |
|
| 29 | + | ); |
|
| 30 | + | </script> |
|
| 31 | + | ||
| 32 | + | <textarea |
|
| 33 | + | bind:value={typedText} |
|
| 34 | + | placeholder="start typing..." |
|
| 35 | + | rows="4" |
|
| 36 | + | ></textarea> |
|
| 37 | + | ||
| 38 | + | {#if digraphs.length > 0} |
|
| 39 | + | <StatsBar {digraphs} /> |
|
| 40 | + | ||
| 41 | + | <div class="digraph-table-wrap"> |
|
| 42 | + | <table class="digraph-table"> |
|
| 43 | + | <thead> |
|
| 44 | + | <tr> |
|
| 45 | + | <th>keys</th> |
|
| 46 | + | <th>HT1</th> |
|
| 47 | + | <th>HT2</th> |
|
| 48 | + | <th>PP</th> |
|
| 49 | + | <th>RR</th> |
|
| 50 | + | <th>PR</th> |
|
| 51 | + | <th>RP</th> |
|
| 52 | + | </tr> |
|
| 53 | + | </thead> |
|
| 54 | + | <tbody> |
|
| 55 | + | {#each digraphs as d (d.id)} |
|
| 56 | + | <tr> |
|
| 57 | + | <td class="keys-cell">{d.keys}</td> |
|
| 58 | + | <td>{d.holdTime1}</td> |
|
| 59 | + | <td>{d.holdTime2}</td> |
|
| 60 | + | <td>{d.pressPress}</td> |
|
| 61 | + | <td>{d.releaseRelease}</td> |
|
| 62 | + | <td>{d.pressRelease}</td> |
|
| 63 | + | <td>{d.releasePress}</td> |
|
| 64 | + | </tr> |
|
| 65 | + | {/each} |
|
| 66 | + | </tbody> |
|
| 67 | + | </table> |
|
| 68 | + | </div> |
|
| 69 | + | ||
| 70 | + | <div class="json-section"> |
|
| 71 | + | <div class="json-header"> |
|
| 72 | + | <span>data</span> |
|
| 73 | + | <div class="json-actions"> |
|
| 74 | + | <button on:click={copyJson}>{copied ? 'copied!' : 'copy'}</button> |
|
| 75 | + | </div> |
|
| 76 | + | </div> |
|
| 77 | + | <pre class="json-output">{jsonData}</pre> |
|
| 78 | + | </div> |
|
| 79 | + | {/if} |
|
| 80 | + | ||
| 81 | + | <style> |
|
| 82 | + | textarea { |
|
| 83 | + | width: 100%; |
|
| 84 | + | border: 1px solid #333; |
|
| 85 | + | background: transparent; |
|
| 86 | + | color: inherit; |
|
| 87 | + | font-family: inherit; |
|
| 88 | + | font-size: 14px; |
|
| 89 | + | padding: 1rem; |
|
| 90 | + | resize: vertical; |
|
| 91 | + | outline: none; |
|
| 92 | + | box-sizing: border-box; |
|
| 93 | + | } |
|
| 94 | + | ||
| 95 | + | textarea::placeholder { |
|
| 96 | + | color: #888; |
|
| 97 | + | } |
|
| 98 | + | ||
| 99 | + | textarea:focus { |
|
| 100 | + | border-color: #888; |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | .digraph-table-wrap { |
|
| 104 | + | border: 1px solid #333; |
|
| 105 | + | max-height: 300px; |
|
| 106 | + | overflow-y: auto; |
|
| 107 | + | } |
|
| 108 | + | ||
| 109 | + | .digraph-table { |
|
| 110 | + | width: 100%; |
|
| 111 | + | border-collapse: collapse; |
|
| 112 | + | font-size: 12px; |
|
| 113 | + | } |
|
| 114 | + | ||
| 115 | + | .digraph-table th, |
|
| 116 | + | .digraph-table td { |
|
| 117 | + | padding: 0.4rem 0.5rem; |
|
| 118 | + | text-align: center; |
|
| 119 | + | border-bottom: 1px solid #333; |
|
| 120 | + | } |
|
| 121 | + | ||
| 122 | + | .digraph-table th { |
|
| 123 | + | position: sticky; |
|
| 124 | + | top: 0; |
|
| 125 | + | background: #121113; |
|
| 126 | + | color: #888; |
|
| 127 | + | font-weight: 400; |
|
| 128 | + | font-size: 10px; |
|
| 129 | + | text-transform: uppercase; |
|
| 130 | + | letter-spacing: 0.05em; |
|
| 131 | + | } |
|
| 132 | + | ||
| 133 | + | .digraph-table .keys-cell { |
|
| 134 | + | text-align: left; |
|
| 135 | + | font-weight: 700; |
|
| 136 | + | } |
|
| 137 | + | ||
| 138 | + | .json-section { |
|
| 139 | + | display: flex; |
|
| 140 | + | flex-direction: column; |
|
| 141 | + | gap: 0.5rem; |
|
| 142 | + | } |
|
| 143 | + | ||
| 144 | + | .json-header { |
|
| 145 | + | display: flex; |
|
| 146 | + | justify-content: space-between; |
|
| 147 | + | align-items: center; |
|
| 148 | + | font-size: 12px; |
|
| 149 | + | color: #888; |
|
| 150 | + | } |
|
| 151 | + | ||
| 152 | + | .json-actions { |
|
| 153 | + | display: flex; |
|
| 154 | + | gap: 0.5rem; |
|
| 155 | + | } |
|
| 156 | + | ||
| 157 | + | .json-actions button { |
|
| 158 | + | font-size: 12px; |
|
| 159 | + | padding: 4px 8px; |
|
| 160 | + | } |
|
| 161 | + | ||
| 162 | + | .json-output { |
|
| 163 | + | font-size: 12px; |
|
| 164 | + | line-height: 1.5; |
|
| 165 | + | color: #ffffff; |
|
| 166 | + | border: 1px solid #333; |
|
| 167 | + | padding: 1rem; |
|
| 168 | + | overflow-x: auto; |
|
| 169 | + | white-space: pre; |
|
| 170 | + | max-height: 400px; |
|
| 171 | + | overflow-y: auto; |
|
| 172 | + | } |
|
| 173 | + | </style> |
| 1 | + | <script> |
|
| 2 | + | import { avg } from '../utils'; |
|
| 3 | + | ||
| 4 | + | export let digraphs = []; |
|
| 5 | + | ||
| 6 | + | $: avgHT1 = avg(digraphs, (d) => d.holdTime1); |
|
| 7 | + | $: avgHT2 = avg(digraphs, (d) => d.holdTime2); |
|
| 8 | + | $: avgPP = avg(digraphs, (d) => d.pressPress); |
|
| 9 | + | $: avgRR = avg(digraphs, (d) => d.releaseRelease); |
|
| 10 | + | $: avgPR = avg(digraphs, (d) => d.pressRelease); |
|
| 11 | + | $: avgRP = avg(digraphs, (d) => d.releasePress); |
|
| 12 | + | </script> |
|
| 13 | + | ||
| 14 | + | <div class="stats-bar"> |
|
| 15 | + | <div class="stat"> |
|
| 16 | + | <span class="stat-val">{avgHT1}<span class="unit-sm">ms</span></span> |
|
| 17 | + | <span class="stat-label">hold time 1</span> |
|
| 18 | + | </div> |
|
| 19 | + | <div class="stat"> |
|
| 20 | + | <span class="stat-val">{avgHT2}<span class="unit-sm">ms</span></span> |
|
| 21 | + | <span class="stat-label">hold time 2</span> |
|
| 22 | + | </div> |
|
| 23 | + | <div class="stat"> |
|
| 24 | + | <span class="stat-val">{avgPP}<span class="unit-sm">ms</span></span> |
|
| 25 | + | <span class="stat-label">press-press</span> |
|
| 26 | + | </div> |
|
| 27 | + | <div class="stat"> |
|
| 28 | + | <span class="stat-val">{avgRR}<span class="unit-sm">ms</span></span> |
|
| 29 | + | <span class="stat-label">release-release</span> |
|
| 30 | + | </div> |
|
| 31 | + | <div class="stat"> |
|
| 32 | + | <span class="stat-val">{avgPR}<span class="unit-sm">ms</span></span> |
|
| 33 | + | <span class="stat-label">press-release</span> |
|
| 34 | + | </div> |
|
| 35 | + | <div class="stat"> |
|
| 36 | + | <span class="stat-val">{avgRP}<span class="unit-sm">ms</span></span> |
|
| 37 | + | <span class="stat-label">release-press</span> |
|
| 38 | + | </div> |
|
| 39 | + | </div> |
|
| 40 | + | ||
| 41 | + | <style> |
|
| 42 | + | .stats-bar { |
|
| 43 | + | display: grid; |
|
| 44 | + | grid-template-columns: repeat(6, 1fr); |
|
| 45 | + | border: 1px solid #333; |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | .stat { |
|
| 49 | + | display: flex; |
|
| 50 | + | flex-direction: column; |
|
| 51 | + | align-items: center; |
|
| 52 | + | padding: 0.75rem 0.5rem; |
|
| 53 | + | border-right: 1px solid #333; |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | .stat:last-child { |
|
| 57 | + | border-right: none; |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | .stat-val { |
|
| 61 | + | font-size: 14px; |
|
| 62 | + | font-weight: 700; |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | .stat-label { |
|
| 66 | + | font-size: 10px; |
|
| 67 | + | color: #888; |
|
| 68 | + | margin-top: 0.15rem; |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | .unit-sm { |
|
| 72 | + | font-size: 10px; |
|
| 73 | + | color: #888; |
|
| 74 | + | font-weight: 400; |
|
| 75 | + | } |
|
| 76 | + | ||
| 77 | + | @media (max-width: 480px) { |
|
| 78 | + | .stats-bar { |
|
| 79 | + | grid-template-columns: repeat(3, 1fr); |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | .stat:nth-child(3) { |
|
| 83 | + | border-right: none; |
|
| 84 | + | } |
|
| 85 | + | ||
| 86 | + | .stat:nth-child(1), |
|
| 87 | + | .stat:nth-child(2), |
|
| 88 | + | .stat:nth-child(3) { |
|
| 89 | + | border-bottom: 1px solid #333; |
|
| 90 | + | } |
|
| 91 | + | } |
|
| 92 | + | </style> |
| 1 | + | <script> |
|
| 2 | + | export let activeTab = 'capture'; |
|
| 3 | + | ||
| 4 | + | const tabs = [ |
|
| 5 | + | { id: 'capture', label: 'capture' }, |
|
| 6 | + | { id: 'profile', label: 'profile' }, |
|
| 7 | + | { id: 'compare', label: 'compare' }, |
|
| 8 | + | { id: 'humanness', label: 'human-ness' }, |
|
| 9 | + | ]; |
|
| 10 | + | </script> |
|
| 11 | + | ||
| 12 | + | <nav class="tab-bar"> |
|
| 13 | + | {#each tabs as tab} |
|
| 14 | + | <button |
|
| 15 | + | class="tab" |
|
| 16 | + | class:active={activeTab === tab.id} |
|
| 17 | + | on:click={() => (activeTab = tab.id)} |
|
| 18 | + | > |
|
| 19 | + | {tab.label} |
|
| 20 | + | </button> |
|
| 21 | + | {/each} |
|
| 22 | + | </nav> |
|
| 23 | + | ||
| 24 | + | <style> |
|
| 25 | + | .tab-bar { |
|
| 26 | + | display: flex; |
|
| 27 | + | gap: 0; |
|
| 28 | + | border: 1px solid #333; |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | .tab { |
|
| 32 | + | flex: 1; |
|
| 33 | + | padding: 0.5rem 1rem; |
|
| 34 | + | font-size: 12px; |
|
| 35 | + | letter-spacing: 0.05em; |
|
| 36 | + | border: none; |
|
| 37 | + | border-right: 1px solid #333; |
|
| 38 | + | background: transparent; |
|
| 39 | + | color: #888; |
|
| 40 | + | cursor: pointer; |
|
| 41 | + | } |
|
| 42 | + | ||
| 43 | + | .tab:last-child { |
|
| 44 | + | border-right: none; |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | .tab:hover { |
|
| 48 | + | color: #fff; |
|
| 49 | + | opacity: 1; |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | .tab.active { |
|
| 53 | + | color: #fff; |
|
| 54 | + | background: #1a1a1a; |
|
| 55 | + | } |
|
| 56 | + | </style> |
| 1 | + | export interface RawDigraph { |
|
| 2 | + | id: number; |
|
| 3 | + | keys: string; |
|
| 4 | + | key1: string; |
|
| 5 | + | key2: string; |
|
| 6 | + | holdTime1: number; |
|
| 7 | + | holdTime2: number; |
|
| 8 | + | pressPress: number; |
|
| 9 | + | releaseRelease: number; |
|
| 10 | + | pressRelease: number; |
|
| 11 | + | releasePress: number; |
|
| 12 | + | } |
|
| 13 | + | ||
| 14 | + | export interface MetricStats { |
|
| 15 | + | mean: number; |
|
| 16 | + | std: number; |
|
| 17 | + | min: number; |
|
| 18 | + | max: number; |
|
| 19 | + | count: number; |
|
| 20 | + | } |
|
| 21 | + | ||
| 22 | + | export interface DigraphAggregation { |
|
| 23 | + | normalizedKeys: string; |
|
| 24 | + | count: number; |
|
| 25 | + | holdTime1: MetricStats; |
|
| 26 | + | holdTime2: MetricStats; |
|
| 27 | + | pressPress: MetricStats; |
|
| 28 | + | releaseRelease: MetricStats; |
|
| 29 | + | pressRelease: MetricStats; |
|
| 30 | + | releasePress: MetricStats; |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | export interface SessionMetadata { |
|
| 34 | + | totalKeystrokes: number; |
|
| 35 | + | backspaceCount: number; |
|
| 36 | + | pauseCount: number; |
|
| 37 | + | avgTypingSpeed: number; |
|
| 38 | + | sessionDurationMs: number; |
|
| 39 | + | pasteCount: number; |
|
| 40 | + | pastedCharCount: number; |
|
| 41 | + | } |
|
| 42 | + | ||
| 43 | + | export interface Profile { |
|
| 44 | + | id: string; |
|
| 45 | + | name: string; |
|
| 46 | + | createdAt: string; |
|
| 47 | + | digraphCount: number; |
|
| 48 | + | aggregations: DigraphAggregation[]; |
|
| 49 | + | metadata: SessionMetadata; |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | export interface ComparisonResult { |
|
| 53 | + | overallDistance: number; |
|
| 54 | + | similarityPercent: number; |
|
| 55 | + | confidence: 'high' | 'medium' | 'low' | 'insufficient'; |
|
| 56 | + | sharedCount: number; |
|
| 57 | + | perDigraph: PerDigraphComparison[]; |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | export interface PerDigraphComparison { |
|
| 61 | + | normalizedKeys: string; |
|
| 62 | + | distance: number; |
|
| 63 | + | matchPercent: number; |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | export interface HumannessResult { |
|
| 67 | + | score: number; |
|
| 68 | + | verdict: 'likely human' | 'uncertain' | 'likely bot'; |
|
| 69 | + | subScores: { |
|
| 70 | + | timingVariance: number; |
|
| 71 | + | correctionRate: number; |
|
| 72 | + | pauseDistribution: number; |
|
| 73 | + | distributionShape: number; |
|
| 74 | + | flightTimeNegativity: number; |
|
| 75 | + | burstPatterns: number; |
|
| 76 | + | }; |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | export type TabId = 'capture' | 'profile' | 'compare' | 'humanness'; |
| 1 | + | export function round(v: number): number { |
|
| 2 | + | return Math.round(v * 10) / 10; |
|
| 3 | + | } |
|
| 4 | + | ||
| 5 | + | export function avg(arr: any[], fn: (d: any) => number): string { |
|
| 6 | + | if (arr.length === 0) return '\u2014'; |
|
| 7 | + | return (arr.reduce((s, d) => s + fn(d), 0) / arr.length).toFixed(1); |
|
| 8 | + | } |
|
| 9 | + | ||
| 10 | + | export function std(values: number[]): number { |
|
| 11 | + | if (values.length < 2) return 0; |
|
| 12 | + | const m = values.reduce((s, v) => s + v, 0) / values.length; |
|
| 13 | + | const variance = values.reduce((s, v) => s + (v - m) ** 2, 0) / (values.length - 1); |
|
| 14 | + | return Math.sqrt(variance); |
|
| 15 | + | } |