Skip to main content

max / goingson

polish: deferred fuzz pass — UI cues, frontend trims, Rust quick wins CSS: .btn base now carries --shadow-brutal-xs with hover lift and press-down active state; disabled state replaces opacity-only fade with transparent-bg + muted color/border. .tab.active and .pill.active gain the same xs shadow so the active chrome lifts above inactive siblings. .sync-dot encodes state in shape as well as color — warn renders as a rotated diamond, error as a square — so the indicator is legible in mobile UI (label hidden) and in monochrome rendering. Frontend polish: "Try again" buttons inline on the four bare "Failed to load" surfaces (tasks, emails, events, task-board) so recovery doesn't depend on catching the toast. Stripped seven chatty console.log calls in app.js + seed-data.js (boot logs, sync handlers, prod seed-data hint). Task-drawer close glyph x → &times;. Settings empty-plugins hint replaces a Linux-only path with an OS-agnostic phrasing. index.html stragglers fixed: row row-flex-2 → row-flex row-flex-2 to match neighbors; back-arrow at index.html:270 literal ← → &larr; matching the other three back buttons. Rust quick wins: tokio::fs::read for attachment payloads in send_email so the read doesn't block the runtime. ContentType parse in smtp_client no longer unwraps on a static fallback — propagates a typed error instead. Seven mutex .expect("...poisoned") sites switched to unwrap_or_else(|e| e.into_inner()) since the protected data is recoverable on poisoning (state.rs, oauth.rs, sync.rs, email_sync.rs). Docs: CHANGELOG replaces "audit runs 1-12" internal language with the six concrete user-visible fixes that block-stamped the launch. README adds a commercial-license contact line for PolyForm-NC inquiries. LICENSE picks up the PolyForm-required "Required Notice" copyright line that was sitting uncommitted from a prior session. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-03 01:46 UTC
Commit: 7421ead3afb30ef1a2da39bc8805095c3ca4e91e
Parent: aa396dc
18 files changed, +44 insertions, -40 deletions
M CHANGELOG.md +7 -3
@@ -22,9 +22,13 @@ First beta-ready release.
22 22 - macOS, Windows, and Linux builds (signed and notarized on macOS)
23 23
24 24 ### Fixed
25 - - All issues identified in audit runs 1–12
26 - - Concurrency and type safety patterns (Rust patterns audit)
25 + - Empty-state copy across tasks, emails, projects, contacts and events
26 + - About modal and Settings populated with publisher, license, contact, source, privacy, and copyright
27 + - Hard-fail error messages rewritten to plain language with actionable next steps
28 + - Bulk-action buttons now signal destructive intent with the danger color
29 + - Mutex poisoning recovery — no more panics on contended locks under crash recovery
30 + - SMTP attachment content-type parsing no longer relies on an unwrap
27 31
28 32 ### Security
29 - - Full observability instrumentation (206 traced functions)
33 + - Privacy policy contact address corrected to `info@makenot.work`
30 34 - No production unsafe code
M LICENSE +2
@@ -1,3 +1,5 @@
1 + Required Notice: Copyright 2026 Make Creative, LLC (https://makenot.work)
2 +
1 3 PolyForm Noncommercial License 1.0.0
2 4
3 5 <https://polyformproject.org/licenses/noncommercial/1.0.0>
M README.md +1 -1
@@ -71,4 +71,4 @@ Dependency flow: `core` is leaf -> `db-sqlite` and `plugin-runtime` depend on `c
71 71
72 72 ## License
73 73
74 - PolyForm Noncommercial 1.0.0
74 + PolyForm Noncommercial 1.0.0. Personal, research, and non-commercial use are free. For commercial use, contact `info@makenot.work` to discuss a commercial license.
@@ -476,6 +476,7 @@ body {
476 476 .ui-mode-desktop .tab.active {
477 477 background-color: var(--accent-blue);
478 478 color: var(--text-on-accent);
479 + box-shadow: var(--shadow-brutal-xs);
479 480 }
480 481
481 482 .ui-mode-desktop .tab-icon {
@@ -526,6 +527,7 @@ body {
526 527 background: var(--text-primary);
527 528 color: var(--bg-card);
528 529 border-color: var(--text-primary);
530 + box-shadow: var(--shadow-brutal-xs);
529 531 }
530 532
531 533 /* ===================================================================
@@ -598,29 +600,37 @@ body {
598 600 font-size: 0.9rem;
599 601 font-weight: 600;
600 602 cursor: pointer;
601 - transition: background-color 0.15s ease;
603 + transition: background-color 0.15s ease, transform 0.1s ease, box-shadow 0.1s ease;
602 604 text-decoration: none;
603 605 background: var(--bg-card);
604 606 color: var(--text-primary);
607 + box-shadow: var(--shadow-brutal-xs);
605 608 }
606 609
607 610 .btn:hover {
608 611 background: var(--bg-secondary);
612 + transform: translate(-1px, -1px);
613 + box-shadow: var(--shadow-offset-md) var(--shadow-offset-md) 0 var(--border-color);
609 614 }
610 615
611 616 .btn:active {
612 617 background: var(--bg-tertiary);
618 + transform: translate(1px, 1px);
619 + box-shadow: none;
613 620 }
614 621
615 622 .btn:disabled {
616 - background: var(--bg-tertiary);
623 + background: transparent;
617 624 color: var(--text-muted);
625 + border-color: var(--text-muted);
618 626 cursor: not-allowed;
619 - opacity: 0.7;
627 + box-shadow: none;
620 628 }
621 629
622 630 .btn:disabled:hover {
623 - background: var(--bg-tertiary);
631 + background: transparent;
632 + transform: none;
633 + box-shadow: none;
624 634 }
625 635
626 636 .btn-primary {
@@ -4698,8 +4708,8 @@ kbd {
4698 4708 }
4699 4709 .sync-dot.connected { background: var(--accent-green); }
4700 4710 .sync-dot.syncing { background: var(--accent-blue); animation: sync-pulse 1s infinite; }
4701 - .sync-dot.warn { background: var(--accent-yellow); }
4702 - .sync-dot.error { background: var(--accent-red); }
4711 + .sync-dot.warn { background: var(--accent-yellow); border-radius: 0; transform: rotate(45deg); }
4712 + .sync-dot.error { background: var(--accent-red); border-radius: 0; }
4703 4713
4704 4714 /* Status label — always shown at desktop widths so color isn't the sole signal.
4705 4715 Hidden on narrow widths to save space; hover still surfaces the title attr. */
@@ -267,7 +267,7 @@
267 267 <div id="task-overview-view" class="subview hidden">
268 268 <div class="page-header">
269 269 <div class="row-flex row-flex-4">
270 - <button class="btn btn-secondary" onclick="GoingsOn.taskOverview.close()">← Back</button>
270 + <button class="btn btn-secondary" onclick="GoingsOn.taskOverview.close()">&larr; Back</button>
271 271 <h2 class="page-title" id="task-overview-title">Task Overview</h2>
272 272 </div>
273 273 <div id="task-overview-actions"></div>
@@ -463,7 +463,7 @@
463 463 <div id="emails-view" class="subview" role="tabpanel" aria-labelledby="emails-tab">
464 464 <div class="page-header">
465 465 <h2 class="page-title">Emails</h2>
466 - <div class="row row-flex-2">
466 + <div class="row-flex row-flex-2">
467 467 <button class="btn btn-secondary mobile-hide" onclick="GoingsOn.emails.openDrafts()">Drafts</button>
468 468 <button class="btn btn-primary" onclick="GoingsOn.emails.openCompose()" title="Compose email (n)">+ Compose</button>
469 469 </div>
@@ -600,7 +600,7 @@
600 600 <!-- Task Detail Drawer (Phase 7 Tier 6 — side-drawer task detail) -->
601 601 <aside id="task-detail-drawer" class="task-drawer" role="dialog" aria-modal="false" aria-labelledby="task-drawer-title" aria-hidden="true">
602 602 <div class="task-drawer-header">
603 - <button class="btn btn-sm btn-secondary task-drawer-close" onclick="GoingsOn.taskOverview.close()" title="Close (Esc)" aria-label="Close task detail">x</button>
603 + <button class="btn btn-sm btn-secondary task-drawer-close" onclick="GoingsOn.taskOverview.close()" title="Close (Esc)" aria-label="Close task detail">&times;</button>
604 604 <h2 class="task-drawer-title" id="task-drawer-title">Task</h2>
605 605 <div class="task-drawer-actions" id="task-drawer-actions"></div>
606 606 </div>
@@ -9,9 +9,6 @@
9 9 // ============ Application Initialization ============
10 10
11 11 document.addEventListener('DOMContentLoaded', async () => {
12 - console.log('GoingsOn Desktop loaded');
13 -
14 -
15 12 // Check if api is available
16 13 if (!GoingsOn.api) {
17 14 console.error('API not available');
@@ -25,7 +22,6 @@ document.addEventListener('DOMContentLoaded', async () => {
25 22 try {
26 23 const projects = await GoingsOn.api.projects.list();
27 24 GoingsOn.projects.setCache(projects);
28 - console.log('Loaded', projects.length, 'projects');
29 25 } catch (err) {
30 26 console.error('Failed to load projects:', err);
31 27 }
@@ -34,7 +30,6 @@ document.addEventListener('DOMContentLoaded', async () => {
34 30 try {
35 31 const accounts = await GoingsOn.api.emailAccounts.list();
36 32 GoingsOn.emails.setAccountsCache(accounts);
37 - console.log('Loaded', accounts.length, 'email accounts');
38 33 } catch (err) {
39 34 console.error('Failed to load email accounts:', err);
40 35 }
@@ -143,14 +138,12 @@ if (window.__TAURI__) {
143 138
144 139 // Database external change detection
145 140 listen('db:external-change', () => {
146 - console.log('External database change detected, refreshing view');
147 141 GoingsOn.cache.invalidateAll();
148 142 refreshCurrentViewData();
149 143 });
150 144
151 145 // Cloud sync: remote changes applied
152 146 listen('sync:changes-applied', () => {
153 - console.log('Sync: remote changes applied, refreshing view');
154 147 GoingsOn.cache.invalidateAll();
155 148 refreshCurrentViewData();
156 149 });
@@ -189,7 +182,6 @@ if (window.__TAURI__) {
189 182 async function refreshCurrentViewData() {
190 183 // Don't refresh if a modal is open (user is editing something)
191 184 if (document.querySelector('.modal:not(.hidden)')) {
192 - console.log('Modal open, skipping external refresh');
193 185 return;
194 186 }
195 187
@@ -202,9 +194,6 @@ async function refreshCurrentViewData() {
202 194
203 195 // Reload the current view's data
204 196 await GoingsOn.navigation.loadViewData(currentView);
205 -
206 - // Subtle indication that data was refreshed
207 - console.log(`View "${currentView}" refreshed due to external change`);
208 197 } catch (err) {
209 198 console.error('Failed to refresh view after external change:', err);
210 199 }
@@ -102,7 +102,7 @@
102 102 }
103 103 GoingsOn.cache.markLoaded('emails');
104 104 } catch (err) {
105 - container.innerHTML = `<div class="loading loading--error">Failed to load emails</div>`;
105 + container.innerHTML = `<div class="loading loading--error">Failed to load emails. <button class="btn-link" onclick="GoingsOn.emails.load()">Try again</button></div>`;
106 106 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load emails'), 'error', {
107 107 action: { label: 'Retry', fn: load },
108 108 duration: 8000,
@@ -416,7 +416,7 @@
416 416 }
417 417 GoingsOn.cache.markLoaded('events');
418 418 } catch (err) {
419 - upcomingContainer.innerHTML = `<div class="error-state error-state--padded">Failed to load events</div>`;
419 + upcomingContainer.innerHTML = `<div class="error-state error-state--padded">Failed to load events. <button class="btn-link" onclick="GoingsOn.cache.invalidate('events'); GoingsOn.events.load()">Try again</button></div>`;
420 420 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load events'), 'error', {
421 421 action: { label: 'Retry', fn: () => { GoingsOn.cache.invalidate('events'); load(); } },
422 422 duration: 8000,
@@ -390,6 +390,4 @@ GoingsOn.seedData = {
390 390 clearAll: clearAllData,
391 391 };
392 392
393 - console.log('Demo data generator loaded. Run GoingsOn.seedData.seed() in the console to generate sample data.');
394 -
395 393 })();
@@ -193,7 +193,7 @@
193 193
194 194 const enabledIds = new Set(enabledPlugins.map(p => p.id));
195 195 const pluginsList = allPlugins.length === 0
196 - ? '<p class="empty-italic">No plugins installed. Place plugins in ~/.config/goingson/plugins/</p>'
196 + ? '<p class="empty-italic">No plugins installed. Drop a plugin file into the GoingsOn plugins directory inside your OS app-data location.</p>'
197 197 : allPlugins.map(plugin => renderPluginItem(plugin, enabledIds.has(plugin.id))).join('');
198 198
199 199 container.innerHTML = `
@@ -89,7 +89,7 @@
89 89 } catch (err) {
90 90 const board = document.getElementById('task-kanban-board');
91 91 if (board) {
92 - board.innerHTML = `<div class="loading loading--error">Error: ${esc(err.message || String(err))}</div>`;
92 + board.innerHTML = `<div class="loading loading--error">Failed to load board: ${esc(err.message || String(err))}. <button class="btn-link" onclick="GoingsOn.taskBoard.renderBoard()">Try again</button></div>`;
93 93 }
94 94 }
95 95 }
@@ -123,7 +123,7 @@
123 123 // Update cache with filtered results
124 124 GoingsOn.state.set('tasks', response.tasks);
125 125 } catch (err) {
126 - container.innerHTML = `<div class="loading loading--error">Failed to load tasks</div>`;
126 + container.innerHTML = `<div class="loading loading--error">Failed to load tasks. <button class="btn-link" onclick="GoingsOn.tasks.renderFilteredTasks()">Try again</button></div>`;
127 127 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load tasks'), 'error', {
128 128 action: { label: 'Retry', fn: renderFilteredTasks },
129 129 duration: 8000,
@@ -595,7 +595,7 @@ async fn send_email_inner(state: &Arc<AppState>, input: SendEmailInput) -> Resul
595 595 if !path.is_file() {
596 596 return Err(ApiError::validation("attachmentPaths", format!("File not found: {}", path_str)));
597 597 }
598 - let data = std::fs::read(path)
598 + let data = tokio::fs::read(path).await
599 599 .map_api_err("Failed to read attachment file", ApiError::internal)?;
600 600 let filename = path.file_name()
601 601 .and_then(|n| n.to_str())
@@ -46,7 +46,7 @@ pub async fn sync_email_account_inner(
46 46 full_sync: Option<bool>,
47 47 ) -> Result<SyncResponse, ApiError> {
48 48 // Acquire per-account lock to prevent concurrent syncs on the same account
49 - if !state.email_sync_locks.lock().expect("email_sync_locks poisoned").insert(id) {
49 + if !state.email_sync_locks.lock().unwrap_or_else(|e| e.into_inner()).insert(id) {
50 50 return Err(ApiError::bad_request("Sync already in progress for this account"));
51 51 }
52 52
@@ -128,7 +128,7 @@ pub async fn start_oauth(
128 128
129 129 // Store PKCE verifier and flow details server-side (never sent to frontend)
130 130 {
131 - let mut flows = state.pending_oauth_flows.lock().expect("pending_oauth_flows poisoned");
131 + let mut flows = state.pending_oauth_flows.lock().unwrap_or_else(|e| e.into_inner());
132 132 flows.insert(start_result.state.clone(), crate::state::PendingOAuthFlow {
133 133 code_verifier: start_result.code_verifier,
134 134 provider_id: provider_id.clone(),
@@ -163,7 +163,7 @@ pub async fn complete_oauth(
163 163 ) -> Result<OAuthCompleteResponse, ApiError> {
164 164 // Look up and consume the pending flow by state token (CSRF validation)
165 165 let flow = {
166 - let mut flows = state.pending_oauth_flows.lock().expect("pending_oauth_flows poisoned");
166 + let mut flows = state.pending_oauth_flows.lock().unwrap_or_else(|e| e.into_inner());
167 167 flows.remove(&input.state)
168 168 }.ok_or_else(|| ApiError::bad_request("Invalid or expired OAuth state token"))?;
169 169
@@ -72,7 +72,7 @@ pub struct SyncSettingsInput {
72 72
73 73 /// Extract the sync client from state. Clones the Arc for use across await points.
74 74 fn get_sync_client(state: &AppState) -> Option<std::sync::Arc<synckit_client::SyncKitClient>> {
75 - state.sync_client.read().expect("sync_client poisoned").clone()
75 + state.sync_client.read().unwrap_or_else(|e| e.into_inner()).clone()
76 76 }
77 77
78 78 fn require_sync_client(state: &AppState) -> Result<std::sync::Arc<synckit_client::SyncKitClient>, ApiError> {
@@ -174,7 +174,7 @@ pub async fn sync_start_auth(
174 174
175 175 // Store PKCE verifier server-side (never sent to frontend)
176 176 {
177 - let mut flows = state.pending_oauth_flows.lock().expect("pending_oauth_flows poisoned");
177 + let mut flows = state.pending_oauth_flows.lock().unwrap_or_else(|e| e.into_inner());
178 178 flows.insert(csrf_state.clone(), crate::state::PendingOAuthFlow {
179 179 code_verifier,
180 180 provider_id: "synckit".to_string(),
@@ -200,7 +200,7 @@ pub async fn sync_complete_auth(
200 200
201 201 // Look up and consume the pending flow by state token (CSRF + PKCE validation)
202 202 let flow = {
203 - let mut flows = state.pending_oauth_flows.lock().expect("pending_oauth_flows poisoned");
203 + let mut flows = state.pending_oauth_flows.lock().unwrap_or_else(|e| e.into_inner());
204 204 flows.remove(&input.state)
205 205 }.ok_or_else(|| ApiError::bad_request("Invalid or expired OAuth state token"))?;
206 206
@@ -288,7 +288,8 @@ impl SmtpClient {
288 288
289 289 for file in &params.attachments {
290 290 let content_type = file.mime_type.parse::<ContentType>()
291 - .unwrap_or(ContentType::parse("application/octet-stream").unwrap());
291 + .or_else(|_| "application/octet-stream".parse::<ContentType>())
292 + .map_err(|e| format!("Invalid attachment content type: {}", e))?;
292 293 let attachment = Attachment::new(file.filename.clone())
293 294 .body(file.data.clone(), content_type);
294 295 multipart = multipart.singlepart(attachment);
@@ -170,7 +170,7 @@ impl AppState {
170 170
171 171 /// Gets or creates a per-account token refresh lock.
172 172 pub fn token_refresh_lock(&self, account_id: uuid::Uuid) -> Arc<TokioMutex<()>> {
173 - let mut locks = self.token_refresh_locks.lock().expect("token_refresh_locks poisoned");
173 + let mut locks = self.token_refresh_locks.lock().unwrap_or_else(|e| e.into_inner());
174 174 locks.entry(account_id)
175 175 .or_insert_with(|| Arc::new(TokioMutex::new(())))
176 176 .clone()