chore: refactored structure 46739c2f
Steve · 2026-03-19 19:57 8 file(s) · +630 −251
PLAN.md (added) +177 −0
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.
src/App.svelte +38 −241
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>
src/lib/Counter.svelte (deleted) +0 −10
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>
src/lib/components/CapturePanel.svelte (added) +173 −0
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>
src/lib/components/StatsBar.svelte (added) +92 −0
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>
src/lib/components/TabBar.svelte (added) +56 −0
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>
src/lib/types.ts (added) +79 −0
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';
src/lib/utils.ts (added) +15 −0
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 +
}