granola-toolkit 0.20.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +2 -1
  2. package/dist/cli.js +550 -384
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -208,7 +208,8 @@ The initial browser client includes:
208
208
  - a searchable meeting list
209
209
  - sort and updated-date filters
210
210
  - quick open by meeting id or title
211
- - a meeting detail view with notes and transcript panes
211
+ - a focused meeting workspace with notes, transcript, metadata, and raw tabs
212
+ - keyboard-first workspace switching with `1`-`4`, `[` and `]`
212
213
  - app-state status from the shared core
213
214
  - note and transcript export actions backed by the same local API
214
215
  - stronger empty and error states for list/detail failures
package/dist/cli.js CHANGED
@@ -2132,382 +2132,8 @@ function resolveNoteFormat(value) {
2132
2132
  }
2133
2133
  }
2134
2134
  //#endregion
2135
- //#region src/server/web.ts
2136
- function renderGranolaWebPage() {
2137
- return `<!doctype html>
2138
- <html lang="en">
2139
- <head>
2140
- <meta charset="utf-8" />
2141
- <meta name="viewport" content="width=device-width, initial-scale=1" />
2142
- <title>Granola Toolkit</title>
2143
- <style>
2144
- :root {
2145
- --bg: #f2ede2;
2146
- --panel: rgba(255, 252, 247, 0.86);
2147
- --panel-strong: #fffaf2;
2148
- --line: rgba(36, 39, 44, 0.12);
2149
- --ink: #1d242c;
2150
- --muted: #5d6b77;
2151
- --accent: #0d6a6d;
2152
- --accent-soft: rgba(13, 106, 109, 0.12);
2153
- --warm: #a34f2f;
2154
- --ok: #246b4f;
2155
- --error: #9d2c2c;
2156
- --shadow: 0 24px 80px rgba(40, 32, 16, 0.12);
2157
- --radius: 24px;
2158
- --mono: "SF Mono", "IBM Plex Mono", "Cascadia Code", monospace;
2159
- --serif: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif;
2160
- --sans: "Avenir Next", "Segoe UI", sans-serif;
2161
- }
2162
-
2163
- * { box-sizing: border-box; }
2164
-
2165
- body {
2166
- margin: 0;
2167
- min-height: 100vh;
2168
- font-family: var(--sans);
2169
- color: var(--ink);
2170
- background:
2171
- radial-gradient(circle at top left, rgba(163, 79, 47, 0.18), transparent 32%),
2172
- radial-gradient(circle at right 12%, rgba(13, 106, 109, 0.16), transparent 28%),
2173
- linear-gradient(180deg, #f8f2e8 0%, var(--bg) 100%);
2174
- }
2175
-
2176
- .shell {
2177
- display: grid;
2178
- grid-template-columns: 320px minmax(0, 1fr);
2179
- gap: 18px;
2180
- min-height: 100vh;
2181
- padding: 24px;
2182
- }
2183
-
2184
- .pane {
2185
- background: var(--panel);
2186
- backdrop-filter: blur(18px);
2187
- border: 1px solid var(--line);
2188
- border-radius: var(--radius);
2189
- box-shadow: var(--shadow);
2190
- }
2191
-
2192
- .sidebar {
2193
- display: grid;
2194
- grid-template-rows: auto auto 1fr;
2195
- overflow: hidden;
2196
- }
2197
-
2198
- .hero, .toolbar, .detail-head {
2199
- padding: 22px 24px;
2200
- border-bottom: 1px solid var(--line);
2201
- }
2202
-
2203
- .hero h1 {
2204
- margin: 0;
2205
- font-family: var(--serif);
2206
- font-size: clamp(2rem, 3vw, 2.8rem);
2207
- font-weight: 600;
2208
- letter-spacing: -0.04em;
2209
- }
2210
-
2211
- .hero p, .toolbar p {
2212
- margin: 8px 0 0;
2213
- color: var(--muted);
2214
- line-height: 1.5;
2215
- }
2216
-
2217
- .search,
2218
- .select,
2219
- .field-input {
2220
- width: 100%;
2221
- margin-top: 16px;
2222
- padding: 12px 14px;
2223
- border: 1px solid var(--line);
2224
- border-radius: 999px;
2225
- background: rgba(255, 255, 255, 0.7);
2226
- color: var(--ink);
2227
- font: inherit;
2228
- }
2229
-
2230
- .field-row {
2231
- display: grid;
2232
- gap: 10px;
2233
- margin-top: 12px;
2234
- }
2235
-
2236
- .field-row--inline {
2237
- grid-template-columns: repeat(2, minmax(0, 1fr));
2238
- }
2239
-
2240
- .field-label {
2241
- display: block;
2242
- margin-bottom: 6px;
2243
- color: var(--muted);
2244
- font-size: 0.78rem;
2245
- font-weight: 700;
2246
- letter-spacing: 0.08em;
2247
- text-transform: uppercase;
2248
- }
2249
-
2250
- .meeting-list {
2251
- padding: 14px;
2252
- overflow: auto;
2253
- }
2254
-
2255
- .meeting-row {
2256
- width: 100%;
2257
- display: grid;
2258
- gap: 4px;
2259
- text-align: left;
2260
- margin: 0 0 10px;
2261
- padding: 14px 16px;
2262
- border: 1px solid transparent;
2263
- border-radius: 18px;
2264
- background: rgba(255, 255, 255, 0.72);
2265
- color: inherit;
2266
- cursor: pointer;
2267
- transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
2268
- }
2269
-
2270
- .meeting-row:hover,
2271
- .meeting-row[data-selected="true"] {
2272
- transform: translateY(-1px);
2273
- border-color: rgba(13, 106, 109, 0.25);
2274
- background: var(--panel-strong);
2275
- }
2276
-
2277
- .meeting-row__title {
2278
- font-weight: 600;
2279
- }
2280
-
2281
- .meeting-row__meta {
2282
- color: var(--muted);
2283
- font-size: 0.92rem;
2284
- }
2285
-
2286
- .meeting-empty {
2287
- padding: 18px;
2288
- color: var(--muted);
2289
- }
2290
-
2291
- .meeting-empty--error {
2292
- color: var(--error);
2293
- }
2294
-
2295
- .detail {
2296
- display: grid;
2297
- grid-template-rows: auto auto 1fr;
2298
- min-width: 0;
2299
- }
2300
-
2301
- .detail-head {
2302
- display: flex;
2303
- align-items: center;
2304
- justify-content: space-between;
2305
- gap: 18px;
2306
- }
2307
-
2308
- .detail-head h2 {
2309
- margin: 0;
2310
- font-family: var(--serif);
2311
- font-size: clamp(1.8rem, 2.4vw, 2.4rem);
2312
- font-weight: 600;
2313
- }
2314
-
2315
- .state-badge {
2316
- padding: 10px 14px;
2317
- border-radius: 999px;
2318
- background: var(--accent-soft);
2319
- color: var(--accent);
2320
- font-size: 0.92rem;
2321
- font-weight: 700;
2322
- }
2323
-
2324
- .state-badge[data-tone="busy"] { color: var(--warm); background: rgba(163, 79, 47, 0.12); }
2325
- .state-badge[data-tone="error"] { color: var(--error); background: rgba(157, 44, 44, 0.12); }
2326
- .state-badge[data-tone="ok"] { color: var(--ok); background: rgba(36, 107, 79, 0.12); }
2327
-
2328
- .toolbar {
2329
- display: flex;
2330
- flex-wrap: wrap;
2331
- align-items: center;
2332
- justify-content: space-between;
2333
- gap: 14px;
2334
- }
2335
-
2336
- .toolbar-actions {
2337
- display: flex;
2338
- flex-wrap: wrap;
2339
- gap: 10px;
2340
- }
2341
-
2342
- .toolbar-form {
2343
- display: grid;
2344
- grid-template-columns: minmax(0, 1fr) auto;
2345
- gap: 10px;
2346
- width: min(440px, 100%);
2347
- }
2348
-
2349
- .button {
2350
- border: 0;
2351
- border-radius: 999px;
2352
- padding: 12px 16px;
2353
- font: inherit;
2354
- font-weight: 700;
2355
- cursor: pointer;
2356
- }
2357
-
2358
- .button--primary {
2359
- background: var(--ink);
2360
- color: white;
2361
- }
2362
-
2363
- .button--secondary {
2364
- background: rgba(255, 255, 255, 0.72);
2365
- color: var(--ink);
2366
- border: 1px solid var(--line);
2367
- }
2368
-
2369
- .status-grid {
2370
- display: grid;
2371
- grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
2372
- gap: 14px;
2373
- }
2374
-
2375
- .status-label {
2376
- display: block;
2377
- margin-bottom: 6px;
2378
- color: var(--muted);
2379
- font-size: 0.78rem;
2380
- letter-spacing: 0.08em;
2381
- text-transform: uppercase;
2382
- }
2383
-
2384
- .detail-meta {
2385
- display: flex;
2386
- flex-wrap: wrap;
2387
- gap: 10px;
2388
- padding: 0 24px 18px;
2389
- }
2390
-
2391
- .detail-chip {
2392
- padding: 10px 12px;
2393
- border-radius: 999px;
2394
- background: rgba(255, 255, 255, 0.72);
2395
- border: 1px solid var(--line);
2396
- color: var(--muted);
2397
- font-size: 0.88rem;
2398
- }
2399
-
2400
- .detail-body {
2401
- padding: 0 24px 24px;
2402
- overflow: auto;
2403
- }
2404
-
2405
- .detail-section {
2406
- margin-bottom: 20px;
2407
- padding: 20px;
2408
- background: rgba(255, 255, 255, 0.72);
2409
- border: 1px solid var(--line);
2410
- border-radius: 20px;
2411
- }
2412
-
2413
- .detail-section h2 {
2414
- margin: 0 0 14px;
2415
- font-size: 1rem;
2416
- letter-spacing: 0.08em;
2417
- text-transform: uppercase;
2418
- }
2419
-
2420
- .detail-pre {
2421
- margin: 0;
2422
- white-space: pre-wrap;
2423
- word-break: break-word;
2424
- font-family: var(--mono);
2425
- line-height: 1.55;
2426
- }
2427
-
2428
- .empty {
2429
- margin: 24px;
2430
- padding: 24px;
2431
- border-radius: 20px;
2432
- background: rgba(255, 255, 255, 0.72);
2433
- border: 1px dashed rgba(36, 39, 44, 0.2);
2434
- color: var(--muted);
2435
- }
2436
-
2437
- @media (max-width: 900px) {
2438
- .shell {
2439
- grid-template-columns: 1fr;
2440
- }
2441
-
2442
- .field-row--inline,
2443
- .toolbar-form {
2444
- grid-template-columns: 1fr;
2445
- }
2446
- }
2447
- </style>
2448
- </head>
2449
- <body>
2450
- <div class="shell">
2451
- <aside class="pane sidebar">
2452
- <section class="hero">
2453
- <h1>Granola Toolkit</h1>
2454
- <p>Browser workspace for meetings, notes, transcripts, and export flows on top of one local server instance.</p>
2455
- <input class="search" data-search placeholder="Search meetings, ids, or tags" />
2456
- <div class="field-row field-row--inline">
2457
- <label>
2458
- <span class="field-label">Sort</span>
2459
- <select class="select" data-sort>
2460
- <option value="updated-desc">Newest first</option>
2461
- <option value="updated-asc">Oldest first</option>
2462
- <option value="title-asc">Title A-Z</option>
2463
- <option value="title-desc">Title Z-A</option>
2464
- </select>
2465
- </label>
2466
- <label>
2467
- <span class="field-label">Updated From</span>
2468
- <input class="field-input" data-updated-from type="date" />
2469
- </label>
2470
- </div>
2471
- <label class="field-row">
2472
- <span class="field-label">Updated To</span>
2473
- <input class="field-input" data-updated-to type="date" />
2474
- </label>
2475
- </section>
2476
- <section class="toolbar">
2477
- <div>
2478
- <p>Meetings are loaded from the shared server state so this view can later coexist with the terminal UI.</p>
2479
- </div>
2480
- <div class="toolbar-form">
2481
- <input class="field-input" data-quick-open placeholder="Quick open by id or title" />
2482
- <button class="button button--secondary" data-quick-open-button>Open</button>
2483
- </div>
2484
- </section>
2485
- <section class="meeting-list" data-meeting-list></section>
2486
- </aside>
2487
- <main class="pane detail">
2488
- <section class="detail-head">
2489
- <div>
2490
- <h2>Meeting Workspace</h2>
2491
- <div data-app-state></div>
2492
- </div>
2493
- <div class="state-badge" data-state-badge data-tone="idle">Connecting…</div>
2494
- </section>
2495
- <section class="toolbar">
2496
- <div class="toolbar-actions">
2497
- <button class="button button--primary" data-refresh>Refresh</button>
2498
- <button class="button button--secondary" data-export-notes>Export Notes</button>
2499
- <button class="button button--secondary" data-export-transcripts>Export Transcripts</button>
2500
- </div>
2501
- <p>Initial beta web client. It speaks to the same local API that future TUI and attach flows will use.</p>
2502
- </section>
2503
- <div class="detail-meta" data-detail-meta></div>
2504
- <div class="detail-body" data-detail-body>
2505
- <div class="empty" data-empty>Select a meeting to inspect its notes and transcript.</div>
2506
- </div>
2507
- </main>
2508
- </div>
2509
- <script type="module">
2510
- ${String.raw`
2135
+ //#region src/web/client-script.ts
2136
+ const granolaWebClientScript = String.raw`
2511
2137
  const state = {
2512
2138
  appState: null,
2513
2139
  detailError: "",
@@ -2516,10 +2142,12 @@ const state = {
2516
2142
  quickOpen: "",
2517
2143
  search: "",
2518
2144
  selectedMeeting: null,
2145
+ selectedMeetingBundle: null,
2519
2146
  selectedMeetingId: null,
2520
2147
  sort: "updated-desc",
2521
2148
  updatedFrom: "",
2522
2149
  updatedTo: "",
2150
+ workspaceTab: "notes",
2523
2151
  };
2524
2152
 
2525
2153
  const els = {
@@ -2538,6 +2166,7 @@ const els = {
2538
2166
  transcriptButton: document.querySelector("[data-export-transcripts]"),
2539
2167
  updatedFrom: document.querySelector("[data-updated-from]"),
2540
2168
  updatedTo: document.querySelector("[data-updated-to]"),
2169
+ workspaceTabs: document.querySelectorAll("[data-workspace-tab]"),
2541
2170
  };
2542
2171
 
2543
2172
  function escapeHtml(value) {
@@ -2579,6 +2208,12 @@ function currentFilterSummary() {
2579
2208
  return parts.join(", ");
2580
2209
  }
2581
2210
 
2211
+ function renderWorkspaceTabs() {
2212
+ for (const button of els.workspaceTabs) {
2213
+ button.dataset.selected = button.dataset.workspaceTab === state.workspaceTab ? "true" : "false";
2214
+ }
2215
+ }
2216
+
2582
2217
  function renderAppState() {
2583
2218
  if (!state.appState) {
2584
2219
  els.appState.innerHTML = "<p>Waiting for server state…</p>";
@@ -2615,6 +2250,7 @@ function renderMeetingList() {
2615
2250
  if (state.meetings.length === 0) {
2616
2251
  state.selectedMeetingId = null;
2617
2252
  state.selectedMeeting = null;
2253
+ state.selectedMeetingBundle = null;
2618
2254
  const filterSummary = currentFilterSummary();
2619
2255
  const message = filterSummary
2620
2256
  ? "No meetings match " + filterSummary + "."
@@ -2645,6 +2281,8 @@ function renderMeetingList() {
2645
2281
  }
2646
2282
 
2647
2283
  function renderMeetingDetail() {
2284
+ renderWorkspaceTabs();
2285
+
2648
2286
  if (state.detailError) {
2649
2287
  els.empty.hidden = false;
2650
2288
  els.empty.textContent = state.detailError;
@@ -2669,15 +2307,46 @@ function renderMeetingDetail() {
2669
2307
  '<div class="detail-chip">Transcript: ' + escapeHtml(String(record.meeting.transcriptSegmentCount)) + " segments</div>",
2670
2308
  ].join("");
2671
2309
 
2310
+ const bundle = state.selectedMeetingBundle;
2311
+ const metadataLines = [
2312
+ "Title: " + (record.meeting.title || record.meeting.id),
2313
+ "Created: " + record.meeting.createdAt,
2314
+ "Updated: " + record.meeting.updatedAt,
2315
+ "Tags: " + (record.meeting.tags.length ? record.meeting.tags.join(", ") : "none"),
2316
+ "Transcript loaded: " + (record.meeting.transcriptLoaded ? "yes" : "no"),
2317
+ ].join("\n");
2318
+
2319
+ let mainTitle = "Notes";
2320
+ let mainBody = record.noteMarkdown || "";
2321
+
2322
+ switch (state.workspaceTab) {
2323
+ case "transcript":
2324
+ mainTitle = "Transcript";
2325
+ mainBody = record.transcriptText || "(Transcript unavailable)";
2326
+ break;
2327
+ case "metadata":
2328
+ mainTitle = "Metadata";
2329
+ mainBody = metadataLines;
2330
+ break;
2331
+ case "raw":
2332
+ mainTitle = "Raw Bundle";
2333
+ mainBody = JSON.stringify(bundle || record, null, 2);
2334
+ break;
2335
+ default:
2336
+ break;
2337
+ }
2338
+
2672
2339
  els.detailBody.innerHTML = [
2673
- '<section class="detail-section">',
2674
- "<h2>Notes</h2>",
2675
- '<pre class="detail-pre">' + escapeHtml(record.noteMarkdown || "") + "</pre>",
2676
- "</section>",
2677
- '<section class="detail-section">',
2678
- "<h2>Transcript</h2>",
2679
- '<pre class="detail-pre">' + escapeHtml(record.transcriptText || "(Transcript unavailable)") + "</pre>",
2340
+ '<div class="workspace-grid">',
2341
+ '<aside class="detail-section workspace-sidebar">',
2342
+ "<h2>Meeting Metadata</h2>",
2343
+ '<pre class="detail-pre">' + escapeHtml(metadataLines) + "</pre>",
2344
+ "</aside>",
2345
+ '<section class="detail-section workspace-main">',
2346
+ "<h2>" + escapeHtml(mainTitle) + "</h2>",
2347
+ '<pre class="detail-pre">' + escapeHtml(mainBody) + "</pre>",
2680
2348
  "</section>",
2349
+ "</div>",
2681
2350
  ].join("");
2682
2351
  }
2683
2352
 
@@ -2733,6 +2402,7 @@ async function loadMeetings(options = {}) {
2733
2402
  } catch (error) {
2734
2403
  state.listError = error instanceof Error ? error.message : String(error);
2735
2404
  state.selectedMeeting = null;
2405
+ state.selectedMeetingBundle = null;
2736
2406
  state.detailError = state.listError;
2737
2407
  renderMeetingList();
2738
2408
  renderMeetingDetail();
@@ -2746,10 +2416,12 @@ async function loadMeeting(id) {
2746
2416
  try {
2747
2417
  state.detailError = "";
2748
2418
  const payload = await fetchJson("/meetings/" + encodeURIComponent(id));
2419
+ state.selectedMeetingBundle = payload;
2749
2420
  state.selectedMeeting = payload.meeting || null;
2750
2421
  renderMeetingDetail();
2751
2422
  } catch (error) {
2752
2423
  state.selectedMeeting = null;
2424
+ state.selectedMeetingBundle = null;
2753
2425
  state.detailError = error instanceof Error ? error.message : String(error);
2754
2426
  renderMeetingDetail();
2755
2427
  }
@@ -2891,6 +2563,59 @@ els.quickOpenButton.addEventListener("click", () => {
2891
2563
  void quickOpenMeeting();
2892
2564
  });
2893
2565
 
2566
+ els.workspaceTabs.forEach((button) => {
2567
+ button.addEventListener("click", () => {
2568
+ state.workspaceTab = button.dataset.workspaceTab || "notes";
2569
+ renderMeetingDetail();
2570
+ });
2571
+ });
2572
+
2573
+ document.addEventListener("keydown", (event) => {
2574
+ if (
2575
+ event.target instanceof HTMLInputElement ||
2576
+ event.target instanceof HTMLSelectElement ||
2577
+ event.target instanceof HTMLTextAreaElement
2578
+ ) {
2579
+ return;
2580
+ }
2581
+
2582
+ const tabs = ["notes", "transcript", "metadata", "raw"];
2583
+ if (event.key === "1") {
2584
+ state.workspaceTab = "notes";
2585
+ renderMeetingDetail();
2586
+ return;
2587
+ }
2588
+
2589
+ if (event.key === "2") {
2590
+ state.workspaceTab = "transcript";
2591
+ renderMeetingDetail();
2592
+ return;
2593
+ }
2594
+
2595
+ if (event.key === "3") {
2596
+ state.workspaceTab = "metadata";
2597
+ renderMeetingDetail();
2598
+ return;
2599
+ }
2600
+
2601
+ if (event.key === "4") {
2602
+ state.workspaceTab = "raw";
2603
+ renderMeetingDetail();
2604
+ return;
2605
+ }
2606
+
2607
+ const currentIndex = tabs.indexOf(state.workspaceTab);
2608
+ if (event.key === "]") {
2609
+ state.workspaceTab = tabs[(currentIndex + 1) % tabs.length];
2610
+ renderMeetingDetail();
2611
+ }
2612
+
2613
+ if (event.key === "[") {
2614
+ state.workspaceTab = tabs[(currentIndex + tabs.length - 1) % tabs.length];
2615
+ renderMeetingDetail();
2616
+ }
2617
+ });
2618
+
2894
2619
  const events = new EventSource("/events");
2895
2620
  events.addEventListener("state.updated", (event) => {
2896
2621
  const payload = JSON.parse(event.data);
@@ -2908,7 +2633,448 @@ void refreshAll().catch((error) => {
2908
2633
  els.empty.hidden = false;
2909
2634
  els.empty.textContent = error.message;
2910
2635
  });
2911
- `}
2636
+ `;
2637
+ //#endregion
2638
+ //#region src/web/markup.ts
2639
+ const granolaWebMarkup = String.raw`
2640
+ <div class="shell">
2641
+ <aside class="pane sidebar">
2642
+ <section class="hero">
2643
+ <h1>Granola Toolkit</h1>
2644
+ <p>Browser workspace for meetings, notes, transcripts, and export flows on top of one local server instance.</p>
2645
+ <input class="search" data-search placeholder="Search meetings, ids, or tags" />
2646
+ <div class="field-row field-row--inline">
2647
+ <label>
2648
+ <span class="field-label">Sort</span>
2649
+ <select class="select" data-sort>
2650
+ <option value="updated-desc">Newest first</option>
2651
+ <option value="updated-asc">Oldest first</option>
2652
+ <option value="title-asc">Title A-Z</option>
2653
+ <option value="title-desc">Title Z-A</option>
2654
+ </select>
2655
+ </label>
2656
+ <label>
2657
+ <span class="field-label">Updated From</span>
2658
+ <input class="field-input" data-updated-from type="date" />
2659
+ </label>
2660
+ </div>
2661
+ <label class="field-row">
2662
+ <span class="field-label">Updated To</span>
2663
+ <input class="field-input" data-updated-to type="date" />
2664
+ </label>
2665
+ </section>
2666
+ <section class="toolbar">
2667
+ <div>
2668
+ <p>Meetings are loaded from the shared server state so this view can later coexist with the terminal UI.</p>
2669
+ </div>
2670
+ <div class="toolbar-form">
2671
+ <input class="field-input" data-quick-open placeholder="Quick open by id or title" />
2672
+ <button class="button button--secondary" data-quick-open-button>Open</button>
2673
+ </div>
2674
+ </section>
2675
+ <section class="meeting-list" data-meeting-list></section>
2676
+ </aside>
2677
+ <main class="pane detail">
2678
+ <section class="detail-head">
2679
+ <div>
2680
+ <h2>Meeting Workspace</h2>
2681
+ <div data-app-state></div>
2682
+ </div>
2683
+ <div class="state-badge" data-state-badge data-tone="idle">Connecting…</div>
2684
+ </section>
2685
+ <section class="toolbar">
2686
+ <div class="toolbar-actions">
2687
+ <button class="button button--primary" data-refresh>Refresh</button>
2688
+ <button class="button button--secondary" data-export-notes>Export Notes</button>
2689
+ <button class="button button--secondary" data-export-transcripts>Export Transcripts</button>
2690
+ </div>
2691
+ <p>Initial beta web client. It speaks to the same local API that future TUI and attach flows will use.</p>
2692
+ </section>
2693
+ <nav class="workspace-tabs">
2694
+ <button class="workspace-tab" data-workspace-tab="notes">Notes</button>
2695
+ <button class="workspace-tab" data-workspace-tab="transcript">Transcript</button>
2696
+ <button class="workspace-tab" data-workspace-tab="metadata">Metadata</button>
2697
+ <button class="workspace-tab" data-workspace-tab="raw">Raw</button>
2698
+ <span class="workspace-hint">1-4 switch tabs, [ and ] cycle</span>
2699
+ </nav>
2700
+ <div class="detail-meta" data-detail-meta></div>
2701
+ <div class="detail-body" data-detail-body>
2702
+ <div class="empty" data-empty>Select a meeting to inspect its notes and transcript.</div>
2703
+ </div>
2704
+ </main>
2705
+ </div>
2706
+ `;
2707
+ //#endregion
2708
+ //#region src/web/styles.ts
2709
+ const granolaWebStyles = String.raw`
2710
+ :root {
2711
+ --bg: #f2ede2;
2712
+ --panel: rgba(255, 252, 247, 0.86);
2713
+ --panel-strong: #fffaf2;
2714
+ --line: rgba(36, 39, 44, 0.12);
2715
+ --ink: #1d242c;
2716
+ --muted: #5d6b77;
2717
+ --accent: #0d6a6d;
2718
+ --accent-soft: rgba(13, 106, 109, 0.12);
2719
+ --warm: #a34f2f;
2720
+ --ok: #246b4f;
2721
+ --error: #9d2c2c;
2722
+ --shadow: 0 24px 80px rgba(40, 32, 16, 0.12);
2723
+ --radius: 24px;
2724
+ --mono: "SF Mono", "IBM Plex Mono", "Cascadia Code", monospace;
2725
+ --serif: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif;
2726
+ --sans: "Avenir Next", "Segoe UI", sans-serif;
2727
+ }
2728
+
2729
+ * { box-sizing: border-box; }
2730
+
2731
+ body {
2732
+ margin: 0;
2733
+ min-height: 100vh;
2734
+ font-family: var(--sans);
2735
+ color: var(--ink);
2736
+ background:
2737
+ radial-gradient(circle at top left, rgba(163, 79, 47, 0.18), transparent 32%),
2738
+ radial-gradient(circle at right 12%, rgba(13, 106, 109, 0.16), transparent 28%),
2739
+ linear-gradient(180deg, #f8f2e8 0%, var(--bg) 100%);
2740
+ }
2741
+
2742
+ .shell {
2743
+ display: grid;
2744
+ grid-template-columns: 320px minmax(0, 1fr);
2745
+ gap: 18px;
2746
+ min-height: 100vh;
2747
+ padding: 24px;
2748
+ }
2749
+
2750
+ .pane {
2751
+ background: var(--panel);
2752
+ backdrop-filter: blur(18px);
2753
+ border: 1px solid var(--line);
2754
+ border-radius: var(--radius);
2755
+ box-shadow: var(--shadow);
2756
+ }
2757
+
2758
+ .sidebar {
2759
+ display: grid;
2760
+ grid-template-rows: auto auto 1fr;
2761
+ overflow: hidden;
2762
+ }
2763
+
2764
+ .hero, .toolbar, .detail-head {
2765
+ padding: 22px 24px;
2766
+ border-bottom: 1px solid var(--line);
2767
+ }
2768
+
2769
+ .hero h1 {
2770
+ margin: 0;
2771
+ font-family: var(--serif);
2772
+ font-size: clamp(2rem, 3vw, 2.8rem);
2773
+ font-weight: 600;
2774
+ letter-spacing: -0.04em;
2775
+ }
2776
+
2777
+ .hero p, .toolbar p {
2778
+ margin: 8px 0 0;
2779
+ color: var(--muted);
2780
+ line-height: 1.5;
2781
+ }
2782
+
2783
+ .search,
2784
+ .select,
2785
+ .field-input {
2786
+ width: 100%;
2787
+ margin-top: 16px;
2788
+ padding: 12px 14px;
2789
+ border: 1px solid var(--line);
2790
+ border-radius: 999px;
2791
+ background: rgba(255, 255, 255, 0.7);
2792
+ color: var(--ink);
2793
+ font: inherit;
2794
+ }
2795
+
2796
+ .field-row {
2797
+ display: grid;
2798
+ gap: 10px;
2799
+ margin-top: 12px;
2800
+ }
2801
+
2802
+ .field-row--inline {
2803
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2804
+ }
2805
+
2806
+ .field-label {
2807
+ display: block;
2808
+ margin-bottom: 6px;
2809
+ color: var(--muted);
2810
+ font-size: 0.78rem;
2811
+ font-weight: 700;
2812
+ letter-spacing: 0.08em;
2813
+ text-transform: uppercase;
2814
+ }
2815
+
2816
+ .meeting-list {
2817
+ padding: 14px;
2818
+ overflow: auto;
2819
+ }
2820
+
2821
+ .meeting-row {
2822
+ width: 100%;
2823
+ display: grid;
2824
+ gap: 4px;
2825
+ text-align: left;
2826
+ margin: 0 0 10px;
2827
+ padding: 14px 16px;
2828
+ border: 1px solid transparent;
2829
+ border-radius: 18px;
2830
+ background: rgba(255, 255, 255, 0.72);
2831
+ color: inherit;
2832
+ cursor: pointer;
2833
+ transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
2834
+ }
2835
+
2836
+ .meeting-row:hover,
2837
+ .meeting-row[data-selected="true"] {
2838
+ transform: translateY(-1px);
2839
+ border-color: rgba(13, 106, 109, 0.25);
2840
+ background: var(--panel-strong);
2841
+ }
2842
+
2843
+ .meeting-row__title {
2844
+ font-weight: 600;
2845
+ }
2846
+
2847
+ .meeting-row__meta {
2848
+ color: var(--muted);
2849
+ font-size: 0.92rem;
2850
+ }
2851
+
2852
+ .meeting-empty {
2853
+ padding: 18px;
2854
+ color: var(--muted);
2855
+ }
2856
+
2857
+ .meeting-empty--error {
2858
+ color: var(--error);
2859
+ }
2860
+
2861
+ .detail {
2862
+ display: grid;
2863
+ grid-template-rows: auto auto 1fr;
2864
+ min-width: 0;
2865
+ }
2866
+
2867
+ .detail-head {
2868
+ display: flex;
2869
+ align-items: center;
2870
+ justify-content: space-between;
2871
+ gap: 18px;
2872
+ }
2873
+
2874
+ .detail-head h2 {
2875
+ margin: 0;
2876
+ font-family: var(--serif);
2877
+ font-size: clamp(1.8rem, 2.4vw, 2.4rem);
2878
+ font-weight: 600;
2879
+ }
2880
+
2881
+ .state-badge {
2882
+ padding: 10px 14px;
2883
+ border-radius: 999px;
2884
+ background: var(--accent-soft);
2885
+ color: var(--accent);
2886
+ font-size: 0.92rem;
2887
+ font-weight: 700;
2888
+ }
2889
+
2890
+ .state-badge[data-tone="busy"] { color: var(--warm); background: rgba(163, 79, 47, 0.12); }
2891
+ .state-badge[data-tone="error"] { color: var(--error); background: rgba(157, 44, 44, 0.12); }
2892
+ .state-badge[data-tone="ok"] { color: var(--ok); background: rgba(36, 107, 79, 0.12); }
2893
+
2894
+ .toolbar {
2895
+ display: flex;
2896
+ flex-wrap: wrap;
2897
+ align-items: center;
2898
+ justify-content: space-between;
2899
+ gap: 14px;
2900
+ }
2901
+
2902
+ .toolbar-actions {
2903
+ display: flex;
2904
+ flex-wrap: wrap;
2905
+ gap: 10px;
2906
+ }
2907
+
2908
+ .toolbar-form {
2909
+ display: grid;
2910
+ grid-template-columns: minmax(0, 1fr) auto;
2911
+ gap: 10px;
2912
+ width: min(440px, 100%);
2913
+ }
2914
+
2915
+ .workspace-tabs {
2916
+ display: flex;
2917
+ flex-wrap: wrap;
2918
+ align-items: center;
2919
+ gap: 10px;
2920
+ padding: 0 24px 18px;
2921
+ }
2922
+
2923
+ .workspace-tab {
2924
+ border: 1px solid var(--line);
2925
+ border-radius: 999px;
2926
+ padding: 10px 14px;
2927
+ background: rgba(255, 255, 255, 0.72);
2928
+ color: var(--muted);
2929
+ cursor: pointer;
2930
+ font: inherit;
2931
+ font-weight: 700;
2932
+ }
2933
+
2934
+ .workspace-tab[data-selected="true"] {
2935
+ background: var(--ink);
2936
+ color: white;
2937
+ border-color: var(--ink);
2938
+ }
2939
+
2940
+ .workspace-hint {
2941
+ color: var(--muted);
2942
+ font-size: 0.86rem;
2943
+ margin-left: auto;
2944
+ }
2945
+
2946
+ .button {
2947
+ border: 0;
2948
+ border-radius: 999px;
2949
+ padding: 12px 16px;
2950
+ font: inherit;
2951
+ font-weight: 700;
2952
+ cursor: pointer;
2953
+ }
2954
+
2955
+ .button--primary {
2956
+ background: var(--ink);
2957
+ color: white;
2958
+ }
2959
+
2960
+ .button--secondary {
2961
+ background: rgba(255, 255, 255, 0.72);
2962
+ color: var(--ink);
2963
+ border: 1px solid var(--line);
2964
+ }
2965
+
2966
+ .status-grid {
2967
+ display: grid;
2968
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
2969
+ gap: 14px;
2970
+ }
2971
+
2972
+ .status-label {
2973
+ display: block;
2974
+ margin-bottom: 6px;
2975
+ color: var(--muted);
2976
+ font-size: 0.78rem;
2977
+ letter-spacing: 0.08em;
2978
+ text-transform: uppercase;
2979
+ }
2980
+
2981
+ .detail-meta {
2982
+ display: flex;
2983
+ flex-wrap: wrap;
2984
+ gap: 10px;
2985
+ padding: 0 24px 18px;
2986
+ }
2987
+
2988
+ .detail-chip {
2989
+ padding: 10px 12px;
2990
+ border-radius: 999px;
2991
+ background: rgba(255, 255, 255, 0.72);
2992
+ border: 1px solid var(--line);
2993
+ color: var(--muted);
2994
+ font-size: 0.88rem;
2995
+ }
2996
+
2997
+ .detail-body {
2998
+ padding: 0 24px 24px;
2999
+ overflow: auto;
3000
+ }
3001
+
3002
+ .workspace-grid {
3003
+ display: grid;
3004
+ grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
3005
+ gap: 18px;
3006
+ }
3007
+
3008
+ .workspace-sidebar,
3009
+ .workspace-main {
3010
+ margin-bottom: 0;
3011
+ }
3012
+
3013
+ .detail-section {
3014
+ margin-bottom: 20px;
3015
+ padding: 20px;
3016
+ background: rgba(255, 255, 255, 0.72);
3017
+ border: 1px solid var(--line);
3018
+ border-radius: 20px;
3019
+ }
3020
+
3021
+ .detail-section h2 {
3022
+ margin: 0 0 14px;
3023
+ font-size: 1rem;
3024
+ letter-spacing: 0.08em;
3025
+ text-transform: uppercase;
3026
+ }
3027
+
3028
+ .detail-pre {
3029
+ margin: 0;
3030
+ white-space: pre-wrap;
3031
+ word-break: break-word;
3032
+ font-family: var(--mono);
3033
+ line-height: 1.55;
3034
+ }
3035
+
3036
+ .empty {
3037
+ margin: 24px;
3038
+ padding: 24px;
3039
+ border-radius: 20px;
3040
+ background: rgba(255, 255, 255, 0.72);
3041
+ border: 1px dashed rgba(36, 39, 44, 0.2);
3042
+ color: var(--muted);
3043
+ }
3044
+
3045
+ @media (max-width: 900px) {
3046
+ .shell {
3047
+ grid-template-columns: 1fr;
3048
+ }
3049
+
3050
+ .field-row--inline,
3051
+ .toolbar-form,
3052
+ .workspace-grid {
3053
+ grid-template-columns: 1fr;
3054
+ }
3055
+
3056
+ .workspace-hint {
3057
+ margin-left: 0;
3058
+ }
3059
+ }
3060
+ `;
3061
+ //#endregion
3062
+ //#region src/server/web.ts
3063
+ function renderGranolaWebPage() {
3064
+ return `<!doctype html>
3065
+ <html lang="en">
3066
+ <head>
3067
+ <meta charset="utf-8" />
3068
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
3069
+ <title>Granola Toolkit</title>
3070
+ <style>
3071
+ ${granolaWebStyles}
3072
+ </style>
3073
+ </head>
3074
+ <body>
3075
+ ${granolaWebMarkup}
3076
+ <script type="module">
3077
+ ${granolaWebClientScript}
2912
3078
  <\/script>
2913
3079
  </body>
2914
3080
  </html>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.20.0",
3
+ "version": "0.21.1",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",