neoagent 3.0.1-beta.2 → 3.0.1-beta.20

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 (83) hide show
  1. package/.env.example +19 -0
  2. package/README.md +16 -0
  3. package/docs/benchmarking.md +102 -0
  4. package/docs/billing.md +224 -0
  5. package/docs/configuration.md +22 -0
  6. package/docs/index.md +1 -0
  7. package/flutter_app/lib/main.dart +4 -1
  8. package/flutter_app/lib/main_app_shell.dart +316 -0
  9. package/flutter_app/lib/main_billing.dart +1465 -0
  10. package/flutter_app/lib/main_chat.dart +1568 -231
  11. package/flutter_app/lib/main_controller.dart +263 -26
  12. package/flutter_app/lib/main_devices.dart +1 -1
  13. package/flutter_app/lib/main_install.dart +1147 -0
  14. package/flutter_app/lib/main_navigation.dart +12 -0
  15. package/flutter_app/lib/main_operations.dart +134 -9
  16. package/flutter_app/lib/main_settings.dart +148 -52
  17. package/flutter_app/lib/main_shared.dart +1 -0
  18. package/flutter_app/lib/src/backend_client.dart +69 -1
  19. package/landing/index.html +2 -2
  20. package/lib/manager.js +156 -0
  21. package/lib/schema_migrations.js +84 -0
  22. package/package.json +10 -4
  23. package/server/admin/access.js +12 -7
  24. package/server/admin/admin.css +78 -0
  25. package/server/admin/admin.js +511 -23
  26. package/server/admin/billing.js +415 -0
  27. package/server/admin/index.html +120 -13
  28. package/server/admin/users.js +15 -15
  29. package/server/db/database.js +13 -21
  30. package/server/db/sessions_db.js +8 -0
  31. package/server/http/middleware.js +4 -4
  32. package/server/http/routes.js +9 -0
  33. package/server/http/static.js +4 -2
  34. package/server/middleware/requireBilling.js +12 -0
  35. package/server/public/.last_build_id +1 -1
  36. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  37. package/server/public/flutter_bootstrap.js +1 -1
  38. package/server/public/main.dart.js +89120 -85431
  39. package/server/routes/admin.js +524 -29
  40. package/server/routes/agents.js +203 -21
  41. package/server/routes/billing.js +127 -0
  42. package/server/routes/billing_webhook.js +43 -0
  43. package/server/routes/memory.js +1 -4
  44. package/server/routes/settings.js +1 -2
  45. package/server/routes/skills.js +1 -1
  46. package/server/routes/triggers.js +2 -2
  47. package/server/services/account/sessions.js +1 -9
  48. package/server/services/ai/loop/agent_engine_core.js +42 -0
  49. package/server/services/ai/loop/blank_recovery.js +36 -0
  50. package/server/services/ai/loop/completion_judge.js +42 -0
  51. package/server/services/ai/loop/conversation_loop.js +248 -34
  52. package/server/services/ai/loop/progress_monitor.js +6 -6
  53. package/server/services/ai/loopPolicy.js +37 -44
  54. package/server/services/ai/messagingFallback.js +25 -1
  55. package/server/services/ai/models.js +18 -0
  56. package/server/services/ai/preModelCompaction.js +25 -5
  57. package/server/services/ai/rate_limits.js +28 -4
  58. package/server/services/ai/systemPrompt.js +23 -5
  59. package/server/services/ai/taskAnalysis.js +2 -0
  60. package/server/services/ai/tools.js +231 -20
  61. package/server/services/billing/billing_email.js +106 -0
  62. package/server/services/billing/config.js +17 -0
  63. package/server/services/billing/plans.js +121 -0
  64. package/server/services/billing/stripe_client.js +21 -0
  65. package/server/services/billing/subscriptions.js +462 -0
  66. package/server/services/billing/trial_guard.js +111 -0
  67. package/server/services/browser/contentExtractor.js +629 -0
  68. package/server/services/browser/controller.js +4 -8
  69. package/server/services/desktop/gateway.js +7 -4
  70. package/server/services/integrations/google/calendar.js +30 -2
  71. package/server/services/integrations/secrets.js +7 -4
  72. package/server/services/manager.js +11 -0
  73. package/server/services/messaging/automation.js +1 -1
  74. package/server/services/messaging/telnyx.js +12 -11
  75. package/server/services/messaging/whatsapp.js +1 -1
  76. package/server/services/recordings/manager.js +13 -7
  77. package/server/services/runtime/backends/local-vm.js +40 -22
  78. package/server/services/tasks/runtime.js +103 -15
  79. package/server/services/tasks/schedule_utils.js +5 -5
  80. package/server/services/voice/runtimeManager.js +19 -10
  81. package/server/services/wearable/gateway.js +8 -5
  82. package/server/services/websocket.js +26 -8
  83. package/server/services/workspace/manager.js +37 -3
package/lib/manager.js CHANGED
@@ -2000,6 +2000,152 @@ function cmdVersion() {
2000
2000
  console.log(currentInstalledVersionLabel());
2001
2001
  }
2002
2002
 
2003
+ async function cmdBilling(args = []) {
2004
+ const sub = (args[0] || 'status').toLowerCase();
2005
+
2006
+ if (sub === '--help' || sub === '-h' || sub === 'help') {
2007
+ console.log('\nNeoAgent Billing');
2008
+ console.log('Usage: neoagent billing [subcommand]');
2009
+ console.log('');
2010
+ console.log('Subcommands:');
2011
+ console.log(' neoagent billing Show billing configuration status');
2012
+ console.log(' neoagent billing setup Interactive Stripe setup wizard');
2013
+ console.log(' neoagent billing enable Enable billing and restart');
2014
+ console.log(' neoagent billing disable Disable billing and restart');
2015
+ console.log('');
2016
+ return;
2017
+ }
2018
+
2019
+ if (sub === 'status') {
2020
+ cliBanner('Billing', 'configuration status');
2021
+ heading('Billing Status');
2022
+ const env = Object.fromEntries(parseEnv(readEnvFileRaw()).entries());
2023
+ const enabled = ['1', 'true', 'yes'].includes(String(env.NEOAGENT_BILLING_ENABLED || '').toLowerCase());
2024
+ const hasSecret = Boolean(env.STRIPE_SECRET_KEY);
2025
+ const hasPub = Boolean(env.STRIPE_PUBLISHABLE_KEY);
2026
+ const hasWebhook = Boolean(env.STRIPE_WEBHOOK_SECRET);
2027
+ const trialDays = env.BILLING_TRIAL_DAYS || '14';
2028
+
2029
+ cliSection('Billing config');
2030
+ statusLine(enabled, 'enabled', enabled ? 'yes' : 'no', enabled ? '' : 'run: neoagent billing enable');
2031
+ statusLine(hasSecret, 'secret', hasSecret ? maskEnvValue('SECRET', env.STRIPE_SECRET_KEY) : 'not set', hasSecret ? '' : 'run: neoagent billing setup');
2032
+ statusLine(hasPub, 'public', hasPub ? maskEnvValue('KEY', env.STRIPE_PUBLISHABLE_KEY) : 'not set');
2033
+ statusLine(hasWebhook, 'webhook', hasWebhook ? 'configured' : 'not set');
2034
+ console.log('');
2035
+ cliSection('Trial');
2036
+ console.log(` trial days ${trialDays}`);
2037
+
2038
+ const port = loadEnvPort();
2039
+ const publicUrl = env.PUBLIC_URL || `http://localhost:${port}`;
2040
+ console.log('');
2041
+ cliSection('Webhook endpoint');
2042
+ console.log(` ${publicUrl}/api/billing/webhook`);
2043
+ console.log('');
2044
+
2045
+ if (!hasSecret || !hasPub) {
2046
+ logWarn('Stripe keys not configured. Run `neoagent billing setup` to get started.');
2047
+ } else if (!enabled) {
2048
+ logInfo('Billing is configured but not enabled. Run `neoagent billing enable` to activate.');
2049
+ } else {
2050
+ logOk('Billing is active. Manage plans in Admin › Billing.');
2051
+ }
2052
+ return;
2053
+ }
2054
+
2055
+ if (sub === 'enable') {
2056
+ heading('Enable Billing');
2057
+ upsertEnvValue('NEOAGENT_BILLING_ENABLED', 'true');
2058
+ logOk('Set NEOAGENT_BILLING_ENABLED=true');
2059
+ logInfo('Restarting NeoAgent to apply changes...');
2060
+ cmdRestart();
2061
+ return;
2062
+ }
2063
+
2064
+ if (sub === 'disable') {
2065
+ heading('Disable Billing');
2066
+ upsertEnvValue('NEOAGENT_BILLING_ENABLED', 'false');
2067
+ logOk('Set NEOAGENT_BILLING_ENABLED=false');
2068
+ logInfo('Restarting NeoAgent to apply changes...');
2069
+ cmdRestart();
2070
+ return;
2071
+ }
2072
+
2073
+ if (sub === 'setup') {
2074
+ cliBanner('Billing Setup', 'Stripe configuration');
2075
+ heading('Billing Setup');
2076
+ ensureRuntimeDirs();
2077
+
2078
+ const current = Object.fromEntries(parseEnv(readEnvFileRaw()).entries());
2079
+ const port = loadEnvPort();
2080
+ const publicUrl = current.PUBLIC_URL || `http://localhost:${port}`;
2081
+
2082
+ logInfo('Press Enter to keep the current value shown in brackets.');
2083
+ logInfo(`Webhook endpoint: ${publicUrl}/api/billing/webhook`);
2084
+ console.log('');
2085
+
2086
+ heading('Stripe API keys');
2087
+ logInfo('Find these in your Stripe dashboard: https://dashboard.stripe.com/apikeys');
2088
+ const secretKey = await askSecret('Stripe secret key (sk_live_... or sk_test_...)', current.STRIPE_SECRET_KEY || '');
2089
+ const publishableKey = await askSecret('Stripe publishable key (pk_live_... or pk_test_...)', current.STRIPE_PUBLISHABLE_KEY || '');
2090
+
2091
+ if (secretKey && !secretKey.startsWith('sk_')) {
2092
+ logWarn('Secret key does not start with "sk_" — double-check the value.');
2093
+ }
2094
+ if (publishableKey && !publishableKey.startsWith('pk_')) {
2095
+ logWarn('Publishable key does not start with "pk_" — double-check the value.');
2096
+ }
2097
+
2098
+ heading('Webhook');
2099
+ logInfo(`Register this endpoint in Stripe: ${publicUrl}/api/billing/webhook`);
2100
+ logInfo('Required events: customer.subscription.*, invoice.payment_succeeded, invoice.payment_failed');
2101
+ const webhookSecret = await askSecret('Stripe webhook signing secret (whsec_...)', current.STRIPE_WEBHOOK_SECRET || '');
2102
+
2103
+ if (webhookSecret && !webhookSecret.startsWith('whsec_')) {
2104
+ logWarn('Webhook secret does not start with "whsec_" — double-check the value.');
2105
+ }
2106
+
2107
+ heading('Trial');
2108
+ const trialDaysRaw = await ask('Free trial length in days (0 to disable)', current.BILLING_TRIAL_DAYS || '14');
2109
+ const trialDays = Number(trialDaysRaw);
2110
+ if (!Number.isInteger(trialDays) || trialDays < 0) {
2111
+ throw new Error(`Invalid trial length "${trialDaysRaw}". Must be a non-negative integer.`);
2112
+ }
2113
+
2114
+ if (secretKey) upsertEnvValue('STRIPE_SECRET_KEY', secretKey);
2115
+ if (publishableKey) upsertEnvValue('STRIPE_PUBLISHABLE_KEY', publishableKey);
2116
+ if (webhookSecret) upsertEnvValue('STRIPE_WEBHOOK_SECRET', webhookSecret);
2117
+ upsertEnvValue('BILLING_TRIAL_DAYS', String(trialDays));
2118
+
2119
+ logOk('Stripe credentials saved to .env');
2120
+
2121
+ const isAlreadyEnabled = ['1', 'true', 'yes'].includes(String(current.NEOAGENT_BILLING_ENABLED || '').toLowerCase());
2122
+ let enableNow = isAlreadyEnabled;
2123
+ if (!isAlreadyEnabled) {
2124
+ const enableAnswer = await ask('Enable billing now? (y/N)', 'N');
2125
+ enableNow = enableAnswer.toLowerCase() === 'y' || enableAnswer.toLowerCase() === 'yes';
2126
+ }
2127
+
2128
+ if (enableNow) {
2129
+ upsertEnvValue('NEOAGENT_BILLING_ENABLED', 'true');
2130
+ logOk('Billing enabled');
2131
+ logInfo('Restarting NeoAgent to apply credentials...');
2132
+ cmdRestart();
2133
+ } else {
2134
+ logInfo('Billing is not yet enabled. Run `neoagent billing enable` when ready.');
2135
+ }
2136
+
2137
+ console.log('');
2138
+ heading('Next steps');
2139
+ logInfo('1. Verify the webhook endpoint is reachable from Stripe');
2140
+ logInfo('2. Create subscription plans in Admin › Billing › Plans');
2141
+ logInfo('3. Add a free plan (price = 0) for users without a paid subscription');
2142
+ logInfo(' See: neoagent billing status');
2143
+ return;
2144
+ }
2145
+
2146
+ throw new Error(`Unknown billing subcommand: ${sub}. Run "neoagent billing --help" for usage.`);
2147
+ }
2148
+
2003
2149
  function printHelp() {
2004
2150
  const c = COLORS;
2005
2151
  const W = 38;
@@ -2047,6 +2193,13 @@ function printHelp() {
2047
2193
  row('admin', 'Show admin dashboard URL and credentials');
2048
2194
  console.log('');
2049
2195
 
2196
+ cliSection('Billing');
2197
+ row('billing', 'Show billing configuration status');
2198
+ row('billing setup', 'Interactive Stripe setup wizard');
2199
+ row('billing enable', 'Enable billing and restart');
2200
+ row('billing disable', 'Disable billing and restart');
2201
+ console.log('');
2202
+
2050
2203
  cliSection('Maintenance');
2051
2204
  row('migrate', 'Migrate from another agent installation');
2052
2205
  row('migrate dry-run', 'Preview what would be migrated');
@@ -2106,6 +2259,9 @@ async function runCLI(argv) {
2106
2259
  case 'login':
2107
2260
  await cmdLogin(argv.slice(1));
2108
2261
  break;
2262
+ case 'billing':
2263
+ await cmdBilling(argv.slice(1));
2264
+ break;
2109
2265
  case 'admin': {
2110
2266
  cliBanner('Admin Dashboard', 'credentials');
2111
2267
  const adminCreds = readAdminCredentials();
@@ -458,6 +458,88 @@ function migrateToolPoliciesAllowAlways(db) {
458
458
  `);
459
459
  }
460
460
 
461
+ function migrateBilling(db) {
462
+ db.exec(`
463
+ CREATE TABLE IF NOT EXISTS billing_plans (
464
+ id TEXT PRIMARY KEY,
465
+ name TEXT NOT NULL,
466
+ description TEXT DEFAULT '',
467
+ price_cents INTEGER NOT NULL DEFAULT 0,
468
+ currency TEXT NOT NULL DEFAULT 'usd',
469
+ interval TEXT DEFAULT 'month',
470
+ stripe_price_id TEXT,
471
+ token_limit_4h INTEGER,
472
+ token_limit_weekly INTEGER,
473
+ allowed_models_json TEXT DEFAULT '[]',
474
+ features_json TEXT DEFAULT '[]',
475
+ is_active INTEGER DEFAULT 1,
476
+ sort_order INTEGER DEFAULT 0,
477
+ created_at TEXT DEFAULT (datetime('now')),
478
+ updated_at TEXT DEFAULT (datetime('now'))
479
+ );
480
+
481
+ CREATE TABLE IF NOT EXISTS billing_customers (
482
+ user_id INTEGER PRIMARY KEY,
483
+ stripe_customer_id TEXT UNIQUE,
484
+ created_at TEXT DEFAULT (datetime('now')),
485
+ updated_at TEXT DEFAULT (datetime('now')),
486
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
487
+ );
488
+
489
+ CREATE TABLE IF NOT EXISTS user_subscriptions (
490
+ id TEXT PRIMARY KEY,
491
+ user_id INTEGER NOT NULL,
492
+ plan_id TEXT NOT NULL,
493
+ stripe_subscription_id TEXT UNIQUE,
494
+ status TEXT NOT NULL,
495
+ trial_ends_at TEXT,
496
+ current_period_start TEXT,
497
+ current_period_end TEXT,
498
+ cancel_at_period_end INTEGER DEFAULT 0,
499
+ canceled_at TEXT,
500
+ created_at TEXT DEFAULT (datetime('now')),
501
+ updated_at TEXT DEFAULT (datetime('now')),
502
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
503
+ FOREIGN KEY (plan_id) REFERENCES billing_plans(id)
504
+ );
505
+
506
+ CREATE TABLE IF NOT EXISTS subscription_events (
507
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
508
+ user_id INTEGER NOT NULL,
509
+ subscription_id TEXT,
510
+ event_type TEXT NOT NULL,
511
+ stripe_event_id TEXT UNIQUE,
512
+ payload_json TEXT DEFAULT '{}',
513
+ created_at TEXT DEFAULT (datetime('now')),
514
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
515
+ );
516
+
517
+ CREATE TABLE IF NOT EXISTS trial_fingerprints (
518
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
519
+ fingerprint_hash TEXT NOT NULL,
520
+ fingerprint_type TEXT NOT NULL,
521
+ user_id INTEGER,
522
+ used_at TEXT DEFAULT (datetime('now')),
523
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
524
+ );
525
+
526
+ CREATE INDEX IF NOT EXISTS idx_billing_customers_stripe
527
+ ON billing_customers(stripe_customer_id);
528
+
529
+ CREATE INDEX IF NOT EXISTS idx_user_subscriptions_user
530
+ ON user_subscriptions(user_id, status, updated_at DESC);
531
+
532
+ CREATE INDEX IF NOT EXISTS idx_user_subscriptions_stripe
533
+ ON user_subscriptions(stripe_subscription_id);
534
+
535
+ CREATE INDEX IF NOT EXISTS idx_subscription_events_stripe
536
+ ON subscription_events(stripe_event_id);
537
+
538
+ CREATE INDEX IF NOT EXISTS idx_trial_fingerprints_hash
539
+ ON trial_fingerprints(fingerprint_hash, fingerprint_type);
540
+ `);
541
+ }
542
+
461
543
  function runSchemaMigrations(db) {
462
544
  migrateMemoryEmbeddingIndex(db);
463
545
  migrateMemoryProvenance(db);
@@ -469,6 +551,7 @@ function runSchemaMigrations(db) {
469
551
  migrateToolPermissions(db);
470
552
  migrateApprovalPersistence(db);
471
553
  migrateToolPoliciesAllowAlways(db);
554
+ migrateBilling(db);
472
555
  }
473
556
 
474
557
  module.exports = {
@@ -482,5 +565,6 @@ module.exports = {
482
565
  migrateToolPermissions,
483
566
  migrateApprovalPersistence,
484
567
  migrateToolPoliciesAllowAlways,
568
+ migrateBilling,
485
569
  runSchemaMigrations,
486
570
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "3.0.1-beta.2",
3
+ "version": "3.0.1-beta.20",
4
4
  "description": "Self-hosted AI agent for long-running tasks, automation, messaging, device control, and local memory",
5
5
  "license": "AGPL-3.0-only",
6
6
  "main": "server/index.js",
@@ -51,10 +51,14 @@
51
51
  "flutter:test": "cd flutter_app && flutter test ../test/flutter/unit ../test/flutter/widget",
52
52
  "flutter:test:unit": "cd flutter_app && flutter test ../test/flutter/unit",
53
53
  "flutter:test:widget": "cd flutter_app && flutter test ../test/flutter/widget",
54
+ "benchmark:setup": "node benchmark/cli.js setup",
55
+ "benchmark:run": "node benchmark/cli.js run",
56
+ "benchmark:report": "node benchmark/cli.js report",
54
57
  "benchmark:tokens": "node scripts/benchmark-token-cost.js",
55
58
  "benchmark:memory": "node scripts/benchmark-memory.js",
56
59
  "benchmark:memorybench:install": "node scripts/install-memorybench-provider.js",
57
- "release": "npx semantic-release"
60
+ "release": "npx semantic-release",
61
+ "audit:ci": "npm audit --omit=dev --audit-level=high"
58
62
  },
59
63
  "repository": {
60
64
  "type": "git",
@@ -99,11 +103,12 @@
99
103
  "remotion": "^4.0.459",
100
104
  "sharp": "^0.34.5",
101
105
  "socket.io": "^4.8.1",
106
+ "stripe": "^22.2.1",
102
107
  "telegraf": "^4.16.3",
103
108
  "telnyx": "^5.51.0",
104
109
  "tesseract.js": "^7.0.0",
105
110
  "uuid": "^11.1.0",
106
- "ws": "^8.19.0"
111
+ "ws": "^8.21.0"
107
112
  },
108
113
  "overrides": {
109
114
  "axios": "^1.15.2",
@@ -113,7 +118,8 @@
113
118
  "protobufjs": "^7.5.5",
114
119
  "serialize-javascript": "^7.0.5",
115
120
  "undici": "^6.24.0",
116
- "webpackbar": "^7.0.0"
121
+ "webpackbar": "^7.0.0",
122
+ "ws": "^8.21.0"
117
123
  },
118
124
  "devDependencies": {
119
125
  "@docusaurus/core": "3.10.0",
@@ -113,12 +113,12 @@ async function setSignup(enabled) {
113
113
  }
114
114
 
115
115
  async function rotateApiKey(btn) {
116
- if (!confirm(
117
- 'Rotate admin API key?\n\n' +
118
- 'The current key will stop working immediately.\n' +
119
- 'Any scripts using it must be updated.\n\n' +
120
- 'The new key will be shown once — copy it before leaving.'
121
- )) return;
116
+ if (!await showConfirmModal({
117
+ title: 'Rotate admin API key?',
118
+ body: 'The current key will stop working immediately. Any scripts using it must be updated.<br><br>The new key will be shown once — copy it before leaving.',
119
+ confirmLabel: 'Rotate Key',
120
+ confirmClass: 'btn-danger',
121
+ })) return;
122
122
  btn.disabled = true;
123
123
  btn.textContent = 'Rotating…';
124
124
  try {
@@ -140,7 +140,12 @@ async function rotateApiKey(btn) {
140
140
  }
141
141
 
142
142
  async function revokeApiKey(btn) {
143
- if (!confirm('Revoke admin API key?\n\nAll programmatic access using this key will stop immediately.')) return;
143
+ if (!await showConfirmModal({
144
+ title: 'Revoke admin API key?',
145
+ body: 'All programmatic access using this key will stop immediately.',
146
+ confirmLabel: 'Revoke Key',
147
+ confirmClass: 'btn-danger',
148
+ })) return;
144
149
  btn.disabled = true;
145
150
  btn.textContent = 'Revoking…';
146
151
  _revealedKey = null;
@@ -318,3 +318,81 @@ button:disabled,
318
318
  border: 1px solid rgba(116, 192, 124, 0.25);
319
319
  color: var(--success);
320
320
  }
321
+
322
+ /* ── Form helpers (used in dynamic modals) ───────────────────────────────── */
323
+ .form-label {
324
+ display: block;
325
+ font-size: 11px;
326
+ font-weight: 600;
327
+ letter-spacing: 0.04em;
328
+ color: var(--text-muted);
329
+ text-transform: uppercase;
330
+ margin-bottom: 6px;
331
+ }
332
+
333
+ .form-label small {
334
+ font-size: 10px;
335
+ font-weight: 400;
336
+ text-transform: none;
337
+ letter-spacing: 0;
338
+ opacity: 0.75;
339
+ }
340
+
341
+ .form-input {
342
+ width: 100%;
343
+ background: var(--bg-input);
344
+ border: 1px solid var(--border-light);
345
+ border-radius: var(--radius-sm);
346
+ padding: 9px 12px;
347
+ color: var(--text);
348
+ font-family: var(--font-sans);
349
+ font-size: 13px;
350
+ outline: none;
351
+ transition: border-color 0.15s;
352
+ -webkit-appearance: none;
353
+ }
354
+
355
+ .form-input:focus {
356
+ border-color: var(--accent);
357
+ box-shadow: 0 0 0 3px var(--accent-muted);
358
+ }
359
+
360
+ .form-input:disabled {
361
+ opacity: 0.5;
362
+ cursor: default;
363
+ }
364
+
365
+ /* ── Small button variant ────────────────────────────────────────────────── */
366
+ .btn-sm {
367
+ padding: 5px 10px;
368
+ font-size: 12px;
369
+ }
370
+
371
+ /* ── Generic data table (billing, etc.) ──────────────────────────────────── */
372
+ .data-table {
373
+ width: 100%;
374
+ border-collapse: collapse;
375
+ font-size: 13px;
376
+ }
377
+
378
+ .data-table th {
379
+ text-align: left;
380
+ font-size: 10px;
381
+ font-weight: 600;
382
+ text-transform: uppercase;
383
+ letter-spacing: 0.07em;
384
+ color: var(--text-muted);
385
+ padding: 6px 10px;
386
+ border-bottom: 1px solid var(--border);
387
+ white-space: nowrap;
388
+ }
389
+
390
+ .data-table td {
391
+ padding: 10px 10px;
392
+ border-bottom: 1px solid var(--border);
393
+ color: var(--text-secondary);
394
+ vertical-align: middle;
395
+ }
396
+
397
+ .data-table tr:last-child td { border-bottom: none; }
398
+ .data-table tr:hover td { background: var(--row-hover); }