portal-mojo 0.2.21 → 0.2.23

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 (49) hide show
  1. package/docs/ModelTable.md +11 -0
  2. package/docs/admin-assistant.md +75 -23
  3. package/docs/admin-dns-domains.md +96 -90
  4. package/docs/admin-dns-records.md +1 -1
  5. package/docs/admin-dns-registrar.md +29 -4
  6. package/docs/admin-dns.md +2 -3
  7. package/docs/admin-modals.md +21 -0
  8. package/docs/menus-and-access.md +24 -0
  9. package/package.json +1 -1
  10. package/src/admin/assistant/AssistantFeed.tsx +7 -4
  11. package/src/admin/assistant/AssistantSetupPage.tsx +141 -64
  12. package/src/admin/assistant/api.ts +9 -7
  13. package/src/admin/assistant/pages.tsx +125 -109
  14. package/src/admin/assistant/setup.ts +1 -1
  15. package/src/admin/assistant/skill-lifecycle.ts +5 -4
  16. package/src/admin/core/index.ts +10 -2
  17. package/src/admin/dns/DnsRecordsPanel.tsx +45 -9
  18. package/src/admin/dns/DomainAssignment.tsx +55 -0
  19. package/src/admin/dns/DomainDetail.tsx +55 -38
  20. package/src/admin/dns/DomainOnboardingContent.tsx +9 -4
  21. package/src/admin/dns/DomainPurchaseDetail.tsx +42 -7
  22. package/src/admin/dns/DomainPurchaseWizard.tsx +102 -72
  23. package/src/admin/dns/DomainPurchasesPage.tsx +1 -1
  24. package/src/admin/dns/DomainRegisterExisting.tsx +21 -6
  25. package/src/admin/dns/DomainRegistrarSettings.tsx +28 -48
  26. package/src/admin/dns/DomainRegistrationPanel.tsx +127 -0
  27. package/src/admin/dns/DomainsPage.tsx +25 -24
  28. package/src/admin/dns/ProviderCredentialsPage.tsx +32 -18
  29. package/src/admin/dns/RegistrantContactPage.tsx +73 -27
  30. package/src/admin/dns/api.ts +26 -25
  31. package/src/admin/dns/domain-authority.ts +11 -0
  32. package/src/admin/dns/index.ts +2 -2
  33. package/src/admin/dns/purchase-data.ts +5 -3
  34. package/src/admin/dns/registrar-api.ts +57 -11
  35. package/src/admin/domains/infrastructure.ts +8 -8
  36. package/src/admin/domains/operations.ts +6 -6
  37. package/src/admin/edge/VhostsPage.tsx +6 -1
  38. package/src/admin/jobs/JobDashboardPage.tsx +14 -4
  39. package/src/client/mock.ts +14 -8
  40. package/src/client/params.ts +3 -2
  41. package/src/styles/components/admin-assistant.css +83 -20
  42. package/src/styles/components/admin-domains.css +67 -11
  43. package/src/styles/components/admin-edge.css +1 -0
  44. package/src/styles/components/admin-jobs.css +1 -0
  45. package/src/ui/FilterBar.tsx +16 -10
  46. package/src/ui/ModelTable.tsx +7 -3
  47. package/src/ui/SidebarNav.tsx +10 -1
  48. package/src/ui/menu-registry.ts +2 -0
  49. package/src/ui/preset-owned.ts +39 -0
@@ -6779,6 +6779,8 @@ let domainRegistrarSaveFault: 'warning' | 'ambiguous' | null = null;
6779
6779
  let dnsWriteFault: 'reject' | 'ambiguous' | 'reconcile' | null = null;
6780
6780
  let dnsFailNextRead = false;
6781
6781
  const mockRegistrantScopeState = new Map<string, 'saved' | 'cleared'>();
6782
+ const mockRegistrantConfigured = (group: number) => mockRegistrantScopeState.get(group > 0 ? `group:${group}` : 'house') === 'saved'
6783
+ || dnsRegistrarMode !== 'contact-missing' && (group === 0 || group % 2 === 1);
6782
6784
 
6783
6785
  export type MockUploadMode = 'raw-put' | 'raw-put-unprefixed' | 'config-put' | 'config-put-bearer' | 'config-post';
6784
6786
  export type MockUploadFault = 'initiate-ambiguous' | 'transfer-ambiguous' | 'transfer-failed' | 'transfer-expired' | 'complete-reject' | 'complete-ambiguous' | 'complete-body-malformed';
@@ -6903,7 +6905,7 @@ export function setMockDnsAcmeMode(value: 'production' | 'staging' | 'unconfigur
6903
6905
  export function setMockDnsRegistrarMode(value: 'ready' | 'disabled' | 'contact-missing'): void { dnsRegistrarMode = value; }
6904
6906
 
6905
6907
  /** Verifier-only one-shot result after a confirmation token is consumed. */
6906
- export function armMockRegistrarPurchaseFault(value: 'failed' | 'ambiguous'): void { dnsRegistrarFault = value; }
6908
+ export function armMockRegistrarPurchaseFault(value: 'failed' | 'ambiguous' | null): void { dnsRegistrarFault = value; }
6907
6909
  export function armMockDomainRegistrarSaveFault(value: 'warning' | 'ambiguous'): void { domainRegistrarSaveFault = value; }
6908
6910
 
6909
6911
  /** Verifier-only one-shot DNS transport outcomes; all state still lives in db.dnsRecords. */
@@ -9767,7 +9769,7 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
9767
9769
  const caller = userFromBearer(opts.headers);
9768
9770
  if (!caller) return permissionDenied(401);
9769
9771
  const assistantGate = hasGlobalPermission(caller, ['view_admin', 'assistant']);
9770
- const canInspect = (row: MockAssistantConversation) => row.user === caller.id || assistantGate;
9772
+ const canInspect = (row: MockAssistantConversation) => row.user === caller.id || hasGlobalPermission(caller, ['view_admin']);
9771
9773
  const canDelete = (row: MockAssistantConversation) => row.user === caller.id || hasGlobalPermission(caller, ['view_admin']);
9772
9774
  const assistantUser = (userId: number) => { const row = db.users.find((user) => user.id === userId); return row ? { id: row.id, display_name: row.display_name } : { id: userId, display_name: `User #${userId}` }; };
9773
9775
  const conversationWire = (row: MockAssistantConversation, detail = false) => ({ id: row.id, title: row.title, metadata: { ...row.metadata }, created: row.created, modified: row.modified, user: assistantUser(row.user), ...(detail ? { messages: row.messages.map((message) => ({ ...message, tool_calls: [...message.tool_calls], blocks: message.blocks == null ? null : structuredClone(message.blocks), usage: { ...message.usage } })) } : {}) });
@@ -9826,14 +9828,17 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
9826
9828
  const id = conversationMatch[1] ? Number(conversationMatch[1]) : null;
9827
9829
  if (id != null) { const row = db.assistantConversations.find((candidate) => candidate.id === id); if (!row || !canInspect(row)) return { status: false, error: 'Conversation not found', error_code: 404 }; if (method === 'DELETE') { if (!canDelete(row)) return permissionDenied(); db.assistantConversations = db.assistantConversations.filter((candidate) => candidate.id !== id); return { status: 'deleted' }; } if (method !== 'GET') return { status: false, error: 'Method not allowed', error_code: 405 }; return { status: true, data: conversationWire(row, opts.params?.graph === 'detail'), graph: String(opts.params?.graph ?? 'default') }; }
9828
9830
  if (method !== 'GET') return { status: false, error: 'Method not allowed', error_code: 405 };
9829
- const visible = db.assistantConversations.filter(canInspect).sort((a, b) => b.modified - a.modified); const start = Math.max(0, Number(opts.params?.start ?? 0)); const size = Math.min(100, Math.max(1, Number(opts.params?.size ?? 100))); return { status: true, data: visible.slice(start, start + size).map((row) => conversationWire(row)), count: visible.length, start, size, graph: 'default' };
9831
+ const params = { ...opts.params, start: Math.max(0, Number(opts.params?.start ?? 0)), size: Math.min(100, Math.max(1, Number(opts.params?.size ?? 100))) };
9832
+ return { ...listRows(db.assistantConversations.filter(canInspect).map(row => conversationWire(row)), params, row => row.title, '-modified'), graph: 'default' };
9830
9833
  }
9831
9834
  const skillMatch = path.match(/^\/api\/assistant\/skill(?:\/(\d+))?$/);
9832
9835
  if (skillMatch) {
9833
9836
  if (!assistantGate) return permissionDenied(); const id = skillMatch[1] ? Number(skillMatch[1]) : null;
9834
9837
  const skillWire = (row: MockAssistantSkill, detail = false) => ({ id: row.id, tier: row.tier, name: row.name, description: row.description, auto_execute: row.auto_execute, is_active: row.is_active, created: row.created, modified: row.modified, user: row.user == null ? null : assistantUser(row.user), ...(detail ? { triggers: [...row.triggers], steps: structuredClone(row.steps), metadata: { ...row.metadata } } : {}) });
9835
9838
  if (id != null) { const row = db.assistantSkills.find((candidate) => candidate.id === id); if (!row) return { status: false, error: 'Skill not found', error_code: 404 }; if (method === 'DELETE') { db.assistantSkills = db.assistantSkills.filter((candidate) => candidate.id !== id); return { status: 'deleted' }; } if (method === 'POST') { if (!hasGlobalPermission(caller, ['view_admin'])) return permissionDenied(); if (typeof opts.body?.is_active !== 'boolean') return { status: false, error: 'is_active is required', error_code: 400 }; row.is_active = opts.body.is_active; row.modified = Math.floor(Date.now() / 1000); } else if (method !== 'GET') return { status: false, error: 'Method not allowed', error_code: 405 }; return { status: true, data: skillWire(row, opts.params?.graph === 'detail'), graph: String(opts.params?.graph ?? 'default') }; }
9836
- if (method !== 'GET') return { status: false, error: 'Method not allowed', error_code: 405 }; const rows = [...db.assistantSkills].sort((a, b) => a.name.localeCompare(b.name)); return { status: true, data: rows.map((row) => skillWire(row)), count: rows.length, start: 0, size: rows.length, graph: 'default' };
9839
+ if (method !== 'GET') return { status: false, error: 'Method not allowed', error_code: 405 };
9840
+ const params = { ...opts.params, start: Math.max(0, Number(opts.params?.start ?? 0)), size: Math.min(100, Math.max(1, Number(opts.params?.size ?? 100))) };
9841
+ return { ...listRows(db.assistantSkills.map(row => skillWire(row)), params, row => row.name, 'name'), graph: 'default' };
9837
9842
  }
9838
9843
  const memoryMatch = path.match(/^\/api\/assistant\/memory\/(global|user|group)(?:\/([^/]+))?$/);
9839
9844
  if (memoryMatch) {
@@ -9846,7 +9851,7 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
9846
9851
  }
9847
9852
  const store: Record<string, unknown> = tier === 'global' ? db.assistantMemory.global : tier === 'user' ? (db.assistantMemory.users.get(caller.id) ?? {}) : (db.assistantMemory.groups.get(groupId!) ?? {});
9848
9853
  if (method === 'GET' && entryKey == null) return { status: true, data: { ...store } };
9849
- if (method === 'POST' && entryKey == null) { const key = typeof opts.body?.key === 'string' ? opts.body.key : ''; const value = typeof opts.body?.value === 'string' ? opts.body.value : ''; if (!/^[a-z0-9:_-]{1,64}$/.test(key) || value.length < 1 || value.length > 500) return { status: false, error: 'Invalid memory key or value', error_code: 400 }; store[key] = value; if (tier === 'user') db.assistantMemory.users.set(caller.id, store); if (tier === 'group') db.assistantMemory.groups.set(groupId!, store); return { status: true, data: { key, value } }; }
9854
+ if (method === 'POST' && entryKey == null) { const key = typeof opts.body?.key === 'string' ? opts.body.key : ''; const value = typeof opts.body?.value === 'string' ? opts.body.value : ''; if (!/^[a-z0-9][a-z0-9:_-]{0,63}$/.test(key) || value.length < 1 || value.length > 500) return { status: false, error: 'Invalid memory key or value', error_code: 400 }; store[key] = value; if (tier === 'user') db.assistantMemory.users.set(caller.id, store); if (tier === 'group') db.assistantMemory.groups.set(groupId!, store); return { status: true, data: { key, value } }; }
9850
9855
  if (method === 'DELETE' && entryKey != null) { delete store[entryKey]; return { status: true, data: { deleted: entryKey } }; }
9851
9856
  return { status: false, error: 'Method not allowed', error_code: 405 };
9852
9857
  }
@@ -10339,7 +10344,7 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
10339
10344
  status: true,
10340
10345
  data: {
10341
10346
  purchase_enabled: dnsRegistrarMode !== 'disabled',
10342
- registrant_contact_configured: dnsRegistrarMode !== 'contact-missing' && (groupId === 0 || groupId % 2 === 1),
10347
+ registrant_contact_configured: mockRegistrantConfigured(groupId),
10343
10348
  max_domain_price: '100.00', currency: 'USD', quote_ttl_minutes: 15,
10344
10349
  allowed_record_types: ['A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'SRV', 'TXT'],
10345
10350
  search_batch_limit: 20, suggestions_enabled: true,
@@ -10740,7 +10745,7 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
10740
10745
  const group = db.groups.find((candidate) => candidate.id === groupId && candidate.is_active);
10741
10746
  if (!group || !body.domain) return missingParams('group', 'domain');
10742
10747
  if (dnsRegistrarMode === 'disabled') return { status: false, error: 'Domain purchasing is disabled on this deployment (DNSMAN_PURCHASE_ENABLED is off).', error_code: 400 };
10743
- if (dnsRegistrarMode === 'contact-missing' || groupId % 2 === 0) return { status: false, error: 'No registrant contact is configured', error_code: 400 };
10748
+ if (!mockRegistrantConfigured(groupId)) return { status: false, error: 'No registrant contact is configured', error_code: 400 };
10744
10749
  const answer = resultRow(String(body.domain));
10745
10750
  if (answer.available !== true || !answer.tld_supported) return { status: false, error: 'The domain cannot be quoted', error_code: 400 };
10746
10751
  const years = Number(body.years ?? 1);
@@ -10841,7 +10846,7 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
10841
10846
  const direct = state === 'saved' || (state == null && groupId === 1);
10842
10847
  const settingsFile = state == null && groupId === 0;
10843
10848
  const contact = direct || settingsFile ? canonical : null;
10844
- return { status: true, data: { scope: groupId > 0 ? 'group' : 'global', group: groupId || null, contact, source: direct ? 'database' : settingsFile ? 'settings_file' : 'none', inherited: groupId > 0 && !contact && groupId % 2 === 0, effective_configured: dnsRegistrarMode !== 'contact-missing' && (Boolean(contact) || groupId % 2 === 1 || groupId === 0), problems: [] } };
10849
+ return { status: true, data: { scope: groupId > 0 ? 'group' : 'global', group: groupId || null, contact, source: direct ? 'database' : settingsFile ? 'settings_file' : 'none', inherited: groupId > 0 && !contact && groupId % 2 === 0, effective_configured: mockRegistrantConfigured(groupId), problems: [] } };
10845
10850
  }
10846
10851
 
10847
10852
  if (path === '/api/dnsman/whois' || path === '/api/dnsman/whois/privacy') {
@@ -10852,6 +10857,7 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
10852
10857
  if (!domain) return { status: false, error: 'Domain not found', error_code: 404 };
10853
10858
  if (domain.provider !== 'route53') return { status: false, error: 'Registrar operations are not available for this provider', error_code: 400 };
10854
10859
  if (domain.group == null ? !caller.is_superuser : !dnsMemberCan(caller, domain.group, true)) return permissionDenied();
10860
+ if (domain.registered_on == null) return { status: false, error: 'This hosted zone has no registration in the Route 53 Domains account. Check its registrar.', error_code: 400 };
10855
10861
  if (path.endsWith('/privacy')) {
10856
10862
  domain.privacy = Boolean(input.enabled); domain.modified = Math.floor(Date.now() / 1000);
10857
10863
  return { status: true, data: { name: domain.name, privacy: domain.privacy, operation_id: `op-privacy-${domain.id}` } };
@@ -41,7 +41,8 @@ export interface TableParamsApi extends TableParams {
41
41
  removeFilter(f: ActiveFilter): void;
42
42
  applyPreset(params: Record<string, string>): void;
43
43
  presetActive(params: Record<string, string>): boolean;
44
- clearFilters(): void;
44
+ /** Clears search and filters; `keep` survives (the selected preset's own keys). */
45
+ clearFilters(keep?: Readonly<Record<string, string>>): void;
45
46
  setPage(page: number): void;
46
47
  setSize(size: number): void;
47
48
  /**
@@ -295,7 +296,7 @@ export function useTableParams(
295
296
  if (keys.length === 0) return Object.keys(filters).length === 0;
296
297
  return keys.every((k) => filters[k] === params[k]);
297
298
  },
298
- clearFilters: () => write({ ...state, search: '', page: 1 }, {}),
299
+ clearFilters: (keep) => write({ ...state, search: '', page: 1 }, { ...keep }),
299
300
  setPage: (page) => write({ ...state, page }, filters),
300
301
  setSize: (size) => write({ ...state, size, page: 1 }, filters),
301
302
  applySaved: (saved) => {
@@ -26,25 +26,88 @@
26
26
  .assistant-inspect > header, .assistant-skill-detail > header { display: flex; justify-content: space-between; gap: 12px; }.assistant-inspect > .record-feed { flex: 1; min-height: 0; }
27
27
  .assistant-memory-controls { display: grid; gap: 12px; margin-bottom: 12px; }.assistant-memory-form { display: grid; grid-template-columns: minmax(180px, .6fr) minmax(260px, 1fr) auto; gap: 10px; align-items: end; }
28
28
  @media (max-width: 760px) { .assistant-memory-form { grid-template-columns: 1fr; } }
29
- .assistant-setup { display: grid; gap: 16px; max-width: 1100px; min-width: 0; margin: 0 auto; width: 100%; }
30
- .assistant-setup h1, .assistant-setup h2, .assistant-setup h3 { margin: 0 0 10px; }
31
- .assistant-setup p { overflow-wrap: anywhere; }
32
- .assistant-setup-header, .assistant-setup-switch { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 14px; }
33
- .assistant-setup-switch { padding-top: 20px; margin-top: 18px; border-top: 1px solid var(--line); }
34
- .assistant-setup-switch > span { flex: 1; min-width: 200px; }
35
- .assistant-setup-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; margin: 18px 0; }
29
+ .assistant-setup { display: grid; min-width: 0; margin: 0 auto; width: 100%; }
30
+ .assistant-setup h1 { font-size: clamp(24px, 3vw, 32px); font-weight: 700; line-height: 1.2; margin: 0; }
31
+ .assistant-setup-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 14px; }
32
+ .assistant-setup-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 14px; margin: 18px 0; }
36
33
  .assistant-setup-facts dt { color: var(--mute); font-size: 12px; }.assistant-setup-facts dd { margin: 5px 0 0; overflow-wrap: anywhere; }
37
- .assistant-setup-field { display: grid; gap: 7px; margin: 16px 0; max-width: 650px; }.assistant-setup-field input { width: 100%; min-width: 0; }
38
- .assistant-setup-key-status { padding-top: 10px; }.assistant-setup-warning { border: 1px solid var(--warn); border-radius: 8px; padding: 12px; }
39
- .assistant-setup-advanced { min-width: 0; }.assistant-setup-advanced > summary { padding: 17px; cursor: pointer; font-weight: 600; }.assistant-setup-advanced[open] > summary { border-bottom: 1px solid var(--line); }
40
- .assistant-setup .assistant-table-scroll { max-width: 100%; margin: 14px 0; }.assistant-setup table { border-collapse: collapse; width: 100%; }.assistant-setup th, .assistant-setup td { padding: 9px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--line); }.assistant-setup caption { text-align: left; color: var(--mute); margin-bottom: 8px; }
41
- .assistant-setup button { white-space: normal; }.assistant-setup-confirm { border-color: var(--warn); }
42
- @media (max-width: 600px) { .assistant-setup-header > button { width: 100%; }.assistant-setup-facts { grid-template-columns: 1fr; }.assistant-setup .assistant-action-row { align-items: stretch; flex-direction: column; } }
43
- .assistant-setup h1 { font-size: clamp(24px, 3vw, 32px); font-weight: 700; line-height: 1.2; }
44
- .assistant-setup h2 { font-size: 21px; font-weight: 650; line-height: 1.3; }.assistant-setup h3 { font-size: 16px; font-weight: 650; margin-top: 22px; }
34
+ .assistant-setup-field > span { display: flex; align-items: center; gap: 6px; }
35
+ .assistant-setup-field { display: grid; gap: 8px; margin: 20px 0; }.assistant-setup-field input, .assistant-setup-field select { width: 100%; min-width: 0; }
36
+ /* Setup is a compact overview. Task dialogs own deeper configuration. */
37
+ .assistant-setup { max-width: 960px; gap: 20px; }
38
+ .assistant-setup-header p { margin: 7px 0 0; }
39
+ .assistant-setup .assistant-setup-overview { padding: 24px; }
40
+ .assistant-setup-status, .assistant-setup-status > div { display: flex; align-items: center; gap: 10px; }
41
+ .assistant-setup-status { justify-content: space-between; flex-wrap: wrap; gap: 16px; padding-bottom: 22px; }
42
+ .assistant-setup-status h2 { font-weight: 600; margin: 0; font-size: 20px; }
43
+ .assistant-setup-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--mute); flex: 0 0 auto; }
44
+ .assistant-setup-dot.is-ready { background: var(--ok); }.assistant-setup-dot.is-stopped { background: var(--warn); }
45
+ .assistant-setup-row { display: flex; align-items: center; gap: 18px; padding: 20px 0; border-top: 1px solid var(--line); min-width: 0; }
46
+ .assistant-setup-row > div { flex: 1; min-width: 0; }.assistant-setup-row > .btn { flex-shrink: 0; }
47
+ .assistant-setup-row h3 { font-weight: 600; font-size: 15px; margin: 0; display: flex; align-items: center; gap: 6px; }
48
+ .assistant-setup-row p { color: var(--mute); margin: 5px 0; font-size: 14px; }
49
+ .assistant-setup-row code { font-size: 12px; overflow-wrap: anywhere; }
50
+ .assistant-setup-tools { padding: 0 24px; }.assistant-setup-tools > .assistant-setup-row:first-child { border-top: 0; }
51
+ .assistant-setup-row > i { color: var(--accent); font-size: 19px; flex-shrink: 0; }
52
+ .assistant-setup-help { display: inline-flex; vertical-align: middle; justify-content: center; align-items: center; border: 0; border-radius: 50%; width: 28px; height: 28px; padding: 0; color: var(--mute); background: transparent; cursor: pointer; flex: 0 0 auto; }
53
+ .assistant-setup-help:hover { color: var(--accent); background: var(--surface2); }.assistant-setup-help:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
54
+ .assistant-setup-help-content { width: min(340px, calc(100vw - 32px)); max-height: min(500px, 75dvh); overflow: auto; padding: 16px; font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; }
55
+ .assistant-setup-help-content p { margin: 0 0 10px; }.assistant-setup-help-content p:last-child { margin-bottom: 0; }
56
+ .assistant-setup-help-content .assistant-setup-facts { grid-template-columns: 1fr; margin: 0; }
57
+ .assistant-setup-dialog { margin: auto; width: min(680px, calc(100vw - 32px)); max-width: none; max-height: calc(100dvh - 32px); padding: 0; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); color: var(--ink); box-shadow: 0 24px 80px #0004; overflow: hidden; }
58
+ .assistant-setup-dialog[open] { display: flex; flex-direction: column; }.assistant-setup-dialog::backdrop { background: #0007; }
59
+ .assistant-setup-dialog > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 22px 24px 18px; border-bottom: 1px solid var(--line); flex-shrink: 0; }
60
+ .assistant-setup-dialog h2 { margin: 0; font-size: 22px; line-height: 1.3; font-weight: 650; }.assistant-setup-dialog h3 { margin: 24px 0 10px; font-size: 16px; font-weight: 650; }.assistant-setup-dialog-content > h3:first-child { margin-top: 0; }
61
+ .assistant-setup-dialog-content { overflow: auto; padding: 24px; min-height: 0; overscroll-behavior: contain; }
62
+ .assistant-setup-dialog-content > :first-child { margin-top: 0; }.assistant-setup-dialog-content > :last-child { margin-bottom: 0; }
63
+ .assistant-setup-dialog p { margin: 0 0 12px; line-height: 1.5; overflow-wrap: anywhere; }.assistant-setup-dialog code { overflow-wrap: anywhere; }
64
+ .assistant-setup-dialog > footer { display: flex; justify-content: flex-end; padding: 16px 24px; border-top: 1px solid var(--line); flex-shrink: 0; }
65
+ .assistant-setup-dialog .assistant-action-row { flex-wrap: wrap; align-items: center; gap: 10px; }
66
+ .assistant-setup-dialog .assistant-setup-row:first-child { border-top: 0; padding-top: 0; }
67
+ .assistant-setup-dialog .assistant-setup-row h3 { margin: 0; }.assistant-setup-dialog .assistant-setup-field { max-width: none; }
68
+ .assistant-setup-dialog .assistant-setup-key-status { padding-top: 6px; }.assistant-setup-key-status p { margin: 6px 0; }
69
+ .assistant-setup-current { display: grid; gap: 8px; background: var(--surface2); padding: 14px; border-radius: 8px; }.assistant-setup-current > span { font-size: 12px; color: var(--mute); }
70
+ .assistant-setup-catalogue { margin-top: 18px; display: flex; gap: 6px; align-items: center; font-size: 13px; }
71
+ .assistant-setup-danger-row { border-top: 1px solid var(--line); margin-top: 24px; padding-top: 18px; }
72
+ .assistant-setup-warning { border: 1px solid var(--warn); border-radius: 8px; padding: 12px; font-size: 14px; line-height: 1.5; }
73
+ .assistant-setup-notice { padding: 12px; background: var(--surface2); border-radius: 8px; font-size: 14px; }
74
+ .assistant-setup-text-button { background: none; color: var(--accent); border: 0; padding: 0; cursor: pointer; font: inherit; text-decoration: underline; }
75
+ .assistant-setup-dialog .assistant-table-scroll { overflow-x: auto; max-width: 100%; margin: 14px 0; }.assistant-setup-dialog table { border-collapse: collapse; width: 100%; font-size: 13px; }.assistant-setup-dialog th, .assistant-setup-dialog td { padding: 10px; text-align: left; border-bottom: 1px solid var(--line); }.assistant-setup-dialog caption { text-align: left; color: var(--mute); margin-bottom: 8px; }
76
+ .assistant-setup-dialog-confirm { width: min(460px, calc(100vw - 32px)); }
77
+ @media (max-width: 600px) {
78
+ .assistant-setup { gap: 16px; }.assistant-setup .assistant-setup-overview { padding: 18px; }.assistant-setup-tools { padding: 0 18px; }
79
+ .assistant-setup-row { flex-wrap: wrap; gap: 12px; }.assistant-setup-row > div { flex-basis: calc(100% - 40px); }.assistant-setup-row > .btn { margin-left: auto; }
80
+ .assistant-setup-status > .btn { width: 100%; }.assistant-setup-dialog > header { padding: 18px; }.assistant-setup-dialog-content { padding: 18px; }.assistant-setup-dialog > footer { padding: 14px 18px; }
81
+ .assistant-setup-dialog .assistant-action-row { flex-direction: column; align-items: stretch; }.assistant-setup-dialog .assistant-setup-facts { grid-template-columns: 1fr; }.assistant-setup-dialog button { white-space: normal; }
82
+ }
83
+ .assistant-setup-area { display: grid; gap: 20px; }
84
+ .assistant-setup-mcp h2 { margin: 0 0 8px; font-size: 20px; font-weight: 650; }.assistant-setup-mcp > h3 { margin: 24px 0 12px; font-size: 16px; font-weight: 600; }
85
+ .assistant-setup-mcp > p { margin: 0 0 16px; }.assistant-setup-mcp .assistant-setup-field { max-width: 680px; }
86
+ .assistant-setup-mcp .assistant-action-row { gap: 12px; }.assistant-setup-mcp .assistant-setup-row:first-of-type { margin-top: 22px; }
87
+ @media (max-width: 600px) { .assistant-setup-mcp.panel-pad { padding: 18px; }.assistant-setup-mcp .assistant-action-row { flex-direction: column; align-items: stretch; } }
45
88
 
46
- .assistant-setup-model { min-width: 0; margin: 20px 0; padding: 0 0 20px; border-bottom: 1px solid var(--line); }
47
- .assistant-setup-field select { width: 100%; min-width: 0; }
48
- .assistant-setup-catalogue { margin-top: 16px; padding: 12px; border-radius: 8px; background: var(--surface2); overflow-wrap: anywhere; }
49
- .assistant-setup-model code { overflow-wrap: anywhere; }
50
- @media (max-width: 600px) { .assistant-setup-model .assistant-setup-field { max-width: 100%; } }
89
+ .assistant-setup-address { display: flex; align-items: center; gap: 10px; }.assistant-setup-address input { flex: 1; }.assistant-setup-address button { flex-shrink: 0; }
90
+ @media (max-width: 600px) { .assistant-setup-address { flex-direction: column; align-items: stretch; } }
91
+
92
+ /* Assistant management pages: one task per page, focused record dialogs. */
93
+ .assistant-admin-page { width: 100%; min-width: 0; display: grid; gap: 20px; max-width: 1120px; margin: 0 auto; }
94
+ .assistant-page-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
95
+ .assistant-page-heading .eyebrow { display: block; margin-bottom: 8px; color: var(--mute); font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
96
+ .assistant-page-heading h1 { margin: 0; font-size: 28px; line-height: 1.25; font-weight: 700; display: flex; align-items: center; gap: 10px; }
97
+ .assistant-page-actions { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
98
+ .assistant-page-help { display: inline-flex; align-items: center; justify-content: center; vertical-align: middle; width: 28px; height: 28px; padding: 0; border: 0; background: transparent; color: var(--mute); cursor: pointer; border-radius: 6px; font-size: 15px; flex-shrink: 0; }
99
+ .assistant-page-help:hover { color: var(--ink); background: var(--surface2); }.assistant-page-help:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
100
+ .assistant-page-help-content { width: min(340px, calc(100vw - 24px)); padding: 16px; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }.assistant-page-help-content p { margin: 0 0 10px; }.assistant-page-help-content p:last-child { margin: 0; }
101
+ .assistant-page-search { position: relative; max-width: 380px; }.assistant-page-search > i { position: absolute; left: 12px; top: 12px; color: var(--mute); }.assistant-page-search .input { padding-left: 36px; width: 100%; }
102
+ .assistant-page-list { overflow: hidden; min-width: 0; }.assistant-page-list table { width: 100%; border-collapse: collapse; table-layout: fixed; }.assistant-page-list th, .assistant-page-list td { padding: 16px 20px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: middle; overflow-wrap: anywhere; }.assistant-page-list th { background: var(--surface2); color: var(--mute); font-size: 11px; text-transform: uppercase; letter-spacing: .07em; font-weight: 700; }.assistant-page-list th:first-child { width: 55%; }.assistant-page-list tr:last-child td { border-bottom: 0; }.assistant-page-list td small { display: block; color: var(--mute); font-size: 12px; margin-top: 5px; line-height: 1.5; }.assistant-page-list .link-button { text-align: left; font-weight: 600; overflow-wrap: anywhere; }.assistant-page-list td .badge { margin-left: 6px; }
103
+ .assistant-page-empty { padding: 32px 24px; margin: 0; text-align: center; color: var(--mute); }.assistant-page-empty p { margin: 0 0 8px; }.assistant-page-empty .btn { margin-top: 12px; }
104
+ .assistant-page-pager { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--line); }.assistant-page-pager span { margin-right: auto; color: var(--mute); font-size: 12px; }
105
+ .assistant-page-dialog { width: min(640px, calc(100vw - 32px)); max-width: none; max-height: calc(100dvh - 40px); padding: 0; margin: auto; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); color: var(--ink); box-shadow: var(--shadow-pop); overflow: hidden; }
106
+ .assistant-page-dialog[open] { display: flex; flex-direction: column; }.assistant-page-dialog::backdrop { background: rgb(0 0 0 / .55); backdrop-filter: blur(3px); }.assistant-page-dialog > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 20px 24px; border-bottom: 1px solid var(--line); flex: none; }.assistant-page-dialog h2 { margin: 0; font-size: 20px; font-weight: 700; line-height: 1.4; overflow-wrap: anywhere; }.assistant-page-dialog-body { min-height: 0; overflow-y: auto; padding: 24px; }.assistant-page-dialog-body > .field { margin-top: 20px; }.assistant-page-dialog-body > p:first-child { margin-top: 0; }.assistant-page-dialog > footer { display: flex; justify-content: flex-end; gap: 10px; flex-wrap: wrap; border-top: 1px solid var(--line); padding: 16px 24px; flex: none; }.assistant-page-meta { color: var(--mute); margin: 0 0 18px; font-size: 12px; line-height: 1.6; }.assistant-page-readonly { padding: 10px 12px; background: var(--surface2); color: var(--mute); border-radius: 8px; font-size: 12px; line-height: 1.6; margin: 0 0 16px; }
107
+ .assistant-page-dialog-conversation { width: min(840px, calc(100vw - 32px)); height: min(760px, calc(100dvh - 40px)); }.assistant-page-dialog-conversation .assistant-page-dialog-body { display: flex; flex-direction: column; overflow: hidden; flex: 1; padding: 16px 20px; }.assistant-page-dialog-conversation .record-feed { flex: 1; min-height: 0; }.assistant-page-dialog-conversation .record-feed-scroll { min-height: 0; }
108
+ .assistant-page-chips { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }.assistant-skill-content { display: grid; gap: 22px; }.assistant-skill-description { line-height: 1.6; margin: 0; color: var(--mute); }.assistant-skill-content h3 { margin: 0 0 12px; font-size: 14px; font-weight: 700; }.assistant-skill-triggers { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; padding: 0; margin: 0; }.assistant-skill-triggers li { padding: 5px 10px; border: 1px solid var(--line); border-radius: 6px; font-size: 12px; }.assistant-skill-steps { margin: 0; padding-left: 22px; list-style: decimal; }.assistant-skill-steps li { padding: 10px 0 10px 6px; border-bottom: 1px solid var(--line); line-height: 1.6; }.assistant-skill-steps li:last-child { border: 0; }.assistant-skill-steps small { display: block; color: var(--mute); }.assistant-page-warning { color: var(--warn); font-size: 13px; line-height: 1.6; margin: 0; }.assistant-page-notice { color: var(--ok); font-size: 13px; margin: 0 0 12px; }
109
+ .assistant-memory-area { padding-top: 20px; }.assistant-memory-group { max-width: 380px; margin-bottom: 20px; }.assistant-memory-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }.assistant-memory-toolbar > span { color: var(--mute); font-size: 13px; }.assistant-memory-list { list-style: none; padding: 0; margin: 0; }.assistant-memory-list > li { display: flex; justify-content: space-between; gap: 20px; padding: 20px; border-bottom: 1px solid var(--line); }.assistant-memory-list > li:last-child { border: 0; }.assistant-memory-list > li > div:first-child { min-width: 0; }.assistant-memory-list strong { font-size: 13px; overflow-wrap: anywhere; }.assistant-memory-list p { color: var(--mute); margin: 8px 0 0; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
110
+ @media (max-width:600px) { .assistant-admin-page { gap: 16px; }.assistant-page-heading h1 { font-size: 24px; }.assistant-page-refresh { min-width: 38px; justify-content: center; }.assistant-page-refresh > span { display: none; }.assistant-page-list th, .assistant-page-list td { padding: 12px; font-size: 12px; }.assistant-page-list th:first-child { width: 44%; }.assistant-page-list th { font-size: 10px; letter-spacing: .04em; padding: 12px 10px; white-space: nowrap; }.assistant-page-list td { padding: 12px 10px; }.assistant-page-list td small { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }.assistant-page-dialog { width: calc(100vw - 20px); max-height: calc(100dvh - 24px); }.assistant-page-dialog > header { padding: 16px; }.assistant-page-dialog-body { padding: 18px 16px; }.assistant-page-dialog > footer { padding: 14px 16px; }.assistant-page-dialog-conversation { height: calc(100dvh - 24px); }.assistant-page-dialog-conversation .assistant-page-dialog-body { padding: 12px; }.assistant-memory-toolbar { align-items: flex-start; flex-direction: column; gap: 10px; }.assistant-memory-toolbar > .assistant-page-actions { width: 100%; justify-content: flex-end; }.assistant-memory-list > li { padding: 16px; gap: 8px; } }
111
+
112
+ .assistant-page-dialog-history { height: fit-content; }.assistant-page-dialog-history .record-feed { flex: initial; height: auto; min-height: 0; max-height: calc(100dvh - 280px); }
113
+ .assistant-page-dialog-conversation .attachment-drop { min-block-size: 36px; inline-size: auto; padding: 7px 10px; border-style: solid; }
@@ -1,12 +1,68 @@
1
- .domains-header,.domains-actions,.domains-overview-actions{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}.domains-header{margin-bottom:16px}.domains-actions,.domains-overview-actions{justify-content:flex-start}.domains-header p{margin:0;max-width:60ch}.domains-scope{overflow-wrap:anywhere}.domains-advanced{margin-top:16px;padding:16px;border:1px solid var(--line);border-radius:8px;background:var(--surface)}.domains-advanced summary{font-weight:650;cursor:pointer}.domains-overview-actions{margin-bottom:18px}.domains-registrar-settings{display:grid;gap:14px}.domains-registrar-settings label{display:flex;gap:10px;align-items:center}.domains-demo{display:grid;gap:16px;min-width:0}.domains-demo .seg{flex-wrap:wrap}@media(max-width:560px){.domains-header{align-items:flex-start;flex-direction:column}.domains-actions{width:100%}.domains-actions .btn{flex:1 1 auto;white-space:normal}}
1
+ .domains-page,.domains-page>* { min-width:0; }
2
+ .domains-header,.domains-actions,.domains-overview-actions,.domains-detail-header,.domains-detail-subtitle,.domains-task-help { display:flex;align-items:center;gap:10px; }
3
+ .domains-header { justify-content:space-between;flex-wrap:wrap;margin-bottom:24px;gap:16px; }
4
+ .domains-header h1 { margin:4px 0 8px;font-size:26px;line-height:1.2;font-weight:750;letter-spacing:-.025em;color:var(--ink-strong); }
5
+ .domains-header p { margin:0; }
6
+ .domains-actions,.domains-overview-actions { flex-wrap:wrap; }
7
+ .domains-action-menu { display:grid;gap:4px;padding:6px; }
8
+ .domains-action-menu .btn { justify-content:flex-start; }
9
+ .domains-page .toolbar-heading .panel-title { font-size:19px; }
10
+ .domains-scope,.domains-detail code { overflow-wrap:anywhere; }
11
+ .domains-narrow-scope,.domains-narrow-status { display:none; }
12
+ .domains-detail-header { justify-content:space-between;align-items:flex-start;padding:22px 24px 18px; }
13
+ .domains-detail-header>div { min-width:0; }
14
+ .domains-detail-header h2 { font-size:21px;overflow-wrap:anywhere;margin:0 0 8px; }
15
+ .domains-detail-subtitle { color:var(--mute);flex-wrap:wrap; }
16
+ .domains-detail>.mojo-tabs>.mojo-tab-list { padding:0 24px; }
17
+ .domains-detail-panel { padding:20px 24px;min-width:0; }
18
+ .domains-detail .flat-row { gap:12px; }
19
+ .domains-detail .flat-value { overflow-wrap:anywhere; }
20
+ .domains-overview-actions { margin-top:22px; }
21
+ .domains-assignment,.domains-registrar-settings { display:grid;gap:16px; }
22
+ .domains-assignment>.dim { margin:0; }
23
+ .domains-inline-action { justify-self:start; }
24
+ .domains-demo { display:grid;gap:16px;min-width:0;grid-template-columns:minmax(0,1fr); }
25
+ .domains-demo>* { min-width:0; }
26
+ .domains-demo .seg { flex-wrap:wrap; }
27
+ .domain-onboarding>.dim { margin:4px 0 20px; }
28
+ .domain-onboarding .dns-registrar-grid { gap:18px; }
29
+ .domain-onboarding .dns-onboarding-options { grid-template-columns:1fr;gap:10px;margin-top:20px; }
30
+ .domain-onboarding .dns-onboarding-options>button { grid-template-columns:26px 1fr;column-gap:14px;row-gap:3px;padding:16px; }
31
+ .domain-onboarding .dns-onboarding-options>button>i { grid-row:1/3;font-size:22px;align-self:center; }
32
+ .domain-onboarding .dns-onboarding-options>button>span { grid-column:2; }
33
+ @media(max-width:560px) {
34
+ .domains-header { align-items:flex-start; }
35
+ .domains-actions { width:100%; }
36
+ .domains-actions .btn-primary { margin-left:auto; }
37
+ .domains-detail-header { padding:18px 16px; }
38
+ .domains-detail-header h2 { font-size:19px; }
39
+ .domains-detail>.mojo-tabs>.mojo-tab-list { padding:0 12px; }
40
+ .domains-detail .mojo-tab { padding-left:10px;padding-right:10px; }
41
+ .domains-detail-panel { padding:18px 16px; }
42
+ .domains-detail .flat-row { grid-template-columns:100px minmax(0,1fr);gap:10px; }
43
+ .domains-detail .flat-action:empty { display:none; }
44
+ .domains-detail .flat-value>.btn { margin-top:8px; }
45
+ .domains-detail .dns-record-toolbar { align-items:stretch; }
46
+ .domains-detail .dns-toolbar-actions { flex-wrap:wrap; }
47
+ .domains-narrow-scope { display:inline; }
48
+ .domains-narrow-status { display:block; }
49
+ .domains-page .tbl { table-layout:fixed;width:100%; }
50
+ .domains-page .tbl th:first-child { width:auto; }
51
+ .domains-page .tbl th:last-child { width:128px; }
52
+ .domains-page .tbl td:last-child .btn { white-space:nowrap; }
53
+ .domains-page .tbl th,.domains-page .tbl td { padding-left:12px;padding-right:12px;overflow-wrap:anywhere; }
54
+ }
55
+ .domains-registration .domains-overview-actions:first-child { margin-top:0; }
56
+ .domains-registration .detail-grid,.domains-registration-contact .detail-grid { margin:20px 0;display:grid; }
57
+ .domains-registration .detail-grid>div,.domains-registration-contact .detail-grid>div { display:grid;grid-template-columns:minmax(130px,.45fr) minmax(0,1fr);gap:16px;padding:11px 0;border-bottom:1px solid var(--line); }
58
+ .domains-registration .detail-grid dt,.domains-registration-contact .detail-grid dt { color:var(--mute); }
59
+ .domains-registration .detail-grid dd,.domains-registration-contact .detail-grid dd { margin:0;overflow-wrap:anywhere; }
60
+ .domains-registration-contact>.dim { margin:0 0 20px; }
61
+ .domains-registration-contact .mojo-tab-panel,.dns-registrant-scoped .mojo-tab-panel { padding-top:18px; }
62
+ @media(max-width:560px) {
63
+ .domains-registration .detail-grid>div,.domains-registration-contact .detail-grid>div { grid-template-columns:minmax(100px,.45fr) minmax(0,1fr);gap:10px; }
64
+ .domains-registration-contact .col-6,.dns-registrant-scoped .col-6 { grid-column:1/-1; }
65
+ .dns-registrant-scoped .form-grid>.col-6:nth-child(-n+2) { grid-column:span 6; }
66
+ }
2
67
 
3
- .domains-header h1 { margin: 4px 0 8px; font-size: 26px; line-height: 1.2; font-weight: 750; letter-spacing: -.025em; color: var(--ink-strong); }
4
- .domains-header { align-items: center; margin-bottom: 24px; }
5
-
6
- .domains-page, .domains-page > * { min-width: 0; }
7
- .domains-demo { grid-template-columns: minmax(0, 1fr); }
8
- .domains-demo > * { min-width: 0; }
9
- @media (max-width: 560px) { .domains-header { align-items: flex-start; } }
10
-
11
- .domain-onboarding > .dim { margin: 4px 0 20px; }
12
- .domain-onboarding .dns-registrar-grid { gap: 20px; }
68
+ .dns-registrant-scoped .dns-contact-status { flex-direction:row;align-items:center;justify-content:flex-start; }
@@ -115,6 +115,7 @@
115
115
  .edge-vhost-editor .edge-route-row { align-items: end; }
116
116
  .edge-hostname-detail .edge-route-actions .btn { white-space: nowrap; }
117
117
 
118
+ .edge-serving-actions { display:flex;flex-wrap:wrap;gap:8px; }
118
119
  .edge-serving-heading.page-header { align-items: flex-start; }
119
120
  .edge-serving-heading.page-header > div { min-width: 0; }
120
121
  .edge-serving-evidence .edge-row-actions { align-items: center; gap: 6px; }
@@ -3,6 +3,7 @@
3
3
  because it names a token rather than a color. */
4
4
 
5
5
  /* ── Page shell ─────────────────────────────────────────────────── */
6
+ .jobs-page-links { display:flex;flex-wrap:wrap;gap:8px; }
6
7
  .jobs-dashboard { display: grid; gap: 14px; }
7
8
  .jobs-page-head {
8
9
  display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
@@ -2,12 +2,15 @@ import { ComboBox } from './ComboBox';
2
2
  // FilterBar — web-mojo's filter UX: an "Add filter" menu listing every
3
3
  // available filter with a per-type icon and a check on the active ones, each
4
4
  // opening a type-appropriate dialog; applied filters become editable pills.
5
- // Adding a filter to a page is one entry in a `filters` array.
5
+ // Adding a filter to a page is one entry in a `filters` array. Keys owned by
6
+ // the selected preset (`owned`) are the segment's to show: no pill, no count,
7
+ // and Clear all keeps them (see preset-owned.ts).
6
8
  import { useEffect, useRef, useState } from 'react';
7
9
  import { modal } from './modal';
8
10
  import { DateRangePicker } from './date/DateRangePicker';
9
11
  import { formatFilterDisplay } from '../client/lookups';
10
12
  import type { ActiveFilter, TableParamsApi } from '../client/params';
13
+ import { isPresetOwned } from './preset-owned';
11
14
 
12
15
  export type FilterType = 'text' | 'select' | 'multiselect' | 'number' | 'boolean' | 'date' | 'daterange';
13
16
 
@@ -168,7 +171,7 @@ async function openFilterDialog(def: FilterDef, current: ActiveFilter | undefine
168
171
  }
169
172
 
170
173
  // ── the bar ───────────────────────────────────────────────────────────
171
- export function FilterBar({ defs, params }: { defs: FilterDef[]; params: TableParamsApi }) {
174
+ export function FilterBar({ defs, params, owned = {} }: { defs: FilterDef[]; params: TableParamsApi; owned?: Readonly<Record<string, string>> }) {
172
175
  const [open, setOpen] = useState(false);
173
176
  const wrapRef = useRef<HTMLDivElement>(null);
174
177
 
@@ -187,11 +190,14 @@ export function FilterBar({ defs, params }: { defs: FilterDef[]; params: TablePa
187
190
  }, [open]);
188
191
 
189
192
  const active = params.activeFilters;
193
+ // The menu's checks still show owned keys (they ARE filtering, and the
194
+ // dialog edits them); the count and Clear all are the pills' business.
195
+ const clearable = active.filter((f) => !isPresetOwned(f, owned));
190
196
 
191
197
  return (
192
198
  <div className="filter-menu-wrap" ref={wrapRef}>
193
199
  <button
194
- className={`btn btn-compact${active.length ? ' btn-has-filters' : ''}`}
200
+ className={`btn btn-compact${clearable.length ? ' btn-has-filters' : ''}`}
195
201
  onClick={() => setOpen((v) => !v)}
196
202
  aria-expanded={open}
197
203
  title="Filter"
@@ -199,7 +205,7 @@ export function FilterBar({ defs, params }: { defs: FilterDef[]; params: TablePa
199
205
  {/* Label collapses on normal widths (tool-group icon mode);
200
206
  the active-count badge stays visible either way. */}
201
207
  <i className="bi bi-funnel" /> <span className="btn-label">Filter</span>
202
- {active.length > 0 && <span className="filter-count">{active.length}</span>}
208
+ {clearable.length > 0 && <span className="filter-count">{clearable.length}</span>}
203
209
  </button>
204
210
  {open && (
205
211
  <div className="filter-menu" role="menu">
@@ -221,12 +227,12 @@ export function FilterBar({ defs, params }: { defs: FilterDef[]; params: TablePa
221
227
  </button>
222
228
  );
223
229
  })}
224
- {active.length > 0 && (
230
+ {clearable.length > 0 && (
225
231
  <>
226
232
  <div className="filter-menu-sep" />
227
233
  <button
228
234
  className="filter-menu-item filter-menu-clear"
229
- onClick={() => { setOpen(false); params.clearFilters(); }}
235
+ onClick={() => { setOpen(false); params.clearFilters(owned); }}
230
236
  >
231
237
  <i className="bi bi-x-circle" /> Clear all filters
232
238
  </button>
@@ -238,9 +244,9 @@ export function FilterBar({ defs, params }: { defs: FilterDef[]; params: TablePa
238
244
  );
239
245
  }
240
246
 
241
- /** Active-filter pills — click to edit, × to remove. */
242
- export function FilterPills({ defs, params }: { defs: FilterDef[]; params: TableParamsApi }) {
243
- const active = params.activeFilters;
247
+ /** Active-filter pills — click to edit, × to remove. Preset-owned keys never pill. */
248
+ export function FilterPills({ defs, params, owned = {} }: { defs: FilterDef[]; params: TableParamsApi; owned?: Readonly<Record<string, string>> }) {
249
+ const active = params.activeFilters.filter((f) => !isPresetOwned(f, owned));
244
250
  if (active.length === 0) return null;
245
251
  return (
246
252
  <div className="pill-row">
@@ -261,7 +267,7 @@ export function FilterPills({ defs, params }: { defs: FilterDef[]; params: Table
261
267
  </span>
262
268
  );
263
269
  })}
264
- <button className="pill-clear" onClick={() => params.clearFilters()}>Clear all</button>
270
+ <button className="pill-clear" onClick={() => params.clearFilters(owned)}>Clear all</button>
265
271
  </div>
266
272
  );
267
273
  }
@@ -30,6 +30,7 @@ import { busyWhile } from './loading';
30
30
  import { modal } from './modal';
31
31
  import { RenderGuard } from './safe-node';
32
32
  import { fixedParamKeys, mergeFixedParams } from './fixed-params';
33
+ import { isPresetOwned, presetOwnedFilters } from './preset-owned';
33
34
  import { toast } from './toast';
34
35
  import * as fmt from './format';
35
36
  import { warnOnce } from './warn-once';
@@ -428,6 +429,9 @@ function ModelTableView<T extends { id: RowId }>(props: ModelTableProps<T> & {
428
429
  const firstRow = count === 0 ? 0 : (p.page - 1) * p.size + 1;
429
430
  const lastRow = Math.min(count, (p.page - 1) * p.size + rows.length);
430
431
  const isFiltered = p.activeFilters.length > 0 || (searchable && p.search !== '');
432
+ // The selected segment owns its keys: no pill, and every Clear keeps them.
433
+ const owned = presetOwnedFilters(presets, p.presetActive);
434
+ const clearable = p.activeFilters.some((f) => !isPresetOwned(f, owned)) || (searchable && p.search !== '');
431
435
 
432
436
  // Save the view on every params/visibility change (post-hydration only,
433
437
  // so the restore itself never writes back a half-applied state).
@@ -750,7 +754,7 @@ function ModelTableView<T extends { id: RowId }>(props: ModelTableProps<T> & {
750
754
  toolbar). Icons + tooltips only; text labels appear
751
755
  only on very wide screens via .btn-label. */}
752
756
  <div className="tool-group">
753
- {filters.length > 0 && <FilterBar defs={filters} params={p} />}
757
+ {filters.length > 0 && <FilterBar defs={filters} params={p} owned={owned} />}
754
758
  {columnChooser && (
755
759
  <MenuDropdown button={(open, toggle) => (
756
760
  <button className="btn btn-compact" onClick={toggle} aria-expanded={open} title="Choose columns">
@@ -838,7 +842,7 @@ function ModelTableView<T extends { id: RowId }>(props: ModelTableProps<T> & {
838
842
  </div>
839
843
  )}
840
844
 
841
- <FilterPills defs={filters} params={p} />
845
+ <FilterPills defs={filters} params={p} owned={owned} />
842
846
 
843
847
  {selectable && batchActions.length > 0 && selected.size > 0 && (
844
848
  <div className="batch-bar" role="region" aria-label="Batch actions">
@@ -939,7 +943,7 @@ function ModelTableView<T extends { id: RowId }>(props: ModelTableProps<T> & {
939
943
  {isFiltered ? (
940
944
  <>
941
945
  <h3>No results match your filters</h3>
942
- <button className="btn" onClick={() => p.clearFilters()}>Clear filters</button>
946
+ {clearable && <button className="btn" onClick={() => p.clearFilters(owned)}>Clear filters</button>}
943
947
  </>
944
948
  ) : (
945
949
  <h3>No {title.toLowerCase()} yet</h3>
@@ -115,6 +115,15 @@ function countDestinations(items: MenuItem[]): number {
115
115
  + countDestinations(item.children ?? []), 0);
116
116
  }
117
117
 
118
+ /** Keep secondary tools searchable without filling the everyday navigation. */
119
+ function primaryTree(item: MenuItem, pathname: string): MenuItem | null {
120
+ if (item.searchOnly && !treeOwnsRoute(item, pathname)) return null;
121
+ if (!item.children) return item;
122
+ const children = item.children.map(child => primaryTree(child, pathname))
123
+ .filter((child): child is MenuItem => child != null);
124
+ return !item.route && children.length === 0 ? null : { ...item, children };
125
+ }
126
+
118
127
  function treeOwnsRoute(item: MenuItem, pathname: string): boolean {
119
128
  if (item.route && routesMatch(item.route, pathname, item.exact)) return true;
120
129
  return (item.children ?? []).some((child) => treeOwnsRoute(child, pathname));
@@ -299,7 +308,7 @@ export function SidebarNav({ collapsed = false, onRequestExpand }: SidebarNavPro
299
308
  const normalizedQuery = query.trim().toLocaleLowerCase();
300
309
  const filteredItems = useMemo(() => normalizedQuery
301
310
  ? retainRunDividers(items, items.map((item) => filterTree(item, normalizedQuery)))
302
- : items, [items, normalizedQuery]);
311
+ : retainRunDividers(items, items.map(item => primaryTree(item, pathname))), [items, normalizedQuery, pathname]);
303
312
  const resultCount = countDestinations(filteredItems);
304
313
 
305
314
  if (!menu) return null;
@@ -29,6 +29,8 @@ export interface MenuItem {
29
29
  label?: string;
30
30
  /** Extra quick-navigation terms that are never rendered. */
31
31
  keywords?: string[];
32
+ /** In accordion menus, show only in search or while this destination is active. */
33
+ searchOnly?: boolean;
32
34
  icon?: string;
33
35
  /** Absolute route within the hash router, e.g. '/users'. */
34
36
  route?: string;
@@ -0,0 +1,39 @@
1
+ // presetOwnedFilters — the filter keys the selected segment already shows.
2
+ //
3
+ // A preset is a param bundle with DERIVED active state: its keys live in the
4
+ // params store like any filter. Rendering them as pills as well says the
5
+ // same thing twice, and the pill's × (or Clear all) flips the segment behind
6
+ // the operator's back — a default "Staff" preset cleared to "All users", or
7
+ // a raw lookup like `user__player_profiles__group__not` shown as a filter.
8
+ // So the segment is the one control for the keys it owns: no pill, no count
9
+ // on the Filter button, and Clear all keeps them. A filter the operator adds
10
+ // on top still pills; editing an owned key through the Filter menu changes
11
+ // the value, the preset stops matching, and the pill comes back.
12
+ //
13
+ // This module is intentionally NOT exported from ui/index.ts — it is
14
+ // ModelTable plumbing, kept pure so the verify script can pin it without
15
+ // loading ModelTable's router/query graph.
16
+ import type { ActiveFilter } from '../client/params';
17
+
18
+ interface PresetParams { params: Record<string, string> }
19
+
20
+ /**
21
+ * Union of the params of every active preset. Matching is the params
22
+ * store's own `presetActive`, so a segment highlights exactly when it owns
23
+ * its keys. The empty "All" preset owns nothing.
24
+ */
25
+ export function presetOwnedFilters(
26
+ presets: readonly PresetParams[],
27
+ isActive: (params: Record<string, string>) => boolean,
28
+ ): Record<string, string> {
29
+ const owned: Record<string, string> = {};
30
+ for (const { params } of presets) {
31
+ if (Object.keys(params).length > 0 && isActive(params)) Object.assign(owned, params);
32
+ }
33
+ return owned;
34
+ }
35
+
36
+ /** True when a segment owns this filter. A daterange is owned through its `dr_field`. */
37
+ export function isPresetOwned(f: ActiveFilter, owned: Readonly<Record<string, string>>): boolean {
38
+ return f.kind === 'daterange' ? owned.dr_field === f.id : owned[f.id] !== undefined;
39
+ }