@synap-core/workspace-templates 0.9.2 → 0.9.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synap-core/workspace-templates",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@9.15.4",
6
6
  "description": "Canonical workspace definitions — YAML source + typed exports for browser, backend, CLI, and marketplace",
@@ -86,14 +86,13 @@ profiles:
86
86
  inputType: select
87
87
  enumValues:
88
88
  [
89
- manual,
90
- referral,
89
+ cold-outreach,
91
90
  linkedin,
92
- email,
91
+ twitter,
92
+ landing-page,
93
+ inbound-call,
94
+ referral,
93
95
  event,
94
- inbound,
95
- partner,
96
- import,
97
96
  other,
98
97
  ]
99
98
  - slug: icpScore
@@ -352,6 +351,25 @@ profiles:
352
351
  label: Notes
353
352
  valueType: string
354
353
  inputType: textarea
354
+ # `campaign-type` is the campaign's NATURE/phase, orthogonal to
355
+ # `campaign-channel` (the delivery medium). scoping/outreach/live have no
356
+ # channel equivalent, so folding them into `campaign-channel` would lose
357
+ # them — they get their own def. Kept verbatim across the three campaign
358
+ # declarations (marketing / content-os / here) so there is no drift.
359
+ - slug: campaign-type
360
+ label: Type
361
+ valueType: string
362
+ inputType: select
363
+ enumValues:
364
+ [scoping, outreach, live, partnership, event, other]
365
+ constraints: { defaultValue: outreach }
366
+ # `campaign-color` backs the per-campaign colour the CRM surfaces render.
367
+ # Stored as a palette NAME (see apps/crm/lib/campaignColors.ts), not a hex.
368
+ - slug: campaign-color
369
+ label: Color
370
+ valueType: string
371
+ inputType: select
372
+ enumValues: [indigo, violet, amber, cyan, pink, teal]
355
373
 
356
374
  entityLinks:
357
375
  # The existing generic `linked_to_deal` relation remains the deal ↔
@@ -623,6 +623,25 @@ profiles:
623
623
  label: Notes
624
624
  valueType: string
625
625
  inputType: textarea
626
+ # `campaign-type` is the campaign's NATURE/phase, orthogonal to
627
+ # `campaign-channel` (the delivery medium). scoping/outreach/live have no
628
+ # channel equivalent, so folding them into `campaign-channel` would lose
629
+ # them — they get their own def. Kept verbatim across the three campaign
630
+ # declarations (business-developer / marketing / here) so there is no drift.
631
+ - slug: campaign-type
632
+ label: Type
633
+ valueType: string
634
+ inputType: select
635
+ enumValues:
636
+ [scoping, outreach, live, partnership, event, other]
637
+ constraints: { defaultValue: outreach }
638
+ # `campaign-color` backs the per-campaign colour the CRM surfaces render.
639
+ # Stored as a palette NAME (see apps/crm/lib/campaignColors.ts), not a hex.
640
+ - slug: campaign-color
641
+ label: Color
642
+ valueType: string
643
+ inputType: select
644
+ enumValues: [indigo, violet, amber, cyan, pink, teal]
626
645
 
627
646
  # ── Sponsor (shared ecosystem role — SSOT is foundation.yaml) ─────────────
628
647
  # `scope: shared` dedups to the ONE pod-wide `sponsor` role seeded by
package/src/crm.yaml CHANGED
@@ -18,6 +18,14 @@ meta:
18
18
  - contacts
19
19
  isPublic: true
20
20
 
21
+ # Canonical hosted surface. Browser resolves auth from appId and launches this
22
+ # URL with non-secret workspace/Pod context; CRM establishes its own session.
23
+ app:
24
+ kind: url
25
+ appId: crm
26
+ url: https://crm.synap.live
27
+ title: CRM
28
+
21
29
  # SSOT ordering guarantee — this template REFERENCES foundation's pod-wide
22
30
  # shared `client`/`partner`/`lead`/`churned` roles below. The apply engine seeds
23
31
  # a shared profile's pod-wide BASE property body from whichever template
@@ -79,7 +87,16 @@ profiles:
79
87
  - slug: source
80
88
  label: Source
81
89
  valueType: string
82
- inputType: text
90
+ inputType: select
91
+ enumValues:
92
+ - cold-outreach
93
+ - linkedin
94
+ - twitter
95
+ - landing-page
96
+ - inbound-call
97
+ - referral
98
+ - event
99
+ - other
83
100
  - slug: icpScore
84
101
  label: ICP score
85
102
  valueType: number
@@ -141,7 +158,16 @@ profiles:
141
158
  - slug: source
142
159
  label: Source
143
160
  valueType: string
144
- inputType: text
161
+ inputType: select
162
+ enumValues:
163
+ - cold-outreach
164
+ - linkedin
165
+ - twitter
166
+ - landing-page
167
+ - inbound-call
168
+ - referral
169
+ - event
170
+ - other
145
171
  - slug: icpScore
146
172
  label: ICP score
147
173
  valueType: number
@@ -438,6 +464,72 @@ entityLinks:
438
464
  targetProfileSlug: deal
439
465
  type: linked_to_deal
440
466
 
467
+ onboarding:
468
+ goal: >
469
+ Turn the user's real commercial motion into a usable pipeline — the right
470
+ companies and people, their active opportunities, and an explicit next
471
+ step for every deal — so CRM becomes the place sales work moves forward.
472
+ framing: >
473
+ THE REVENUE OPERATOR: Act as a pragmatic sales lead setting up a CRM people
474
+ will actually maintain. Start from the user's existing motion, vocabulary,
475
+ and active conversations. Resolve before creating so imported or Pod-wide
476
+ people are reused rather than duplicated. Push for a concrete next step and
477
+ date; never invent revenue, probability, or contact details.
478
+ expertise:
479
+ starters:
480
+ - >
481
+ A useful first pipeline is deliberately small. Start with the few stages
482
+ that change how the team acts — contacted, qualifying, proposal,
483
+ negotiating, won, and lost — and keep the template's default stages
484
+ unless the user's real process needs a different view.
485
+ - >
486
+ Populate the workspace with live work, not a perfect historical archive:
487
+ propose the three to five opportunities the user is actively trying to
488
+ move, then add the companies and people needed to understand those deals.
489
+ - >
490
+ Every active deal needs one observable next step with a date. If the next
491
+ step is "follow up" or "keep warm", help turn it into a specific action
492
+ such as "send the revised proposal Thursday" before capturing it.
493
+ blindSpots:
494
+ - >
495
+ A deal without a real person is usually an account-shaped hope. If an
496
+ opportunity has no stakeholder, surface the gap instead of fabricating a
497
+ placeholder contact.
498
+ - >
499
+ Pipelines become fiction when stages describe confidence rather than an
500
+ event the buyer completed. Ask what evidence moves a deal into its current
501
+ stage, especially around proposal and negotiation.
502
+ - >
503
+ More fields do not create better sales hygiene. Prioritize stage, value
504
+ when known, expected close date, and next step; leave unverifiable fields
505
+ empty and offer enrichment separately.
506
+ bar: >
507
+ A sales lead can open the pipeline and immediately see the few live
508
+ opportunities, the real company and stakeholder behind each one, its
509
+ evidence-based stage, and the dated next action — with no duplicate people
510
+ and no invented commercial data.
511
+ collect:
512
+ - profileSlug: company
513
+ what: The real prospect organisations behind current conversations.
514
+ cardinality: few
515
+ keyFields: [website, industry, companySize, source, nextStep]
516
+ - profileSlug: person
517
+ what: The known stakeholders for those organisations and opportunities.
518
+ cardinality: few
519
+ keyFields: [email, jobTitle, company, source, nextFollowUpDate]
520
+ - profileSlug: deal
521
+ what: The live opportunities worth actively moving through the pipeline.
522
+ cardinality: few
523
+ keyFields: [dealStage, value, closeDate, nextStep]
524
+ openingQuestions:
525
+ - "Which three to five opportunities are you actively trying to move right now?"
526
+ - "For the most important one, which company and real stakeholder are involved?"
527
+ - "What has the buyer actually done so far, and what is the next dated action?"
528
+ doneWhen: >
529
+ At least one real opportunity is linked to its company or stakeholder and
530
+ has an evidence-based stage plus a concrete next step; the user can see how
531
+ to add or import the rest without duplicating existing people.
532
+
441
533
  # Playbook — enriches a lead/prospect from public sources (company site,
442
534
  # LinkedIn, firmographic signal) so ICP scoring and qualification start from
443
535
  # real data instead of a blank record. Bound to `lead` so a newly captured
@@ -0,0 +1,183 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ normalizeTemplateLayout,
4
+ toPackageDefinition,
5
+ toWorkspaceDefinition,
6
+ } from './define.js';
7
+ import type { WorkspaceYaml } from './types.js';
8
+ import { validateTemplate } from './validate.js';
9
+
10
+ function template(
11
+ overrides: Partial<Pick<WorkspaceYaml, 'app' | 'layout' | 'views' | 'bento'>>
12
+ ): WorkspaceYaml {
13
+ return {
14
+ meta: {
15
+ slug: 'surface-contract-test',
16
+ name: 'Surface contract test',
17
+ description: 'Surface contract fixture',
18
+ icon: 'panels-top-left',
19
+ color: 'blue',
20
+ },
21
+ workspace: {
22
+ name: 'Surface contract test',
23
+ description: 'Surface contract fixture',
24
+ },
25
+ profiles: [{ slug: 'thing', displayName: 'Thing', properties: [] }],
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ describe('workspace primary-surface conversion', () => {
31
+ it('normalizes an external app into both provisioning doors', () => {
32
+ const source = template({
33
+ app: {
34
+ kind: 'url',
35
+ appId: 'crm',
36
+ url: 'https://crm.synap.live',
37
+ title: 'CRM',
38
+ },
39
+ layout: { pinnedApps: ['data'] },
40
+ });
41
+
42
+ const expected = {
43
+ kind: 'app',
44
+ appId: 'crm',
45
+ rendererType: 'external',
46
+ url: 'https://crm.synap.live',
47
+ title: 'CRM',
48
+ };
49
+
50
+ expect(toWorkspaceDefinition(source).definition.layoutConfig).toMatchObject(
51
+ {
52
+ pinnedApps: ['data'],
53
+ primarySurface: expected,
54
+ }
55
+ );
56
+ expect(toPackageDefinition(source).layoutConfig).toMatchObject({
57
+ pinnedApps: ['data'],
58
+ primarySurface: expected,
59
+ });
60
+ });
61
+
62
+ it('ships the generated CRM template as a hosted primary surface', () => {
63
+ expect(
64
+ toWorkspaceDefinition('crm').definition.layoutConfig?.primarySurface
65
+ ).toEqual({
66
+ kind: 'app',
67
+ appId: 'crm',
68
+ rendererType: 'external',
69
+ url: 'https://crm.synap.live',
70
+ title: 'CRM',
71
+ });
72
+ });
73
+
74
+ it('keeps legacy defaultApp as a native compatibility input', () => {
75
+ expect(
76
+ normalizeTemplateLayout({ defaultApp: 'dashboard' }, undefined)
77
+ ).toMatchObject({
78
+ defaultApp: 'dashboard',
79
+ primarySurface: {
80
+ kind: 'app',
81
+ appId: 'dashboard',
82
+ rendererType: 'native',
83
+ },
84
+ });
85
+ });
86
+
87
+ it('keeps an authored view name unresolved through both provisioning doors', () => {
88
+ const source = template({
89
+ views: [
90
+ {
91
+ name: 'Pipeline',
92
+ slug: 'pipeline',
93
+ type: 'kanban',
94
+ scopeProfileSlug: 'thing',
95
+ },
96
+ ],
97
+ layout: {
98
+ primarySurface: {
99
+ kind: 'view',
100
+ viewName: 'Pipeline',
101
+ viewSlug: 'pipeline',
102
+ },
103
+ },
104
+ });
105
+
106
+ expect(toWorkspaceDefinition(source).definition.layoutConfig).toMatchObject(
107
+ {
108
+ primarySurface: {
109
+ kind: 'view',
110
+ viewName: 'Pipeline',
111
+ viewSlug: 'pipeline',
112
+ },
113
+ }
114
+ );
115
+ expect(toPackageDefinition(source).layoutConfig).toMatchObject({
116
+ primarySurface: {
117
+ kind: 'view',
118
+ viewName: 'Pipeline',
119
+ viewSlug: 'pipeline',
120
+ },
121
+ });
122
+ expect(validateTemplate(source).ok).toBe(true);
123
+ });
124
+
125
+ it('rejects an unknown authored primary view before provisioning', () => {
126
+ const source = template({
127
+ views: [{ name: 'Pipeline', type: 'kanban' }],
128
+ layout: {
129
+ primarySurface: { kind: 'view', viewName: 'Missing' },
130
+ },
131
+ });
132
+
133
+ expect(validateTemplate(source).errors).toContainEqual(
134
+ expect.objectContaining({ rule: 'primary-view-reference-unknown' })
135
+ );
136
+ });
137
+
138
+ it('does not activate historical entity-detail placeholders without identity', () => {
139
+ expect(
140
+ normalizeTemplateLayout(
141
+ { defaultApp: null },
142
+ { kind: 'stored', cellKey: 'entity-detail' }
143
+ )
144
+ ).toMatchObject({
145
+ defaultApp: null,
146
+ primarySurface: null,
147
+ });
148
+ });
149
+
150
+ it('rejects two competing authoring sources', () => {
151
+ const source = template({
152
+ app: { kind: 'native', appId: 'dashboard' },
153
+ layout: {
154
+ primarySurface: {
155
+ kind: 'app',
156
+ appId: 'data',
157
+ rendererType: 'native',
158
+ },
159
+ },
160
+ });
161
+
162
+ expect(validateTemplate(source).errors).toContainEqual(
163
+ expect.objectContaining({ rule: 'primary-surface-authoring-conflict' })
164
+ );
165
+ expect(() => toWorkspaceDefinition(source)).toThrow(
166
+ /both top-level app and layout\.primarySurface/
167
+ );
168
+ });
169
+
170
+ it('rejects a hosted app that cannot be authorized by application id', () => {
171
+ const source = template({
172
+ app: { kind: 'url', url: 'https://crm.synap.live' },
173
+ layout: {},
174
+ });
175
+
176
+ expect(validateTemplate(source).errors).toContainEqual(
177
+ expect.objectContaining({ rule: 'app-url-identity-required' })
178
+ );
179
+ expect(() => toWorkspaceDefinition(source)).toThrow(
180
+ /requires both appId and url/
181
+ );
182
+ });
183
+ });
package/src/define.ts CHANGED
@@ -10,8 +10,12 @@
10
10
  * shape never drifts per call-site.
11
11
  */
12
12
 
13
- import type { TemplateAutomation, TemplateBentoBlock, WorkspaceYaml } from "./types.js";
14
- import { getWorkspaceTemplate } from "./templates.js";
13
+ import type {
14
+ TemplateAppRef, TemplateAutomation, TemplateBentoBlock,
15
+ TemplateLayoutConfig,
16
+ WorkspacePrimarySurface,
17
+ WorkspaceYaml, } from './types.js';
18
+ import { getWorkspaceTemplate } from './templates.js';
15
19
 
16
20
  /**
17
21
  * A `TemplateAutomation` as the apply door expects it: `flowDefinition`, not
@@ -25,12 +29,12 @@ export interface PackageAutomationWire {
25
29
  description?: string;
26
30
  triggerType: TemplateAutomation['triggerType'];
27
31
  triggerConfig?: Record<string, unknown>;
28
- flowDefinition?: { nodes: unknown[]; edges: unknown[]; precondition?: string };
32
+ flowDefinition?: { nodes: unknown[]; edges: unknown[]; precondition?: string; };
29
33
  status?: TemplateAutomation['status'];
30
34
  }
31
35
 
32
36
  function toAutomationWire(
33
- automations: TemplateAutomation[] | undefined,
37
+ automations: TemplateAutomation[] | undefined
34
38
  ): PackageAutomationWire[] | undefined {
35
39
  return automations?.map((a) => ({
36
40
  key: a.key,
@@ -63,6 +67,122 @@ export interface SplitBentoBlocks {
63
67
  bentoViewBlocks: BentoViewBlockWire[];
64
68
  }
65
69
 
70
+ const hasOwn = <K extends PropertyKey>(
71
+ value: object,
72
+ key: K
73
+ ): value is Record<K, unknown> =>
74
+ Object.prototype.hasOwnProperty.call(value, key);
75
+
76
+ /**
77
+ * Convert the legacy top-level `app:` authoring shorthand into the persisted
78
+ * workspace surface contract. The conversion is deliberately conservative:
79
+ * an external app without an application id cannot pass the host's
80
+ * application/origin authorization, and the historical `entity-detail`
81
+ * placeholders have no entity identity to render.
82
+ */
83
+ function appRefToPrimarySurface(
84
+ app: TemplateAppRef | undefined
85
+ ): WorkspacePrimarySurface | undefined {
86
+ if (!app) return undefined;
87
+
88
+ if (app.kind === 'native') {
89
+ if (!app.appId) {
90
+ throw new Error('Workspace template native app is missing appId');
91
+ }
92
+ return {
93
+ kind: 'app',
94
+ appId: app.appId,
95
+ rendererType: 'native',
96
+ ...(app.title ? { title: app.title } : {}),
97
+ ...(app.props ? { props: app.props } : {}),
98
+ };
99
+ }
100
+
101
+ if (app.kind === 'url') {
102
+ if (!app.appId || !app.url) {
103
+ throw new Error(
104
+ 'Workspace template URL app requires both appId and url'
105
+ );
106
+ }
107
+ return {
108
+ kind: 'app',
109
+ appId: app.appId,
110
+ rendererType: 'external',
111
+ url: app.url,
112
+ ...(app.title ? { title: app.title } : {}),
113
+ ...(app.props ? { props: app.props } : {}),
114
+ };
115
+ }
116
+
117
+ if (app.kind === 'stored') {
118
+ if (!app.cellKey) {
119
+ throw new Error('Workspace template stored app is missing cellKey');
120
+ }
121
+ // Twenty-two historical templates carry this inert placeholder without an
122
+ // entity id. Activating it would open an unusable detail cell on entry.
123
+ if (app.cellKey === 'entity-detail' && !app.props?.entityId) {
124
+ return undefined;
125
+ }
126
+ return {
127
+ kind: 'cell',
128
+ cellKey: app.cellKey,
129
+ ...(app.title ? { title: app.title } : {}),
130
+ ...(app.props ? { props: app.props } : {}),
131
+ };
132
+ }
133
+
134
+ return undefined;
135
+ }
136
+
137
+ /**
138
+ * Produce the canonical persisted layout. A template may author either the
139
+ * top-level `app:` shorthand or an explicit `layout.primarySurface`, never both.
140
+ * Legacy `defaultApp` is the final compatibility fallback. Keeping the property
141
+ * absent preserves a live workspace value during reconciliation; an authored
142
+ * null is retained as an explicit clear.
143
+ */
144
+ export function normalizeTemplateLayout(
145
+ layout: TemplateLayoutConfig | undefined,
146
+ app: TemplateAppRef | undefined
147
+ ): TemplateLayoutConfig | undefined {
148
+ if (app && layout && hasOwn(layout, 'primarySurface')) {
149
+ throw new Error(
150
+ 'Workspace template declares both top-level app and layout.primarySurface; choose one authoring source'
151
+ );
152
+ }
153
+
154
+ const primarySurface = (() => {
155
+ if (layout && hasOwn(layout, 'primarySurface')) {
156
+ return layout.primarySurface;
157
+ }
158
+
159
+ const fromApp = appRefToPrimarySurface(app);
160
+ if (fromApp) return fromApp;
161
+
162
+ if (layout && hasOwn(layout, 'defaultApp')) {
163
+ return layout.defaultApp
164
+ ? {
165
+ kind: 'app' as const,
166
+ appId: layout.defaultApp,
167
+ rendererType: 'native' as const,
168
+ }
169
+ : null;
170
+ }
171
+
172
+ return undefined;
173
+ })();
174
+
175
+ if (!layout && primarySurface === undefined) return undefined;
176
+
177
+ return {
178
+ ...layout,
179
+ ...(primarySurface !== undefined ||
180
+ (layout && hasOwn(layout, 'primarySurface'))
181
+ ? { primarySurface }
182
+ : {}),
183
+ };
184
+ }
185
+
66
186
  /**
67
187
  * Split the authored `bento.blocks` DISCRIMINATED UNION into the TWO wire
68
188
  * fields the pod expects. This is the whole point: `TemplateBentoBlock` is one
@@ -229,12 +349,14 @@ export interface ToDefinitionResult {
229
349
  * or selected value string. Keep this conversion at the shared template seam
230
350
  * so browser creation and package application cannot silently disagree.
231
351
  */
232
- function toWorkspacePlaybooks(playbooks: WorkspaceYaml["playbooks"]): Array<Record<string, unknown>> | undefined {
352
+ function toWorkspacePlaybooks(
353
+ playbooks: WorkspaceYaml['playbooks']
354
+ ): Array<Record<string, unknown>> | undefined {
233
355
  return playbooks?.map((playbook) => ({
234
356
  ...playbook,
235
357
  params: playbook.params?.map((param) => ({
236
358
  name: param.name,
237
- type: param.type === "number" || param.type === "boolean" ? param.type : "string",
359
+ type: param.type === 'number' || param.type === 'boolean' ? param.type : 'string',
238
360
  ...(param.defaultValue !== undefined ? { default: param.defaultValue } : {}),
239
361
  ...(param.label
240
362
  ? { description: param.label }
@@ -249,7 +371,7 @@ function toWorkspacePlaybooks(playbooks: WorkspaceYaml["playbooks"]): Array<Reco
249
371
  */
250
372
  export function toWorkspaceDefinition(
251
373
  slugOrYaml: string | WorkspaceYaml,
252
- nameOverride?: string,
374
+ nameOverride?: string
253
375
  ): ToDefinitionResult {
254
376
  const tpl: WorkspaceYaml | undefined =
255
377
  typeof slugOrYaml === 'string' ? getWorkspaceTemplate(slugOrYaml) : slugOrYaml;
@@ -276,7 +398,7 @@ export function toWorkspaceDefinition(
276
398
  seedEntities: tpl.seedEntities ?? [],
277
399
  suggestedRelations: tpl.suggestedRelations,
278
400
  displayTemplates: tpl.displayTemplates,
279
- layoutConfig: tpl.layout,
401
+ layoutConfig: normalizeTemplateLayout(tpl.layout, tpl.app),
280
402
  bentoViewName: tpl.bento?.viewName,
281
403
  bentoLayout: bento.bentoLayout,
282
404
  bentoViewBlocks: bento.bentoViewBlocks,
@@ -315,7 +437,7 @@ export function toWorkspaceDefinition(
315
437
  dependencies: tpl.dependencies,
316
438
  };
317
439
 
318
- return { definition, proposalId, workspaceName: name, templateId: tpl.meta.slug };
440
+ return { definition, proposalId, workspaceName: name, templateId: tpl.meta.slug, };
319
441
  }
320
442
 
321
443
  // ─── Exhaustiveness ledger: toWorkspaceDefinition ────────────────────────────
@@ -366,14 +488,11 @@ type WorkspaceDefinitionForwarded =
366
488
  * else is a real gap and should say so.
367
489
  */
368
490
  type WorkspaceDefinitionDropped =
369
- // UI-only: `workspaces.createFromDefinition` has no `app` field. Consumers
370
- // read `template.app` straight off the YAML to pick the first screen, so this
371
- // never belonged on the wire.
491
+ // Normalized into `layoutConfig.primarySurface`.
372
492
  | 'app'
373
493
  // The door DOES accept `extends`, but no template declares it today (0/21).
374
494
  // Forwarding is a no-op until one does — wire it then.
375
- | 'extends'
376
- ;
495
+ | 'extends';
377
496
 
378
497
  type UnclassifiedWorkspaceDefinitionKey = Exclude<
379
498
  keyof WorkspaceYaml,
@@ -383,7 +502,9 @@ type UnclassifiedWorkspaceDefinitionKey = Exclude<
383
502
  // If this errors, a new `WorkspaceYaml` field is unclassified. Forward it in
384
503
  // `toWorkspaceDefinition` and add it to `WorkspaceDefinitionForwarded`, or add
385
504
  // it to `WorkspaceDefinitionDropped` with a one-line WHY.
386
- const _workspaceDefinitionLedger: [UnclassifiedWorkspaceDefinitionKey] extends [never]
505
+ const _workspaceDefinitionLedger: [UnclassifiedWorkspaceDefinitionKey] extends [
506
+ never,
507
+ ]
387
508
  ? true
388
509
  : [
389
510
  'UNCLASSIFIED YAML FIELD — forward it or add it to WorkspaceDefinitionDropped:',
@@ -462,7 +583,7 @@ export interface PackageDefinitionOutput {
462
583
  * package instead of disk-stored JSON.
463
584
  */
464
585
  export function toPackageDefinition(
465
- slugOrYaml: string | WorkspaceYaml,
586
+ slugOrYaml: string | WorkspaceYaml
466
587
  ): PackageDefinitionOutput {
467
588
  const tpl: WorkspaceYaml | undefined =
468
589
  typeof slugOrYaml === 'string' ? getWorkspaceTemplate(slugOrYaml) : slugOrYaml;
@@ -502,7 +623,7 @@ export function toPackageDefinition(
502
623
  bentoViewBlocks: bento.bentoViewBlocks,
503
624
  bentoViewName: tpl.bento?.viewName,
504
625
  profileEntityBentoTemplates: tpl.profileEntityBentoTemplates,
505
- layoutConfig: tpl.layout,
626
+ layoutConfig: normalizeTemplateLayout(tpl.layout, tpl.app),
506
627
  capabilities: tpl.integrations,
507
628
  playbooks: tpl.playbooks,
508
629
  automations: toAutomationWire(tpl.automations),
@@ -541,7 +662,7 @@ type PackageDefinitionForwarded =
541
662
 
542
663
  /** Keys deliberately NOT forwarded by `toPackageDefinition`. */
543
664
  type PackageDefinitionDropped =
544
- // UI-only, same as the definition door: `PackageDefinition` has no `app`.
665
+ // Normalized into `layoutConfig.primarySurface`.
545
666
  | 'app'
546
667
  // REAL GAP: backend `PackageDefinition` DOES declare `extends`
547
668
  // (package-definition.ts:262), but `PackageDefinitionOutput` omits it. No
@@ -557,7 +678,9 @@ type UnclassifiedPackageDefinitionKey = Exclude<
557
678
  // If this errors, a new `WorkspaceYaml` field is unclassified. Forward it in
558
679
  // `toPackageDefinition` and add it to `PackageDefinitionForwarded`, or add it
559
680
  // to `PackageDefinitionDropped` with a one-line WHY.
560
- const _packageDefinitionLedger: [UnclassifiedPackageDefinitionKey] extends [never]
681
+ const _packageDefinitionLedger: [UnclassifiedPackageDefinitionKey] extends [
682
+ never,
683
+ ]
561
684
  ? true
562
685
  : [
563
686
  'UNCLASSIFIED YAML FIELD — forward it or add it to PackageDefinitionDropped:',