@ikenga/pkg-sales 0.2.0 → 0.3.0

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.
@@ -11,7 +11,7 @@
11
11
  // - .kb-board / .kb-col / .kb-card / .kb-mini-avatar / .kb-add (part 28 kanban)
12
12
  // - .nav-group[data-kind] / .nav-item / .nav-item.is-on / .nav-item.is-hot (part 22)
13
13
  // - .seg.nav-view-seg / .seg button.is-on (part 14 segmented-tabs, list-kanban-switch)
14
- // - .atelier-state.is-{loading,empty,error,streaming} / .atelier-spin (part 26 feedback-state)
14
+ // - .atelier-state.is-{loading,empty,error} / .atelier-spin (part 26 feedback-state)
15
15
  // - .stage-chip / .next-chip / .ux-dot / .badge / .tag / .chip (part 11 badge-tag-chip)
16
16
  // - .btn / .btn-icon / .btn-sm / .btn.affirmative (part 10 buttons)
17
17
  //
@@ -31,6 +31,14 @@ import {
31
31
  useQuery, useMutation, useQueryClient,
32
32
  } from '../../lib/ui.js';
33
33
  import { hostDbQuery, hostDbExec, setMenu, isStandalone } from '../../lib/bridge.js';
34
+ // create-wire recipe: dead "+ / New deal" buttons dispatch a creation brief to
35
+ // the Chi (R-03: a deal is agent-shaped, never a client-side husk INSERT).
36
+ import { buildCreateBrief, dispatchCreate } from '../../lib/create-dispatch.js';
37
+ // dispatch-wire recipe: deal-detail "Approve & run"/"Confirm & run" seed a
38
+ // structured next-action turn into the active Chi (host.sendToActiveSession).
39
+ import { dispatchItemAction } from '../../lib/dispatch.js';
40
+ // facet-wire recipe: sidebar filter facets (f:*) narrow the pipeline list/kanban.
41
+ import { applyFacet } from '../../lib/facet-filter.js';
34
42
 
35
43
  // ─── Stage enum ───────────────────────────────────────────────────────────────
36
44
  // Per the R-04 Pipeline-stages convention (06-skill-action-contract.md §Pipeline-stages).
@@ -186,6 +194,47 @@ function fmtPct(v) {
186
194
  return `${Math.round(v * 100)}%`;
187
195
  }
188
196
 
197
+ // ─── dispatch-wire (RECIPE 1) ─────────────────────────────────────────────────
198
+ // Map a deal row into the recipe-shared descriptor, then seed a next-action turn
199
+ // into the active Chi. A deal is agent-shaped, so "Approve & run"/"Confirm & run"
200
+ // dispatch a structured turn (host.sendToActiveSession) rather than committing
201
+ // headlessly — there is no domain-run verb today (see lib/dispatch.js APPROVE-RUN GAP).
202
+
203
+ function dealToDispatchItem(deal) {
204
+ return {
205
+ kind: 'deal',
206
+ title: deal.title ?? deal.company,
207
+ stage: STAGE_LABEL[deal.stage] ?? deal.stage,
208
+ nextAction: deal.next_action,
209
+ facts: [
210
+ ['Company', deal.company],
211
+ ['Owner', deal.owner ?? deal.assigned_to],
212
+ ['Value', deal.value != null ? fmtCurrency(deal.value) : null],
213
+ ],
214
+ };
215
+ }
216
+
217
+ function handleAction(deal) {
218
+ const mode = deal.next_action_mode === 'approve' ? 'approve' : 'confirm';
219
+ dispatchItemAction(dealToDispatchItem(deal), mode, 'com.ikenga.sales').catch(() => {});
220
+ }
221
+
222
+ // ─── facet-wire (RECIPE 2) ────────────────────────────────────────────────────
223
+ // Sales' "all" affordance is 'f:open-pipeline' (not the generic 'f:all'), so the
224
+ // reset id is passed explicitly to applyFacet. Each predicate expression MIRRORS
225
+ // its badge-count expression in buildSalesMenu so a facet's slice always matches
226
+ // the count shown on its row (facet-wire pitfall 2).
227
+
228
+ const SALES_RESET_FACET = 'f:open-pipeline';
229
+
230
+ const FACET_PREDICATES = {
231
+ 'f:my-deals': (d) => d.owner === 'nedjamez' || d.assigned_to === 'nedjamez',
232
+ 'f:closing-soon': (d) => d.next_action_mode === 'approve',
233
+ 'f:agent-run': (d) => d.owner === 'sales-agent' || d.assigned_to === 'sales-agent',
234
+ ...Object.fromEntries(STAGES.map((s) => [`f:stage:${s}`, (d) => d.stage === s])),
235
+ // 'f:open-pipeline' intentionally ABSENT → SALES_RESET_FACET returns every deal.
236
+ };
237
+
189
238
  // ─── Menu builder ─────────────────────────────────────────────────────────────
190
239
 
191
240
  /**
@@ -194,11 +243,13 @@ function fmtPct(v) {
194
243
  * pipeMode: 'list' | 'kanban' (only relevant when activeView === 0)
195
244
  * deals: the open deals array (for counts)
196
245
  */
197
- function buildSalesMenu(activeView, pipeMode, deals) {
246
+ function buildSalesMenu(activeView, pipeMode, deals, activeFacet) {
198
247
  const openCount = deals.length;
199
248
  const myDeals = deals.filter((d) => d.owner === 'nedjamez' || d.assigned_to === 'nedjamez');
200
249
  const closingSoon = deals.filter((d) => d.next_action_mode === 'approve');
201
250
  const agentRun = deals.filter((d) => (d.owner === 'sales-agent' || d.assigned_to === 'sales-agent'));
251
+ // A facet only highlights on the Pipeline view (facets dim/inert off it).
252
+ const facetActive = (id) => activeView === 0 && activeFacet === id;
202
253
 
203
254
  const viewItems = [
204
255
  {
@@ -248,7 +299,7 @@ function buildSalesMenu(activeView, pipeMode, deals) {
248
299
  label: 'Open pipeline',
249
300
  icon: 'layers',
250
301
  section: 'Filters',
251
- active: true,
302
+ active: facetActive('f:open-pipeline'),
252
303
  badge: openCount,
253
304
  disabled: activeView !== 0,
254
305
  },
@@ -257,7 +308,7 @@ function buildSalesMenu(activeView, pipeMode, deals) {
257
308
  label: 'My deals',
258
309
  icon: 'user',
259
310
  section: 'Filters',
260
- active: false,
311
+ active: facetActive('f:my-deals'),
261
312
  badge: myDeals.length || undefined,
262
313
  disabled: activeView !== 0,
263
314
  },
@@ -266,7 +317,7 @@ function buildSalesMenu(activeView, pipeMode, deals) {
266
317
  label: 'Closing soon',
267
318
  icon: 'zap',
268
319
  section: 'Filters',
269
- active: false,
320
+ active: facetActive('f:closing-soon'),
270
321
  hot: closingSoon.length > 0,
271
322
  badge: closingSoon.length > 0 ? closingSoon.length : undefined,
272
323
  disabled: activeView !== 0,
@@ -276,7 +327,7 @@ function buildSalesMenu(activeView, pipeMode, deals) {
276
327
  label: 'Agent-run',
277
328
  icon: 'cpu',
278
329
  section: 'Filters',
279
- active: false,
330
+ active: facetActive('f:agent-run'),
280
331
  badge: agentRun.length || undefined,
281
332
  disabled: activeView !== 0,
282
333
  },
@@ -286,7 +337,7 @@ function buildSalesMenu(activeView, pipeMode, deals) {
286
337
  id: `f:stage:${s}`,
287
338
  label: STAGE_LABEL[s],
288
339
  section: 'By stage',
289
- active: false,
340
+ active: facetActive(`f:stage:${s}`),
290
341
  badge: deals.filter((d) => d.stage === s).length || undefined,
291
342
  disabled: activeView !== 0,
292
343
  }));
@@ -335,6 +386,12 @@ function DealRow({ deal, isSelected, onClick }) {
335
386
 
336
387
  /** Deal detail pane */
337
388
  function DealDetail({ deal, activities }) {
389
+ // dispatch-wire — local feedback: flips true on click so the operator sees the
390
+ // hand-off landed and the same click can't double-seed the session. Resets when
391
+ // the selected deal changes.
392
+ const [sent, setSent] = useState(false);
393
+ useEffect(() => { setSent(false); }, [deal?.id]);
394
+
338
395
  if (!deal) {
339
396
  return html`<div class="ip-split-pane split-detail" style=${{ display:'flex', alignItems:'center', justifyContent:'center', color:'var(--fg-muted)', fontSize:'0.85rem' }}>
340
397
  Select a deal
@@ -344,12 +401,19 @@ function DealDetail({ deal, activities }) {
344
401
  const hasApprove = deal.next_action_mode === 'approve';
345
402
  const hasConfirm = deal.next_action_mode === 'confirm';
346
403
  const showButton = hasApprove || hasConfirm;
404
+ const onAct = () => { handleAction(deal); setSent(true); };
347
405
 
348
406
  return html`
349
407
  <div class="ip-split-pane split-detail" style=${{ overflowY:'auto' }}>
350
408
  <div class="split-detail-wrap">
351
409
  <div class="split-detail-eyebrow">
352
410
  <span class="stage-chip">${STAGE_LABEL[deal.stage] ?? deal.stage}</span>
411
+ ${deal.next_action_mode ? html`
412
+ <span class="next-chip">
413
+ <span class=${cn('ux-dot', `ux-${deal.next_action_mode}`)}></span>
414
+ ux_mode · ${deal.next_action_mode}
415
+ </span>
416
+ ` : null}
353
417
  </div>
354
418
  <div class="split-detail-title">${deal.title ?? deal.company}</div>
355
419
  <div class="split-detail-sub">${deal.company}${deal.owner ? ` · ${deal.owner}` : ''}</div>
@@ -371,8 +435,13 @@ function DealDetail({ deal, activities }) {
371
435
  ${deal.next_action}
372
436
  ${showButton ? html`
373
437
  <div style=${{ marginTop: '10px' }}>
374
- <button class=${cn('btn', hasApprove ? 'affirmative' : '')} type="button">
375
- ${hasApprove ? 'Approve & run' : 'Confirm & run'}
438
+ <button
439
+ class=${cn('btn', hasApprove ? 'affirmative' : '')}
440
+ type="button"
441
+ disabled=${sent}
442
+ onClick=${onAct}
443
+ >
444
+ ${sent ? 'Sent to your Chi' : hasApprove ? 'Approve & run' : 'Confirm & run'}
376
445
  </button>
377
446
  </div>
378
447
  ` : null}
@@ -381,7 +450,7 @@ function DealDetail({ deal, activities }) {
381
450
  ` : null}
382
451
 
383
452
  ${activities && activities.length > 0 ? html`
384
- <div class="split-detail-eyebrow" style=${{ marginTop:'12px' }}>Activity</div>
453
+ <div class="split-next-head" style=${{ marginTop:'12px' }}>Activity</div>
385
454
  <div class="split-timeline" role="list">
386
455
  ${activities.map((a) => html`
387
456
  <div class="split-tl-row" role="listitem" key=${a.id}>
@@ -446,7 +515,7 @@ function KbAvatar({ owner }) {
446
515
  }
447
516
 
448
517
  /** Pipeline kanban mode */
449
- function PipelineKanban({ deals, onStageChange }) {
518
+ function PipelineKanban({ deals, onStageChange, onCreate }) {
450
519
  const [dragging, setDragging] = useState(null);
451
520
  const [dropTarget, setDropTarget] = useState(null);
452
521
 
@@ -512,7 +581,12 @@ function PipelineKanban({ deals, onStageChange }) {
512
581
  </div>
513
582
  </div>
514
583
  `)}
515
- <button class="kb-add btn-icon" type="button" aria-label=${'Add deal to ' + STAGE_LABEL[s]}>+</button>
584
+ <button
585
+ class="kb-add btn-icon"
586
+ type="button"
587
+ aria-label=${'Add deal to ' + (STAGE_LABEL[s] ?? s)}
588
+ onClick=${() => onCreate?.(s)}
589
+ >+</button>
516
590
  </div>
517
591
  </div>
518
592
  `;
@@ -598,7 +672,7 @@ function ForecastView({ deals }) {
598
672
  <div class="sl-month" key=${m.label}>
599
673
  <span class="sl-month-val">${fmtCurrency(m.val)}</span>
600
674
  <div class="sl-month-bar-wrap">
601
- <div class="sl-month-bar" style=${{ height: `${Math.round((m.val / maxMonthVal) * 64)}px` }}></div>
675
+ <div class="sl-month-bar" style=${{ height: `${Math.round((m.val / maxMonthVal) * 100)}%` }}></div>
602
676
  </div>
603
677
  <span class="sl-month-lab">${m.label}</span>
604
678
  </div>
@@ -680,28 +754,33 @@ function LoadingState() {
680
754
  `;
681
755
  }
682
756
 
683
- function EmptyState() {
757
+ function EmptyState({ onCreate }) {
684
758
  return html`
685
759
  <div class="atelier-state is-empty" id="view-stage">
686
760
  <span>No deals yet</span>
687
- <button class="btn btn-sm" type="button" style=${{ marginTop:'8px' }}>New deal</button>
761
+ <button
762
+ class="btn btn-sm"
763
+ type="button"
764
+ style=${{ marginTop:'8px' }}
765
+ onClick=${() => onCreate?.()}
766
+ >New deal</button>
688
767
  </div>
689
768
  `;
690
769
  }
691
770
 
692
- function ErrorState({ error }) {
771
+ // Error state — design STATES.error (atelier-sales-list.html:1877-1879): alert
772
+ // icon + heading + explanation + a Retry button that refetches.
773
+ function ErrorState({ error, onRetry }) {
693
774
  return html`
694
775
  <div class="atelier-state is-error" id="view-stage">
695
- <span>Failed to load deals</span>
696
- <span style=${{ fontSize:'0.72rem', color:'var(--fg-muted)', marginTop:'4px' }}>${error}</span>
697
- </div>
698
- `;
699
- }
700
-
701
- function StreamingState() {
702
- return html`
703
- <div class="atelier-state is-streaming" id="view-stage">
704
- <span class="atelier-prog" role="status" aria-live="polite">Sales agent running…</span>
776
+ <svg class="ix" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
777
+ <path d="M12 9v4"/><path d="M12 17h.01"/>
778
+ <path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/>
779
+ </svg>
780
+ <h3>Couldn’t load sales</h3>
781
+ <p>The source didn’t respond. Retry, or ask your Chi to check the connection.</p>
782
+ ${onRetry ? html`<button class="btn btn-sm" type="button" onClick=${onRetry}>Retry</button>` : null}
783
+ ${error ? html`<span style=${{ fontSize:'0.72rem', color:'var(--fg-muted)', marginTop:'4px' }}>${error}</span>` : null}
705
784
  </div>
706
785
  `;
707
786
  }
@@ -717,6 +796,9 @@ export function SalesView({ activeFeature }) {
717
796
  });
718
797
  const [pipeMode, setPipeMode] = useState('list'); // 'list' | 'kanban'
719
798
  const [selectedDeal, setSelectedDeal] = useState(null);
799
+ // facet-wire — last-applied sidebar filter facet. 'f:open-pipeline' is the
800
+ // reset/"all" affordance (no predicate → applyFacet returns every deal).
801
+ const [activeFacet, setActiveFacet] = useState(SALES_RESET_FACET);
720
802
  const qc = useQueryClient();
721
803
 
722
804
  // ── Data queries ────────────────────────────────────────────────────────────
@@ -731,6 +813,14 @@ export function SalesView({ activeFeature }) {
731
813
  });
732
814
 
733
815
  const deals = openDealsQ.data ?? [];
816
+ // facet-wire — the visible slice: full list narrowed by the active facet.
817
+ // Feeds the list, kanban AND the empty-state check (so a facet that matches
818
+ // nothing shows the empty pane, not a stale full list). Badges stay live
819
+ // because buildSalesMenu counts the FULL `deals`, not this slice.
820
+ const visibleDeals = useMemo(
821
+ () => applyFacet(deals, activeFacet, FACET_PREDICATES, SALES_RESET_FACET),
822
+ [deals, activeFacet],
823
+ );
734
824
 
735
825
  // ── db-updated refresh ──────────────────────────────────────────────────────
736
826
  useEffect(() => {
@@ -749,6 +839,13 @@ export function SalesView({ activeFeature }) {
749
839
  else if (activeFeature === 'v:won') setActiveView(2);
750
840
  else if (activeFeature === 'seg:list') setPipeMode('list');
751
841
  else if (activeFeature === 'seg:kanban') setPipeMode('kanban');
842
+ // Filter facets (f:*) — LIST-ONLY: force the Pipeline view so a facet the
843
+ // user can't see can't be silently applied, then record it. 'f:open-pipeline'
844
+ // resets; every other id narrows via applyFacet. (facet-wire Move 2.)
845
+ else if (activeFeature.startsWith('f:')) {
846
+ setActiveView(0);
847
+ setActiveFacet(activeFeature);
848
+ }
752
849
  }, [activeFeature]);
753
850
 
754
851
  // ── Pre-select hero deal (D-05 — Catalog onboarding · Chocolate City) ──────
@@ -762,9 +859,9 @@ export function SalesView({ activeFeature }) {
762
859
  // ── setMenu publish ─────────────────────────────────────────────────────────
763
860
  useEffect(() => {
764
861
  if (isStandalone()) return;
765
- const items = buildSalesMenu(activeView, pipeMode, deals);
862
+ const items = buildSalesMenu(activeView, pipeMode, deals, activeFacet);
766
863
  setMenu(items).catch(() => {/* ignore */});
767
- }, [activeView, pipeMode, deals]);
864
+ }, [activeView, pipeMode, deals, activeFacet]);
768
865
 
769
866
  // ── Stage change mutation (kanban drag) ─────────────────────────────────────
770
867
  const stageChange = useMutation({
@@ -778,6 +875,29 @@ export function SalesView({ activeFeature }) {
778
875
  onSuccess: () => qc.invalidateQueries({ queryKey: QK.openDeals }),
779
876
  });
780
877
 
878
+ // ── Dispatch-mode creation (create-wire recipe) ─────────────────────────────
879
+ // R-03: a deal is agent-shaped — it needs company research plus owner,
880
+ // next_action, next_action_mode and win_probability, and links to `contacts`.
881
+ // Seeding an empty client-side INSERT would leave a husk the user must
882
+ // hand-fill, so creation dispatches a structured brief to the active Chi
883
+ // session instead (host.sendToActiveSession, via lib/create-dispatch.js).
884
+ // `stage` is the kanban column's pre-filled context; undefined for the
885
+ // empty-state "New deal" which seeds a full brief.
886
+ const createDeal = useCallback((stage) => {
887
+ const label = stage ? (STAGE_LABEL[stage] ?? stage) : null;
888
+ const brief = buildCreateBrief({
889
+ entity: 'sales deal',
890
+ table: 'sales_deals',
891
+ seed: label ? { stage: label } : {},
892
+ instruction:
893
+ 'Research the company, then set the title, company, owner, value, '
894
+ + 'next action, and win probability'
895
+ + (label ? `, and file it at the ${label} stage` : '')
896
+ + '. Ask me for anything you still need, then add it to the sales_deals table.',
897
+ });
898
+ void dispatchCreate(brief, 'com.ikenga.sales');
899
+ }, []);
900
+
781
901
  // ── Head label ──────────────────────────────────────────────────────────────
782
902
  const headLabel = activeView === 0 ? `Sales · ${deals.length} open`
783
903
  : activeView === 1 ? 'Forecast'
@@ -791,17 +911,18 @@ export function SalesView({ activeFeature }) {
791
911
  if (openDealsQ.isLoading) {
792
912
  body = html`<${LoadingState} />`;
793
913
  } else if (openDealsQ.isError) {
794
- body = html`<${ErrorState} error=${openDealsQ.error?.message ?? 'unknown'} />`;
795
- } else if (deals.length === 0) {
796
- body = html`<${EmptyState} />`;
914
+ body = html`<${ErrorState} error=${openDealsQ.error?.message ?? 'unknown'} onRetry=${() => openDealsQ.refetch()} />`;
915
+ } else if (visibleDeals.length === 0) {
916
+ body = html`<${EmptyState} onCreate=${createDeal} />`;
797
917
  } else if (pipeMode === 'kanban') {
798
918
  body = html`<${PipelineKanban}
799
- deals=${deals}
919
+ deals=${visibleDeals}
800
920
  onStageChange=${(deal, newStage) => stageChange.mutate({ deal, newStage })}
921
+ onCreate=${createDeal}
801
922
  />`;
802
923
  } else {
803
924
  body = html`<${PipelineList}
804
- deals=${deals}
925
+ deals=${visibleDeals}
805
926
  selectedDeal=${selectedDeal}
806
927
  onSelectDeal=${setSelectedDeal}
807
928
  activities=${activitiesQ.data ?? []}
@@ -814,7 +935,7 @@ export function SalesView({ activeFeature }) {
814
935
  if (wonDealsQ.isLoading) {
815
936
  body = html`<${LoadingState} />`;
816
937
  } else if (wonDealsQ.isError) {
817
- body = html`<${ErrorState} error=${wonDealsQ.error?.message ?? 'unknown'} />`;
938
+ body = html`<${ErrorState} error=${wonDealsQ.error?.message ?? 'unknown'} onRetry=${() => wonDealsQ.refetch()} />`;
818
939
  } else {
819
940
  body = html`<${WonView} wonDeals=${wonDealsQ.data ?? WON_DEALS_FIXTURE} />`;
820
941
  }
@@ -1,5 +1,14 @@
1
1
  // MCP Apps SDK bridge — the canonical iframe⇄host protocol Ikenga uses.
2
2
  //
3
+ // SOURCE OF TRUTH (WP-19). This file is the single, byte-identical bridge core
4
+ // vendored into every no-build app pkg's dist/lib/bridge.js. The two per-pkg
5
+ // identifiers that used to be hand-edited into each copy — the source-id passed
6
+ // to host.sendToActiveSession and the short log tag in error/console strings —
7
+ // are now injected via the generated sibling `./pkg-id.js` (PKG_ID, LOG_TAG),
8
+ // which kills the copy-paste-id bug class (a `tasks`/`sales` id or `[sales]`
9
+ // tag left behind in the wrong pkg). Never hand-edit a vendored copy; edit here
10
+ // and re-run the pkg's `scripts/build.mjs`.
11
+ //
3
12
  // Pattern from @modelcontextprotocol/ext-apps Quickstart + the shell's
4
13
  // pkg-iframe-host.tsx implementation:
5
14
  // 1. new App(...) — register handlers before connect
@@ -21,6 +30,7 @@
21
30
  // clobbers our workspace `data-theme` (A/B/C) with 'light'|'dark', breaking the
22
31
  // bundled @ikenga/tokens palette — so the bridge stays out of theming entirely.
23
32
  import { App } from 'https://esm.sh/@modelcontextprotocol/ext-apps@1.7.1/app-with-deps';
33
+ import { PKG_ID, LOG_TAG } from './pkg-id.js';
24
34
 
25
35
  let app = null;
26
36
 
@@ -31,7 +41,7 @@ export async function connectBridge({ name, version, onContextChange }) {
31
41
  tools: { listChanged: false },
32
42
  });
33
43
 
34
- app.onerror = (err) => console.error('[sales] bridge error', err);
44
+ app.onerror = (err) => console.error(`[${LOG_TAG}] bridge error`, err);
35
45
  // Theme is NOT applied here — app.js mirrors it from the parent <html>.
36
46
  // We still forward context so live activeFeature (side-menu) updates reach
37
47
  // the app; data flows through host.dbQuery/dbExec, not the context payload.
@@ -83,7 +93,7 @@ export async function setMenu(items) {
83
93
  * prompt: string — the instruction shown as the user turn
84
94
  * source?: string — provenance tag (defaults to the pkg id)
85
95
  */
86
- export async function hostSendToActiveSession(prompt, source = 'com.ikenga.tasks') {
96
+ export async function hostSendToActiveSession(prompt, source = PKG_ID) {
87
97
  if (!app) throw new Error('bridge not connected');
88
98
  return app.callServerTool({
89
99
  name: 'host.sendToActiveSession',
@@ -118,7 +128,7 @@ export async function hostPaActionsRun(args) {
118
128
  * params: SqlValue[] — positional bind values
119
129
  */
120
130
  export async function hostDbQuery(sql, params = []) {
121
- if (!app) throw new Error('[sales] bridge not connected — db_query unavailable');
131
+ if (!app) throw new Error(`[${LOG_TAG}] bridge not connected — db_query unavailable`);
122
132
  const res = await app.callServerTool({
123
133
  name: 'host.dbQuery',
124
134
  arguments: { sql, params },
@@ -141,7 +151,7 @@ export async function hostDbQuery(sql, params = []) {
141
151
  * params: SqlValue[] — positional bind values
142
152
  */
143
153
  export async function hostDbExec(sql, params = []) {
144
- if (!app) throw new Error('[sales] bridge not connected — db_exec unavailable');
154
+ if (!app) throw new Error(`[${LOG_TAG}] bridge not connected — db_exec unavailable`);
145
155
  const res = await app.callServerTool({
146
156
  name: 'host.dbExec',
147
157
  arguments: { sql, params },
@@ -0,0 +1,93 @@
1
+ // RECIPE-SHARED SHAPE (create-wire) — dispatch-mode creation for domain pkgs.
2
+ //
3
+ // Lift target: @ikenga/pkg-runtime. Every domain pkg's dead "+ / New <X>"
4
+ // buttons route through here so creation is *agent-shaped* — we seed a
5
+ // structured creation brief into the shell's active Chi session and let the
6
+ // agent do the create through its skill (research, cross-table links, enrich
7
+ // fields), instead of the pkg writing a raw client-side husk INSERT.
8
+ //
9
+ // This is the R4 / R-03 rule made concrete: domain pkgs never run transport or
10
+ // CRUD-for-agents directly. Creation of a row that needs enrichment or
11
+ // cross-table fan-out is dispatch. A narrow direct-write exception exists —
12
+ // see create-wire.md "Decision rule" — for a self-contained, fully
13
+ // user-supplied single-table row (the tasks CreateTaskForm case).
14
+ //
15
+ // SEAM (must exist — do not invent):
16
+ // host.sendToActiveSession({ prompt, source? })
17
+ // → shell handler: shell/src/components/pkg/pkg-iframe-host.tsx:551
18
+ // → bridge wrapper: lib/bridge.js (hostSendToActiveSession)
19
+ // Returns { ok, threadId?, reason? }. Refuses with reason:'no-active-session'
20
+ // when no chat pane is focused.
21
+ //
22
+ // PRECONDITION (currently a shell/contract gap — see create-wire.md pitfall #1):
23
+ // The verb is gated on the `engine:invoke` scope (pkg-iframe-host.tsx:562),
24
+ // which the FE resolves from manifest `permissions.engine` containing
25
+ // 'invoke'. Neither the contract PermissionsSchema nor the Rust Permissions
26
+ // struct carries an `engine` key today, so it is stripped and the gate
27
+ // returns scope-denied. Wire it correctly anyway (this file); it lights up
28
+ // the moment the shell adds the key. dispatchCreate swallows the refusal so
29
+ // a blocked gate never throws into the view.
30
+
31
+ import { hostSendToActiveSession, isStandalone } from './bridge.js';
32
+
33
+ /**
34
+ * Build a structured creation brief for the Chi.
35
+ *
36
+ * The SHAPE is constant across domains so every "New <X>" reads the same to the
37
+ * agent: a one-line "Create a new <entity>." heading, an optional fielded
38
+ * "Known so far:" block seeded from the click context, and an explicit
39
+ * instruction that names the target table so the agent files it correctly.
40
+ * Domains vary only the entity label, table name, seed fields, and instruction.
41
+ *
42
+ * @param {object} args
43
+ * @param {string} args.entity Human label, e.g. 'sales deal'.
44
+ * @param {string} args.table Target table, e.g. 'sales_deals'.
45
+ * @param {Record<string, string|number|null|undefined>} [args.seed]
46
+ * Pre-filled context from the click (e.g. { stage: 'Proposal' }).
47
+ * Null/empty values are dropped.
48
+ * @param {string} [args.instruction] What the agent should do before writing.
49
+ * Defaults to a generic "ask then insert into <table>".
50
+ * @returns {string}
51
+ */
52
+ export function buildCreateBrief({ entity, table, seed = {}, instruction }) {
53
+ const lines = [`Create a new ${entity}.`];
54
+ const entries = Object.entries(seed).filter(
55
+ ([, v]) => v !== null && v !== undefined && v !== '',
56
+ );
57
+ if (entries.length) {
58
+ lines.push('', 'Known so far:');
59
+ for (const [k, v] of entries) lines.push(`- ${k}: ${v}`);
60
+ }
61
+ lines.push(
62
+ '',
63
+ instruction ??
64
+ `Ask me for anything you still need, then add it to the ${table} table.`,
65
+ );
66
+ return lines.join('\n');
67
+ }
68
+
69
+ /**
70
+ * Seed a creation brief into the shell's active Chi session.
71
+ *
72
+ * Fire-and-forget by contract: no-ops (returns false) in standalone preview
73
+ * where there is no host, and never rethrows — a scope-denied / no-active-
74
+ * session refusal is logged, not surfaced as an exception, so a dead gate can
75
+ * never break the calling view. Returns true only when the dispatch call
76
+ * resolved.
77
+ *
78
+ * @param {string} brief Output of buildCreateBrief.
79
+ * @param {string} source Provenance tag — pass the pkg id (e.g.
80
+ * 'com.ikenga.sales') so the shell stamps the audit trail
81
+ * with the right origin.
82
+ * @returns {Promise<boolean>}
83
+ */
84
+ export async function dispatchCreate(brief, source) {
85
+ if (isStandalone()) return false;
86
+ try {
87
+ await hostSendToActiveSession(brief, source);
88
+ return true;
89
+ } catch (e) {
90
+ console.warn('[create-dispatch] dispatch failed', e);
91
+ return false;
92
+ }
93
+ }
@@ -0,0 +1,87 @@
1
+ // Recipe-shared shape — atelier-parity RECIPE 1 · dispatch-wire.
2
+ //
3
+ // Lift-ready for a future @ikenga/pkg-runtime extraction: this module is the
4
+ // ONE place a domain pkg (content / research / strategy / sales) turns an
5
+ // operator "Approve & run" / "Confirm & run" click into a real host dispatch.
6
+ // It depends ONLY on hostSendToActiveSession + isStandalone from ./bridge.js
7
+ // (both present in every domain pkg's bridge copy), so it copies verbatim.
8
+ //
9
+ // SEAM RATIONALE (why sendToActiveSession, not a direct run):
10
+ // The shell's iframe host dispatcher (shell/src/components/pkg/
11
+ // pkg-iframe-host.tsx `dispatchHostCall`) supports exactly these host verbs:
12
+ // host.sendToActiveSession (line 551) ← the ONLY agent-run seam
13
+ // host.navigate, host.pkg.setMenu, host.dbQuery, host.dbExec,
14
+ // host.openSessionDialog, host.agentOps.*, host.fetch, host.invoke
15
+ // There is NO direct-run verb. `host.paActionsRun` and `host.paActions.*`
16
+ // both fall through to `return errResult('unknown host tool: ${name}')`
17
+ // (pkg-iframe-host.tsx:830) — do NOT wire to them. So BOTH ux_modes seed a
18
+ // structured user turn into the active Claude session; the agent performs
19
+ // the run through its own privileged path (same pattern the Tasks pkg uses to
20
+ // "create" work — see bridge.js hostSendToActiveSession docstring).
21
+ //
22
+ // APPROVE-RUN GAP (feeds WP-18 / the contract work):
23
+ // 'approve' cannot honestly run-without-a-turn today — there is no host verb
24
+ // that commits an action headlessly for a domain row. What 'approve' CAN do
25
+ // is frame the seeded turn as "operator has authority, run it now" so the
26
+ // agent proceeds without a back-and-forth. A true headless approve-run needs
27
+ // a new shell verb (e.g. host.paActions.commit actually wired in
28
+ // dispatchHostCall, or a domain-run verb). Until then approve == a
29
+ // differently-framed sendToActiveSession.
30
+
31
+ import { hostSendToActiveSession, isStandalone } from './bridge.js';
32
+
33
+ // Frame the seeded turn per ux_mode.
34
+ // approve → operator has authority, wants it done now (agent proceeds).
35
+ // confirm → operator wants the agent to confirm/plan before running.
36
+ const MODE_FRAME = {
37
+ approve: (label) => `Approve and run the next action for ${label}.`,
38
+ confirm: (label) => `Confirm the next action for ${label} with me before running it.`,
39
+ };
40
+
41
+ /**
42
+ * Build the structured user-turn prompt for an item's next action.
43
+ *
44
+ * `item` is the recipe-shared descriptor — each domain pkg maps its own row
45
+ * into this shape (see pieceToDispatchItem in content-view.js for the pattern):
46
+ * {
47
+ * kind: string human label for the entity ('content piece', 'deal', ...)
48
+ * title: string the entity title
49
+ * stage?: string current pipeline stage (already display-labelled)
50
+ * nextAction?:string the next_action text shown in the UI
51
+ * facts?: [k, v][] extra context lines ([['Channel','x'], ...]); nullish v skipped
52
+ * }
53
+ *
54
+ * Pure + side-effect-free so it is unit-testable without a bridge.
55
+ */
56
+ export function buildActionPrompt(item, mode) {
57
+ const kind = item.kind ?? 'item';
58
+ const label = item.title ? `the ${kind} "${item.title}"` : `this ${kind}`;
59
+ const frame = (MODE_FRAME[mode] ?? MODE_FRAME.confirm)(label);
60
+ const lines = [frame, ''];
61
+ if (item.stage) lines.push(`Stage: ${item.stage}`);
62
+ if (item.nextAction) lines.push(`Next action: ${item.nextAction}`);
63
+ for (const [k, v] of item.facts ?? []) {
64
+ if (v != null && v !== '') lines.push(`${k}: ${v}`);
65
+ }
66
+ return lines.join('\n');
67
+ }
68
+
69
+ /**
70
+ * Dispatch an item's next action to the shell's active Claude session.
71
+ *
72
+ * Fire-and-forget seam: seeds a user turn via host.sendToActiveSession. Callers
73
+ * should still `.catch(() => {})` at the click site so a closed/failed bridge
74
+ * never throws into the handler (mirrors outbound-view.js `sendToChat`).
75
+ * No-ops in standalone (no parent shell). Returns the prompt that was sent (or
76
+ * null when standalone) so callers/tests can assert on it.
77
+ *
78
+ * item: descriptor (see buildActionPrompt)
79
+ * mode: 'approve' | 'confirm' (anything else → confirm framing)
80
+ * source: string provenance tag — pass the pkg id (e.g. 'com.ikenga.content')
81
+ */
82
+ export async function dispatchItemAction(item, mode, source) {
83
+ if (isStandalone()) return null;
84
+ const prompt = buildActionPrompt(item, mode);
85
+ await hostSendToActiveSession(prompt, source);
86
+ return prompt;
87
+ }
@@ -0,0 +1,41 @@
1
+ // facet-filter — recipe-shared shape (facet-wire recipe), lift-ready for a
2
+ // future @ikenga/pkg-runtime extraction. DO NOT add domain columns here; this
3
+ // stays domain-agnostic.
4
+ //
5
+ // Wire it fits: the shell relays a sidebar filter-facet click as
6
+ // `royaltiSuite.activeFeature = <the clicked PkgMenuItem id>` (e.g. 'f:mine')
7
+ // via the host-context re-emit — NEVER a pkg-menu-click message. The pkg keeps
8
+ // the last-applied facet id in state and calls `applyFacet` to derive the
9
+ // visible slice of its list. Pair this generic applier with a *domain* predicate
10
+ // map (facetId → (item) => boolean) declared in the view, where the domain
11
+ // columns live. Canonical consumer to mirror: tasks-view.js (FILTER_ITEMS + the
12
+ // activeFeature effect that narrows the list).
13
+
14
+ /** Reset/clear facet id — the "show everything" affordance. Tasks uses 'f:all'. */
15
+ export const RESET_FACET = 'f:all';
16
+
17
+ /**
18
+ * Return the slice of `items` that matches `facetId`.
19
+ *
20
+ * Inertness contract (matches tasks' behavior): a null/empty facet, the reset
21
+ * id, or an id with no predicate all return every item — an *unknown* facet is
22
+ * inert, never empty. So a stray dispatch can't blank the list.
23
+ *
24
+ * @template T
25
+ * @param {T[]} items full list
26
+ * @param {string | null | undefined} facetId last-applied facet id
27
+ * @param {Record<string, (item: T) => boolean>} predicates domain predicate map
28
+ * @param {string} [resetId] clear id (default 'f:all')
29
+ * @returns {T[]}
30
+ */
31
+ export function applyFacet(items, facetId, predicates, resetId = RESET_FACET) {
32
+ if (!Array.isArray(items)) return [];
33
+ if (!facetId || facetId === resetId) return items;
34
+ const pred = predicates?.[facetId];
35
+ return typeof pred === 'function' ? items.filter(pred) : items;
36
+ }
37
+
38
+ /** True for sidebar filter-facet ids (the 'f:' namespace, incl. 'f:t:*'). */
39
+ export function isFacetId(id) {
40
+ return typeof id === 'string' && id.startsWith('f:');
41
+ }
@@ -0,0 +1,5 @@
1
+ // GENERATED by @ikenga/pkg-runtime vendor step — do not edit.
2
+ // Per-pkg identity injected into the shared bridge.js core so the runtime
3
+ // stays byte-identical across pkgs (WP-19). Overwritten on next build.
4
+ export const PKG_ID = "com.ikenga.sales";
5
+ export const LOG_TAG = "sales";
@@ -1,4 +1,4 @@
1
1
  // GENERATED by scripts/build.mjs from dist/sales.css — do not edit.
2
2
  // CSS-as-string: WebKitGTK cannot load link/fetch subresources from the
3
3
  // about:srcdoc the shell mounts, so app.js injects this as an inline <style>.
4
- export default "/* sales.css — domain residue for com.ikenga.sales.\n *\n * Only sales-local classes live here:\n * - .sl-* Forecast view layout (.sl-forecast-kpis, .sl-forecast-kpi, etc.)\n * - .sl-won-* Won view layout (.sl-won-wrap, .sl-won-table, .sl-won-badge)\n * - Kanban + pipeline overrides specific to this domain (.kb-mini-avatar.is-agent,\n * .split-row-when.is-urgent already in kit — no re-derive needed)\n *\n * Kit primitives (.frame*, .ip-split*, .dense-row*, .btn*, .tag, .chip, .badge,\n * .atelier-state*, .nav-group*, .nav-item*, .kb-*, .seg*, .split-*, etc.) are\n * consumed from app-kit-css.js — never re-derived here.\n *\n * Inject order: tokens-css → app-kit-css → sales-css (this file).\n * data-workspace=\"sessions\" on <html> → warm amber-ochre tint for active nav + accents.\n */\n\n/* ── Reduced motion gate ── */\n@media (prefers-reduced-motion: reduce) {\n .split-row { transition: none; }\n .kb-card { transition: none; }\n .kb-card:hover { transform: none; }\n}\n\n/* ── Forecast view (.sl-*) ─────────────────────────────────────────────────── */\n\n.sl-forecast-wrap {\n display: flex;\n flex-direction: column;\n gap: 16px;\n padding: 16px;\n overflow-y: auto;\n height: 100%;\n}\n\n.sl-forecast-kpis {\n display: grid;\n grid-template-columns: repeat(4, 1fr);\n gap: 10px;\n}\n\n.sl-forecast-kpi {\n background: var(--bg-surface, var(--bg-base));\n border: 1px solid var(--border-soft, rgba(0,0,0,0.08));\n border-radius: 8px;\n padding: 14px 16px;\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n.sl-kpi-k {\n font-family: var(--font-mono, monospace);\n font-size: 10px;\n font-weight: 500;\n color: var(--fg-faint, var(--fg-muted));\n text-transform: uppercase;\n letter-spacing: 0.08em;\n}\n\n.sl-kpi-v {\n font-family: var(--font-display, Georgia, serif);\n font-size: 26px;\n font-weight: 500;\n font-style: normal;\n color: var(--fg);\n margin-top: 4px;\n}\n\n.sl-kpi-sub {\n font-size: 0.7rem;\n color: var(--fg-faint, var(--fg-muted));\n font-family: var(--font-mono, monospace);\n}\n\n.sl-forecast-card {\n background: var(--bg-surface, var(--bg-base));\n border: 1px solid var(--border-soft, rgba(0,0,0,0.08));\n border-radius: 8px;\n overflow: hidden;\n}\n\n.sl-forecast-card-h {\n padding: 10px 14px;\n font-size: 0.8rem;\n font-weight: 600;\n color: var(--fg-muted);\n border-bottom: 1px solid var(--border-soft, rgba(0,0,0,0.06));\n text-transform: uppercase;\n letter-spacing: 0.04em;\n}\n\n.sl-funnel-row {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 8px 14px;\n border-bottom: 1px solid var(--border-soft, rgba(0,0,0,0.04));\n}\n\n.sl-funnel-row:last-child {\n border-bottom: none;\n}\n\n.sl-funnel-name {\n width: 100px;\n font-size: 0.78rem;\n color: var(--fg);\n flex-shrink: 0;\n}\n\n.sl-funnel-bar {\n flex: 1;\n height: 22px;\n border-radius: var(--radius-xs, 3px);\n background: var(--bg-sunken, rgba(0,0,0,0.06));\n position: relative;\n overflow: hidden;\n}\n\n.sl-funnel-bar-fill {\n position: absolute;\n left: 0;\n top: 0;\n height: 100%;\n background: var(--bg-raised, rgba(128,128,128,0.3));\n border: 1px solid var(--border-soft, rgba(0,0,0,0.08));\n border-radius: var(--radius-xs, 3px);\n}\n\n.sl-funnel-bar-wt {\n position: absolute;\n left: 0;\n top: 0;\n height: 100%;\n background: var(--primary, hsl(215,70%,55%));\n border-radius: var(--radius-xs, 3px);\n opacity: 0.9;\n}\n\n.sl-funnel-val {\n width: 72px;\n font-size: 0.72rem;\n font-family: var(--font-mono, monospace);\n color: var(--fg-muted);\n text-align: right;\n flex-shrink: 0;\n}\n\n.sl-months {\n display: flex;\n align-items: flex-end;\n gap: 16px;\n padding: 14px;\n height: 170px;\n}\n\n.sl-month {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 4px;\n flex: 1;\n}\n\n.sl-month-bar-wrap {\n flex: 1;\n display: flex;\n align-items: flex-end;\n width: 100%;\n}\n\n.sl-month-bar {\n width: 100%;\n background: var(--primary, hsl(215,70%,55%));\n border-radius: 3px 3px 0 0;\n opacity: 0.7;\n min-height: 4px;\n}\n\n.sl-month-val {\n font-size: 0.65rem;\n font-family: var(--font-mono, monospace);\n color: var(--fg-muted);\n}\n\n.sl-month-lab {\n font-size: 0.65rem;\n color: var(--fg-faint, var(--fg-muted));\n text-transform: uppercase;\n letter-spacing: 0.04em;\n}\n\n/* ── Won view (.sl-won-*) ───────────────────────────────────────────────────── */\n\n.sl-won-wrap {\n display: flex;\n flex-direction: column;\n gap: 16px;\n padding: 16px;\n overflow-y: auto;\n height: 100%;\n}\n\n.sl-won-kpis {\n display: grid;\n grid-template-columns: repeat(4, 1fr);\n gap: 10px;\n}\n\n.sl-won-table-wrap {\n background: var(--bg-surface, var(--bg-base));\n border: 1px solid var(--border-soft, rgba(0,0,0,0.08));\n border-radius: 8px;\n overflow: hidden;\n}\n\n.sl-won-table {\n width: 100%;\n border-collapse: collapse;\n font-size: 0.8rem;\n}\n\n.sl-won-table thead tr {\n background: var(--bg-inset, rgba(0,0,0,0.04));\n}\n\n.sl-won-table th {\n padding: 8px 12px;\n text-align: left;\n font-size: 0.7rem;\n font-weight: 600;\n color: var(--fg-muted);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n border-bottom: 1px solid var(--border-soft, rgba(0,0,0,0.08));\n}\n\n.sl-won-table td {\n padding: 9px 12px;\n border-bottom: 1px solid var(--border-soft, rgba(0,0,0,0.04));\n color: var(--fg);\n vertical-align: middle;\n}\n\n.sl-won-table tbody tr:last-child td {\n border-bottom: none;\n}\n\n.sl-won-table tbody tr:hover td {\n background: var(--bg-hover, rgba(0,0,0,0.025));\n}\n\n.sl-won-badge {\n display: inline-flex;\n align-items: center;\n padding: 2px 6px;\n border-radius: 4px;\n font-size: 0.65rem;\n font-weight: 500;\n background: var(--bg-inset, rgba(0,0,0,0.08));\n color: var(--fg-muted);\n text-transform: capitalize;\n letter-spacing: 0.02em;\n}\n\n.sl-won-amt {\n font-family: var(--font-mono, monospace);\n font-size: 0.8rem;\n font-weight: 600;\n color: var(--live, hsl(150,48%,52%));\n}\n\n/* ── Kanban domain overrides ────────────────────────────────────────────────── */\n\n/* Stage dot colours per the screen doc */\n.kb-col[data-stage=\"lead\"] .kb-col-dot { background: var(--fg-faint, rgba(128,128,128,0.4)); }\n.kb-col[data-stage=\"qualified\"] .kb-col-dot { background: var(--systemic, hsl(140,55%,42%)); }\n.kb-col[data-stage=\"proposal\"] .kb-col-dot { background: var(--primary, hsl(215,70%,55%)); }\n.kb-col[data-stage=\"negotiation\"] .kb-col-dot { background: var(--achievement, hsl(38,85%,52%)); }\n.kb-col[data-stage=\"closing\"] .kb-col-dot { background: var(--live, hsl(348,72%,52%)); }\n\n/* Deal row ux-mode dots */\n.ux-dot {\n display: inline-block;\n width: 7px;\n height: 7px;\n border-radius: 50%;\n flex-shrink: 0;\n margin-right: 4px;\n}\n.ux-dot.ux-confirm { background: var(--achievement, hsl(38,85%,52%)); }\n.ux-dot.ux-silent { background: var(--fg-faint, rgba(128,128,128,0.4)); }\n.ux-dot.ux-approve { background: var(--primary, hsl(215,70%,55%)); }\n\n/* Next-action chip in list rows */\n.next-chip {\n display: inline-flex;\n align-items: center;\n gap: 3px;\n font-size: 0.68rem;\n color: var(--fg-muted);\n background: var(--bg-inset, rgba(0,0,0,0.06));\n border-radius: 4px;\n padding: 2px 6px;\n max-width: 180px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n/* Stage chip in detail pane */\n.stage-chip {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n font-size: 0.7rem;\n font-weight: 600;\n padding: 3px 8px;\n border-radius: 5px;\n background: var(--bg-inset, rgba(0,0,0,0.07));\n color: var(--fg-muted);\n text-transform: capitalize;\n letter-spacing: 0.03em;\n}\n\n/* Hot filter (Closing soon — achievement tint) */\n.nav-item.is-hot .nav-item-count {\n color: var(--achievement, hsl(38,85%,52%));\n font-weight: 700;\n}\n\n/* Deal amount in list rows */\n.split-row-amt {\n font-family: var(--font-mono, monospace);\n font-size: 0.78rem;\n font-weight: 600;\n color: var(--fg);\n white-space: nowrap;\n}\n\n/* Age / urgency */\n.split-row-when {\n font-size: 0.68rem;\n color: var(--fg-faint, var(--fg-muted));\n font-family: var(--font-mono, monospace);\n white-space: nowrap;\n}\n\n.split-row-when.is-urgent {\n color: var(--danger, hsl(0,65%,52%));\n}\n\n/* F-03 · Selected-row accent rail → explicit warm-primary (design uses\n var(--primary), not the sessions amber-ochre --tint-fg-active). */\n.split-row.is-selected .split-row-accent,\n.split-row.is-on .split-row-accent {\n background: var(--primary, hsl(20,50%,34%));\n border-radius: 2px;\n}\n\n/* F-02 · Pipeline list-pane head — sticky Fraunces \"Sales\" title + mono\n count above the stage groups. Ported from the design's .pl-list-head /\n .pl-list-title / .pl-list-meta (not an app-kit primitive). */\n.sl-list-head {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: var(--space-3, 12px) var(--space-4, 16px);\n border-bottom: 1px solid var(--border-soft);\n position: sticky;\n top: 0;\n background: var(--bg-surface);\n z-index: 6;\n}\n\n.sl-list-title {\n font-family: var(--font-display, Georgia, serif);\n font-weight: 500;\n font-size: var(--text-h3, 18px);\n color: var(--fg);\n}\n\n.sl-list-meta {\n font-family: var(--font-mono, monospace);\n font-size: 10.5px;\n color: var(--fg-faint, var(--fg-muted));\n letter-spacing: 0.04em;\n}\n\n/* F-05 · Detail-pane title → Fraunces 28px (design spec). The app-kit\n primitive sets Fraunces at --text-h2 (24px); sales bumps it to 28px to\n match the locked sales design's .pl-detail-title. */\n.split-detail-title {\n font-family: var(--font-display, Georgia, serif);\n font-size: 28px;\n font-weight: 500;\n line-height: 1.15;\n letter-spacing: -0.012em;\n color: var(--fg);\n margin: 0 0 var(--space-2, 8px);\n}\n\n/* F-06 / F-07 / F-08 · The facts grid (3-col card), next-action card\n (live-tinted border + bg), and next-action head (UPPERCASE mono 10px)\n are all consumed verbatim from the app-kit primitives (.split-facts,\n .split-next, .split-next-head) — no sales-local override. The previous\n 2-col plain-grid / un-tinted overrides were drift and have been removed\n so the kit's design-correct rules apply. */\n\n/* Activity timeline in detail pane */\n.split-timeline {\n display: flex;\n flex-direction: column;\n gap: 8px;\n margin-top: 12px;\n}\n\n.split-tl-row {\n display: flex;\n gap: 10px;\n align-items: flex-start;\n}\n\n.split-tl-dot {\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: var(--fg-faint, rgba(128,128,128,0.4));\n flex-shrink: 0;\n margin-top: 5px;\n}\n\n.split-tl-body {\n flex: 1;\n}\n\n.split-tl-title {\n font-size: 0.78rem;\n color: var(--fg);\n}\n\n.split-tl-when {\n font-size: 0.65rem;\n color: var(--fg-faint, var(--fg-muted));\n font-family: var(--font-mono, monospace);\n}\n\n/* Seg toggle (list/kanban) injected into sidebar nav-group */\n.nav-view-seg {\n margin: 4px 8px 6px;\n}\n\n/* Kanban board horizontal scroll mask */\n.kb-board-wrap {\n overflow-x: auto;\n height: 100%;\n -webkit-mask-image: linear-gradient(to right, black calc(100% - 40px), transparent 100%);\n mask-image: linear-gradient(to right, black calc(100% - 40px), transparent 100%);\n}\n"
4
+ export default "/* sales.css — domain residue for com.ikenga.sales.\n *\n * Only sales-local classes live here:\n * - .sl-* Forecast view layout (.sl-forecast-kpis, .sl-forecast-kpi, etc.)\n * - .sl-won-* Won view layout (.sl-won-wrap, .sl-won-table, .sl-won-badge)\n * - Kanban + pipeline overrides specific to this domain (.kb-mini-avatar.is-agent,\n * .split-row-when.is-urgent already in kit — no re-derive needed)\n *\n * Kit primitives (.frame*, .ip-split*, .dense-row*, .btn*, .tag, .chip, .badge,\n * .atelier-state*, .nav-group*, .nav-item*, .kb-*, .seg*, .split-*, etc.) are\n * consumed from app-kit-css.js — never re-derived here.\n *\n * Inject order: tokens-css → app-kit-css → sales-css (this file).\n * data-workspace=\"sessions\" on <html> → warm amber-ochre tint for active nav + accents.\n */\n\n/* ── Reduced motion gate ── */\n@media (prefers-reduced-motion: reduce) {\n .split-row { transition: none; }\n .kb-card { transition: none; }\n .kb-card:hover { transform: none; }\n}\n\n/* ── Forecast view (.sl-*) ─────────────────────────────────────────────────── */\n\n.sl-forecast-wrap {\n display: flex;\n flex-direction: column;\n gap: 16px;\n padding: 16px;\n overflow-y: auto;\n height: 100%;\n}\n\n.sl-forecast-kpis {\n display: grid;\n grid-template-columns: repeat(4, 1fr);\n gap: 10px;\n}\n\n.sl-forecast-kpi {\n background: var(--bg-surface, var(--bg-base));\n border: 1px solid var(--border-soft, rgba(0,0,0,0.08));\n border-radius: 8px;\n padding: 14px 16px;\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n.sl-kpi-k {\n font-family: var(--font-mono, monospace);\n font-size: 10px;\n font-weight: 500;\n color: var(--fg-faint, var(--fg-muted));\n text-transform: uppercase;\n letter-spacing: 0.08em;\n}\n\n.sl-kpi-v {\n font-family: var(--font-display, Georgia, serif);\n font-size: 26px;\n font-weight: 500;\n font-style: normal;\n color: var(--fg);\n margin-top: 4px;\n}\n\n.sl-kpi-sub {\n font-size: 0.7rem;\n color: var(--fg-faint, var(--fg-muted));\n font-family: var(--font-mono, monospace);\n}\n\n.sl-forecast-card {\n background: var(--bg-surface, var(--bg-base));\n border: 1px solid var(--border-soft, rgba(0,0,0,0.08));\n border-radius: 8px;\n overflow: hidden;\n}\n\n.sl-forecast-card-h {\n padding: 10px 14px;\n font-size: 0.8rem;\n font-weight: 600;\n color: var(--fg-muted);\n border-bottom: 1px solid var(--border-soft, rgba(0,0,0,0.06));\n text-transform: uppercase;\n letter-spacing: 0.04em;\n}\n\n.sl-funnel-row {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 8px 14px;\n border-bottom: 1px solid var(--border-soft, rgba(0,0,0,0.04));\n}\n\n.sl-funnel-row:last-child {\n border-bottom: none;\n}\n\n.sl-funnel-name {\n width: 100px;\n font-size: 0.78rem;\n color: var(--fg);\n flex-shrink: 0;\n}\n\n.sl-funnel-bar {\n flex: 1;\n height: 22px;\n border-radius: var(--radius-xs, 3px);\n background: var(--bg-sunken, rgba(0,0,0,0.06));\n position: relative;\n overflow: hidden;\n}\n\n.sl-funnel-bar-fill {\n position: absolute;\n left: 0;\n top: 0;\n height: 100%;\n background: var(--bg-raised, rgba(128,128,128,0.3));\n border: 1px solid var(--border-soft, rgba(0,0,0,0.08));\n border-radius: var(--radius-xs, 3px);\n}\n\n.sl-funnel-bar-wt {\n position: absolute;\n left: 0;\n top: 0;\n height: 100%;\n background: var(--primary, hsl(215,70%,55%));\n border-radius: var(--radius-xs, 3px);\n opacity: 0.9;\n}\n\n.sl-funnel-val {\n width: 72px;\n font-size: 0.72rem;\n font-family: var(--font-mono, monospace);\n color: var(--fg-muted);\n text-align: right;\n flex-shrink: 0;\n}\n\n.sl-months {\n display: flex;\n align-items: flex-end;\n gap: 16px;\n padding: 14px;\n height: 170px;\n}\n\n.sl-month {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 4px;\n flex: 1;\n /* Full-height column so the bar's percentage height resolves against the\n 170px .sl-months container (mirrors the design's .fc-month { height:100% }).\n Completes F-15 — without it the bar-wrap collapses to min-height and the\n JS-percentage bars never scale. */\n height: 100%;\n}\n\n.sl-month-bar-wrap {\n flex: 1;\n display: flex;\n align-items: flex-end;\n width: 100%;\n}\n\n.sl-month-bar {\n width: 100%;\n background: var(--primary, hsl(215,70%,55%));\n border-radius: 3px 3px 0 0;\n opacity: 0.7;\n min-height: 4px;\n}\n\n.sl-month-val {\n font-size: 0.65rem;\n font-family: var(--font-mono, monospace);\n color: var(--fg-muted);\n}\n\n.sl-month-lab {\n font-size: 0.65rem;\n color: var(--fg-faint, var(--fg-muted));\n text-transform: uppercase;\n letter-spacing: 0.04em;\n}\n\n/* ── Won view (.sl-won-*) ───────────────────────────────────────────────────── */\n\n.sl-won-wrap {\n display: flex;\n flex-direction: column;\n gap: 16px;\n padding: 16px;\n overflow-y: auto;\n height: 100%;\n}\n\n.sl-won-kpis {\n display: grid;\n grid-template-columns: repeat(4, 1fr);\n gap: 10px;\n}\n\n.sl-won-table-wrap {\n background: var(--bg-surface, var(--bg-base));\n border: 1px solid var(--border-soft, rgba(0,0,0,0.08));\n border-radius: 8px;\n overflow: hidden;\n}\n\n.sl-won-table {\n width: 100%;\n border-collapse: collapse;\n font-size: 0.8rem;\n}\n\n.sl-won-table thead tr {\n background: var(--bg-inset, rgba(0,0,0,0.04));\n}\n\n.sl-won-table th {\n padding: 8px 12px;\n text-align: left;\n font-size: 0.7rem;\n font-weight: 600;\n color: var(--fg-muted);\n text-transform: uppercase;\n letter-spacing: 0.04em;\n border-bottom: 1px solid var(--border-soft, rgba(0,0,0,0.08));\n}\n\n.sl-won-table td {\n padding: 9px 12px;\n border-bottom: 1px solid var(--border-soft, rgba(0,0,0,0.04));\n color: var(--fg);\n vertical-align: middle;\n}\n\n.sl-won-table tbody tr:last-child td {\n border-bottom: none;\n}\n\n.sl-won-table tbody tr:hover td {\n background: var(--bg-hover, rgba(0,0,0,0.025));\n}\n\n.sl-won-badge {\n display: inline-flex;\n align-items: center;\n padding: 2px 6px;\n border-radius: 4px;\n font-size: 0.65rem;\n font-weight: 500;\n background: var(--bg-inset, rgba(0,0,0,0.08));\n color: var(--fg-muted);\n text-transform: capitalize;\n letter-spacing: 0.02em;\n}\n\n.sl-won-amt {\n font-family: var(--font-mono, monospace);\n font-size: 0.8rem;\n font-weight: 600;\n color: var(--live, hsl(150,48%,52%));\n}\n\n/* ── Kanban domain overrides ────────────────────────────────────────────────── */\n\n/* Stage dot colours per the screen doc */\n.kb-col[data-stage=\"lead\"] .kb-col-dot { background: var(--fg-faint, rgba(128,128,128,0.4)); }\n.kb-col[data-stage=\"qualified\"] .kb-col-dot { background: var(--systemic, hsl(140,55%,42%)); }\n.kb-col[data-stage=\"proposal\"] .kb-col-dot { background: var(--primary, hsl(215,70%,55%)); }\n.kb-col[data-stage=\"negotiation\"] .kb-col-dot { background: var(--achievement, hsl(38,85%,52%)); }\n.kb-col[data-stage=\"closing\"] .kb-col-dot { background: var(--live, hsl(348,72%,52%)); }\n\n/* Deal row ux-mode dots */\n.ux-dot {\n display: inline-block;\n width: 7px;\n height: 7px;\n border-radius: 50%;\n flex-shrink: 0;\n margin-right: 4px;\n}\n.ux-dot.ux-confirm { background: var(--achievement, hsl(38,85%,52%)); }\n.ux-dot.ux-silent { background: var(--fg-faint, rgba(128,128,128,0.4)); }\n.ux-dot.ux-approve { background: var(--primary, hsl(215,70%,55%)); }\n\n/* Next-action chip in list rows */\n.next-chip {\n display: inline-flex;\n align-items: center;\n gap: 3px;\n font-size: 0.68rem;\n color: var(--fg-muted);\n background: var(--bg-inset, rgba(0,0,0,0.06));\n border-radius: 4px;\n padding: 2px 6px;\n max-width: 180px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n/* Stage chip in detail pane */\n.stage-chip {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n font-size: 0.7rem;\n font-weight: 600;\n padding: 3px 8px;\n border-radius: 5px;\n background: var(--bg-inset, rgba(0,0,0,0.07));\n color: var(--fg-muted);\n text-transform: capitalize;\n letter-spacing: 0.03em;\n}\n\n/* Hot filter (Closing soon — achievement tint) */\n.nav-item.is-hot .nav-item-count {\n color: var(--achievement, hsl(38,85%,52%));\n font-weight: 700;\n}\n\n/* Deal amount in list rows */\n.split-row-amt {\n font-family: var(--font-mono, monospace);\n font-size: 0.78rem;\n font-weight: 600;\n color: var(--fg);\n white-space: nowrap;\n}\n\n/* Age / urgency */\n.split-row-when {\n font-size: 0.68rem;\n color: var(--fg-faint, var(--fg-muted));\n font-family: var(--font-mono, monospace);\n white-space: nowrap;\n}\n\n.split-row-when.is-urgent {\n color: var(--danger, hsl(0,65%,52%));\n}\n\n/* F-03 · Selected-row accent rail → explicit warm-primary (design uses\n var(--primary), not the sessions amber-ochre --tint-fg-active). */\n.split-row.is-selected .split-row-accent,\n.split-row.is-on .split-row-accent {\n background: var(--primary, hsl(20,50%,34%));\n border-radius: 2px;\n}\n\n/* F-02 · Pipeline list-pane head — sticky Fraunces \"Sales\" title + mono\n count above the stage groups. Ported from the design's .pl-list-head /\n .pl-list-title / .pl-list-meta (not an app-kit primitive). */\n.sl-list-head {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: var(--space-3, 12px) var(--space-4, 16px);\n border-bottom: 1px solid var(--border-soft);\n position: sticky;\n top: 0;\n background: var(--bg-surface);\n z-index: 6;\n}\n\n.sl-list-title {\n font-family: var(--font-display, Georgia, serif);\n font-weight: 500;\n font-size: var(--text-h3, 18px);\n color: var(--fg);\n}\n\n.sl-list-meta {\n font-family: var(--font-mono, monospace);\n font-size: 10.5px;\n color: var(--fg-faint, var(--fg-muted));\n letter-spacing: 0.04em;\n}\n\n/* F-05 · Detail-pane title → Fraunces 28px (design spec). The app-kit\n primitive sets Fraunces at --text-h2 (24px); sales bumps it to 28px to\n match the locked sales design's .pl-detail-title. */\n.split-detail-title {\n font-family: var(--font-display, Georgia, serif);\n font-size: 28px;\n font-weight: 500;\n line-height: 1.15;\n letter-spacing: -0.012em;\n color: var(--fg);\n margin: 0 0 var(--space-2, 8px);\n}\n\n/* F-06 / F-07 / F-08 · The facts grid (3-col card), next-action card\n (live-tinted border + bg), and next-action head (UPPERCASE mono 10px)\n are all consumed verbatim from the app-kit primitives (.split-facts,\n .split-next, .split-next-head) — no sales-local override. The previous\n 2-col plain-grid / un-tinted overrides were drift and have been removed\n so the kit's design-correct rules apply. */\n\n/* Activity timeline in detail pane */\n.split-timeline {\n display: flex;\n flex-direction: column;\n gap: 8px;\n margin-top: 12px;\n}\n\n.split-tl-row {\n display: flex;\n gap: 10px;\n align-items: flex-start;\n}\n\n.split-tl-dot {\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: var(--fg-faint, rgba(128,128,128,0.4));\n flex-shrink: 0;\n margin-top: 5px;\n}\n\n.split-tl-body {\n flex: 1;\n}\n\n.split-tl-title {\n font-size: 0.78rem;\n color: var(--fg);\n}\n\n.split-tl-when {\n font-size: 0.65rem;\n color: var(--fg-faint, var(--fg-muted));\n font-family: var(--font-mono, monospace);\n}\n\n/* Seg toggle (list/kanban) injected into sidebar nav-group */\n.nav-view-seg {\n margin: 4px 8px 6px;\n}\n\n/* Kanban board horizontal scroll mask */\n.kb-board-wrap {\n overflow-x: auto;\n height: 100%;\n -webkit-mask-image: linear-gradient(to right, black calc(100% - 40px), transparent 100%);\n mask-image: linear-gradient(to right, black calc(100% - 40px), transparent 100%);\n}\n"
package/dist/lib/ui.js CHANGED
@@ -1,5 +1,12 @@
1
1
  // React + htm via esm.sh — no JSX transpile needed. Forkers edit JS, reload.
2
2
  //
3
+ // SOURCE OF TRUTH (WP-19). This is the single, byte-identical UI runtime vendored
4
+ // into every no-build app pkg's dist/lib/ui.js. It supersedes the ~80%-shared
5
+ // per-pkg copies; the only thing that used to diverge was each pkg's slice of the
6
+ // icon dictionary, so this file carries the UNION of every pkg's glyphs (57) — a
7
+ // pkg simply ignores the names it never references. Never hand-edit a vendored
8
+ // copy; edit here and re-run the pkg's scripts/build.mjs.
9
+ //
3
10
  // htm uses tagged template literals to render React elements. Same mental model
4
11
  // as JSX (component tags, expressions in ${}), just without a build step.
5
12
 
@@ -38,30 +45,66 @@ export {
38
45
  };
39
46
 
40
47
  // Tiny icon helper — lucide-static SVG paths inlined as needed to avoid an
41
- // extra CDN hop (the source app used lucide-react; we mirror suite's inline
42
- // pattern instead). Add more glyphs as features need them.
48
+ // extra CDN hop (the source apps used lucide-react; we mirror suite's inline
49
+ // pattern instead). WP-19 UNION of every pkg's glyphs; add more as features need.
43
50
  const ICONS = {
44
- // Source app glyphs (lucide path data):
45
- 'check-square': 'M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11',
46
- 'calendar-days':
47
- 'M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zM8 14h.01M12 14h.01M16 14h.01M8 18h.01M12 18h.01M16 18h.01',
48
- stethoscope:
49
- 'M11 2v2M5 2v2M5 3H4a2 2 0 0 0-2 2v4a6 6 0 0 0 12 0V5a2 2 0 0 0-2-2h-1M8 15a6 6 0 0 0 12 0v-3M20 10a2 2 0 1 0 0-4 2 2 0 0 0 0 4z',
50
- search: 'M11 17.5a6.5 6.5 0 1 0 0-13 6.5 6.5 0 0 0 0 13zM21 21l-4.35-4.35',
51
- plus: 'M5 12h14M12 5v14',
52
- 'chevron-down': 'M6 9l6 6 6-6',
53
- 'alert-circle': 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 8v4M12 16h.01',
54
- loader: 'M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83',
55
- check: 'M20 6L9 17l-5-5',
56
- 'check-circle': 'M22 11.08V12a10 10 0 1 1-5.93-9.14M22 4L12 14.01l-3-3',
57
- mail: 'M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zM22 6l-10 7L2 6',
58
- terminal: 'M4 17l6-6-6-6M12 19h8',
59
- 'git-branch': 'M6 3v12M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 9a9 9 0 0 1-9 9',
60
- // ViewTabs glyphs for Sweeper + Done. `broom` mirrors the design's SWEEP
61
- // glyph (a broom strokes + handle); `check-check` is the lucide name for
62
- // the "double check" used for completed items.
63
- broom: 'M9.8 12.2 5 17l3 3 4.8-4.8M14 8l-3.5 3.5 4 4L18 12l-4-4z M14 8l5-5M19 3l2 2',
64
- 'check-check': 'M18 6 7 17l-5-5M22 10l-7.5 7.5L13 16',
51
+ activity: "M22 12h-4l-3 9L9 3l-3 9H2",
52
+ 'alert-circle': "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 8v4M12 16h.01",
53
+ 'alert-triangle': "M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4M12 17h.01",
54
+ archive: "M21 8v13H3V8M1 3h22v5H1zM10 12h4",
55
+ 'arrow-down': "M12 5v14M19 12l-7 7-7-7",
56
+ 'arrow-left': "M19 12H5M12 5l-7 7 7 7",
57
+ 'arrow-right': "M5 12h14M12 5l7 7-7 7",
58
+ 'bar-chart-2': "M18 20V10M12 20V4M6 20v-6",
59
+ 'book-open': "M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z",
60
+ broom: "M9.8 12.2 5 17l3 3 4.8-4.8M14 8l-3.5 3.5 4 4L18 12l-4-4z M14 8l5-5M19 3l2 2",
61
+ calendar: "M3 4h18v18H3V4zM16 2v4M8 2v4M3 10h18",
62
+ 'calendar-days': "M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zM8 14h.01M12 14h.01M16 14h.01M8 18h.01M12 18h.01M16 18h.01",
63
+ check: "M20 6L9 17l-5-5",
64
+ 'check-check': "M18 6 7 17l-5-5M22 10l-7.5 7.5L13 16",
65
+ 'check-circle': "M22 11.08V12a10 10 0 1 1-5.93-9.14M22 4L12 14.01l-3-3",
66
+ 'check-square': "M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11",
67
+ 'chevron-down': "M6 9l6 6 6-6",
68
+ 'chevron-left': "M15 18l-6-6 6-6",
69
+ 'chevron-right': "M9 18l6-6-6-6",
70
+ 'chevron-up': "M18 15l-6-6-6 6",
71
+ clock: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 6v6l4 2",
72
+ 'corner-down-left': "M9 10l-5 5 5 5M4 15h7a4 4 0 0 0 4-4V5",
73
+ 'edit-3': "M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z",
74
+ eye: "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8zM12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z",
75
+ 'file-text': "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M16 13H8M16 17H8M10 9H8",
76
+ filter: "M22 3H2l8 9.46V19l4 2v-8.54L22 3z",
77
+ 'git-branch': "M6 3v12M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 9a9 9 0 0 1-9 9",
78
+ globe: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z",
79
+ inbox: "M22 12h-6l-2 3H10l-2-3H2M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z",
80
+ linkedin: "M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6zM2 9h4v12H2zM4 6a2 2 0 1 0 0-4 2 2 0 0 0 0 4z",
81
+ list: "M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01",
82
+ loader: "M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83",
83
+ mail: "M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zM22 6l-10 7L2 6",
84
+ 'more-horizontal': "M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2zM19 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2zM5 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z",
85
+ pause: "M6 4h4v16H6zM14 4h4v16h-4z",
86
+ people: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75",
87
+ 'play-circle': "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM10 8l6 4-6 4V8z",
88
+ plus: "M5 12h14M12 5v14",
89
+ radio: "M12 12m-2 0a2 2 0 1 0 4 0a2 2 0 1 0-4 0M4.93 4.93a10 10 0 0 0 0 14.14M19.07 4.93a10 10 0 0 1 0 14.14M7.76 7.76a6 6 0 0 0 0 8.49M16.24 7.76a6 6 0 0 1 0 8.49",
90
+ 'refresh-cw': "M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15",
91
+ search: "M11 17.5a6.5 6.5 0 1 0 0-13 6.5 6.5 0 0 0 0 13zM21 21l-4.35-4.35",
92
+ send: "M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z",
93
+ share2: "M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98M21 5a3 3 0 1 0-6 0 3 3 0 0 0 6 0zM9 12a3 3 0 1 0-6 0 3 3 0 0 0 6 0zM21 19a3 3 0 1 0-6 0 3 3 0 0 0 6 0z",
94
+ 'skip-forward': "M5 4l10 8-10 8V4zM19 5v14",
95
+ star: "M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z",
96
+ stethoscope: "M11 2v2M5 2v2M5 3H4a2 2 0 0 0-2 2v4a6 6 0 0 0 12 0V5a2 2 0 0 0-2-2h-1M8 15a6 6 0 0 0 12 0v-3M20 10a2 2 0 1 0 0-4 2 2 0 0 0 0 4z",
97
+ tag: "M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82zM7 7h.01",
98
+ terminal: "M4 17l6-6-6-6M12 19h8",
99
+ timer: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 6v6M16.24 16.24l-4.24-4.24M4.93 4.93l14.14 14.14",
100
+ trash: "M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6",
101
+ 'trending-up': "M23 6l-9.5 9.5-5-5L1 18M17 6h6v6",
102
+ twitter: "M23 3a10.9 10.9 0 0 1-3.14 1.53 4.48 4.48 0 0 0-7.86 3v1A10.66 10.66 0 0 1 3 4s-4 9 5 13a11.64 11.64 0 0 1-7 2c9 5 20 0 20-11.5a4.5 4.5 0 0 0-.08-.83A7.72 7.72 0 0 0 23 3z",
103
+ user: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z",
104
+ users: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75",
105
+ x: "M18 6L6 18M6 6l12 12",
106
+ 'x-circle': "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM15 9l-6 6M9 9l6 6",
107
+ zap: "M13 2L3 14h9l-1 8 10-12h-9l1-8z",
65
108
  };
66
109
 
67
110
  // Class-name joiner — replaces clsx + tailwind-merge. With Tailwind gone there
@@ -80,7 +123,7 @@ export function cn(...inputs) {
80
123
  }
81
124
 
82
125
  // Button — ported from src/components/Button.tsx. Tailwind utility classes
83
- // became .tk-btn / sz-* / v-* rules in tasks.css.
126
+ // became .tk-btn / sz-* / v-* rules in the pkg's domain CSS.
84
127
  export function Button({ variant = 'default', size = 'md', class: cls = '', children, ...props }) {
85
128
  const className = ['tk-btn', `sz-${size}`, `v-${variant}`, cls]
86
129
  .filter(Boolean)
@@ -105,3 +148,30 @@ export function Icon({ name, size = 16, className, strokeWidth = 2 }) {
105
148
  stroke-linejoin="round"
106
149
  ><path d=${path} /></svg>`;
107
150
  }
151
+
152
+ // Auto-growing textarea hook — the body-editor pattern for outbox/detail panes.
153
+ // A fixed-height textarea with an inner scrollbar reads as a cramped form
154
+ // field; content-sized, it reads as a document the pane scrolls naturally
155
+ // (the outer pane owns overflow). Sets height to scrollHeight on mount, on
156
+ // value change, and on container resize; CSS `field-sizing: content` will
157
+ // obsolete this once WebKitGTK ships it.
158
+ // const ref = useAutoGrow(value);
159
+ // html`<textarea ref=${ref} value=${value} ... style=${{ overflow: 'hidden', resize: 'none' }}></textarea>`
160
+ export function useAutoGrow(value, { minHeight = 160 } = {}) {
161
+ const ref = React.useRef(null);
162
+ const fit = React.useCallback(() => {
163
+ const el = ref.current;
164
+ if (!el) return;
165
+ el.style.height = 'auto';
166
+ el.style.height = `${Math.max(minHeight, el.scrollHeight)}px`;
167
+ }, [minHeight]);
168
+ React.useLayoutEffect(fit, [fit, value]);
169
+ React.useEffect(() => {
170
+ const el = ref.current;
171
+ if (!el || typeof ResizeObserver === 'undefined') return;
172
+ const ro = new ResizeObserver(fit);
173
+ ro.observe(el.parentElement ?? el);
174
+ return () => ro.disconnect();
175
+ }, [fit]);
176
+ return ref;
177
+ }
package/dist/sales.css CHANGED
@@ -160,6 +160,11 @@
160
160
  align-items: center;
161
161
  gap: 4px;
162
162
  flex: 1;
163
+ /* Full-height column so the bar's percentage height resolves against the
164
+ 170px .sl-months container (mirrors the design's .fc-month { height:100% }).
165
+ Completes F-15 — without it the bar-wrap collapses to min-height and the
166
+ JS-percentage bars never scale. */
167
+ height: 100%;
163
168
  }
164
169
 
165
170
  .sl-month-bar-wrap {
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "com.ikenga.sales",
3
3
  "name": "Sales",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "ikenga_api": "1",
6
6
  "kind": "embedded",
7
7
  "author": { "name": "Royalti", "key": "royalti" },
@@ -47,7 +47,8 @@
47
47
  "sales_lead_scores",
48
48
  "contacts"
49
49
  ],
50
- "vault.keys": []
50
+ "vault.keys": [],
51
+ "engine": ["invoke"]
51
52
  },
52
53
 
53
54
  "requires": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikenga/pkg-sales",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Sales — Pipeline, Forecast, Won over the production sales schema. Multi-file iframe pkg; deterministic CSS vendoring build.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {