@owlmeans/viable-common 0.0.25 → 0.0.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +3 -3
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/agent-presentation/SKILL.md +1 -1
  4. package/agent-meta/skills/viable-common/SKILL.md +109 -8
  5. package/build/blueprint/case.d.ts.map +1 -1
  6. package/build/blueprint/case.js +58 -16
  7. package/build/blueprint/case.js.map +1 -1
  8. package/build/blueprint/resolve.d.ts.map +1 -1
  9. package/build/blueprint/resolve.js +3 -0
  10. package/build/blueprint/resolve.js.map +1 -1
  11. package/build/blueprint/types.d.ts +44 -2
  12. package/build/blueprint/types.d.ts.map +1 -1
  13. package/build/blueprint/types.js +35 -1
  14. package/build/blueprint/types.js.map +1 -1
  15. package/build/branding.d.ts +5 -1
  16. package/build/branding.d.ts.map +1 -1
  17. package/build/branding.js +6 -0
  18. package/build/branding.js.map +1 -1
  19. package/build/connect/consts.d.ts +21 -0
  20. package/build/connect/consts.d.ts.map +1 -1
  21. package/build/connect/consts.js +21 -0
  22. package/build/connect/consts.js.map +1 -1
  23. package/build/connect/entrypoints.d.ts +15 -1
  24. package/build/connect/entrypoints.d.ts.map +1 -1
  25. package/build/connect/entrypoints.js +16 -1
  26. package/build/connect/entrypoints.js.map +1 -1
  27. package/build/connect/references.d.ts +14 -1
  28. package/build/connect/references.d.ts.map +1 -1
  29. package/build/connect/references.js +4 -0
  30. package/build/connect/references.js.map +1 -1
  31. package/build/connect/schemas.d.ts +12 -1
  32. package/build/connect/schemas.d.ts.map +1 -1
  33. package/build/connect/schemas.js +27 -1
  34. package/build/connect/schemas.js.map +1 -1
  35. package/build/connect/types.d.ts +27 -0
  36. package/build/connect/types.d.ts.map +1 -1
  37. package/build/consts.d.ts +1 -1
  38. package/build/consts.d.ts.map +1 -1
  39. package/build/consts.js +3 -0
  40. package/build/consts.js.map +1 -1
  41. package/build/metadata/types.d.ts +8 -0
  42. package/build/metadata/types.d.ts.map +1 -1
  43. package/build/metadata/types.js.map +1 -1
  44. package/build/planning/helpers.d.ts +2 -1
  45. package/build/planning/helpers.d.ts.map +1 -1
  46. package/build/planning/helpers.js +5 -2
  47. package/build/planning/helpers.js.map +1 -1
  48. package/build/planning/index.d.ts +1 -0
  49. package/build/planning/index.d.ts.map +1 -1
  50. package/build/planning/index.js +1 -0
  51. package/build/planning/index.js.map +1 -1
  52. package/build/planning/landing.d.ts +25 -0
  53. package/build/planning/landing.d.ts.map +1 -0
  54. package/build/planning/landing.js +38 -0
  55. package/build/planning/landing.js.map +1 -0
  56. package/build/planning/schemas.d.ts.map +1 -1
  57. package/build/planning/schemas.js +13 -0
  58. package/build/planning/schemas.js.map +1 -1
  59. package/build/planning/types.d.ts +28 -3
  60. package/build/planning/types.d.ts.map +1 -1
  61. package/build/scaffold/consts.d.ts +8 -0
  62. package/build/scaffold/consts.d.ts.map +1 -1
  63. package/build/scaffold/consts.js +7 -0
  64. package/build/scaffold/consts.js.map +1 -1
  65. package/build/scaffold/schemas.d.ts.map +1 -1
  66. package/build/scaffold/schemas.js +257 -12
  67. package/build/scaffold/schemas.js.map +1 -1
  68. package/build/scaffold/types.d.ts +119 -4
  69. package/build/scaffold/types.d.ts.map +1 -1
  70. package/build/skills/catalogue.d.ts.map +1 -1
  71. package/build/skills/catalogue.js +128 -24
  72. package/build/skills/catalogue.js.map +1 -1
  73. package/build/skills/consts.d.ts +5 -0
  74. package/build/skills/consts.d.ts.map +1 -1
  75. package/build/skills/consts.js +8 -0
  76. package/build/skills/consts.js.map +1 -1
  77. package/build/skills/roles.d.ts.map +1 -1
  78. package/build/skills/roles.js +11 -5
  79. package/build/skills/roles.js.map +1 -1
  80. package/build/types.d.ts +30 -0
  81. package/build/types.d.ts.map +1 -1
  82. package/package.json +8 -8
  83. package/src/blueprint/case.ts +64 -16
  84. package/src/blueprint/resolve.ts +3 -0
  85. package/src/blueprint/types.ts +57 -1
  86. package/src/branding.ts +6 -0
  87. package/src/connect/consts.ts +22 -0
  88. package/src/connect/entrypoints.ts +26 -4
  89. package/src/connect/references.ts +16 -3
  90. package/src/connect/schemas.ts +33 -4
  91. package/src/connect/types.ts +28 -0
  92. package/src/consts.ts +3 -0
  93. package/src/metadata/types.ts +8 -0
  94. package/src/planning/helpers.ts +5 -2
  95. package/src/planning/index.ts +1 -0
  96. package/src/planning/landing.ts +43 -0
  97. package/src/planning/schemas.ts +13 -0
  98. package/src/planning/types.ts +29 -3
  99. package/src/scaffold/consts.ts +10 -0
  100. package/src/scaffold/schemas.ts +262 -13
  101. package/src/scaffold/types.ts +105 -5
  102. package/src/skills/catalogue.ts +129 -24
  103. package/src/skills/consts.ts +8 -0
  104. package/src/skills/roles.ts +11 -5
  105. package/src/types.ts +30 -0
  106. package/tests/blueprint.spec.ts +98 -0
  107. package/tests/branding.spec.ts +21 -1
  108. package/tests/connect-entrypoints.spec.ts +44 -5
  109. package/tests/planning.spec.ts +56 -1
  110. package/tests/scaffold.spec.ts +131 -0
  111. package/tests/skills.spec.ts +63 -2
@@ -111,11 +111,17 @@ and you are told which one with each task.
111
111
 
112
112
  [ViablePersona.VisualDesigner]: persona(`
113
113
  You are a visual ui designer specializing in writing design guidelines and design systems
114
- for web applications that loves to produce expressive and bright interfaces.
115
- Unless a specification asks for something else, you default to a modern, expressive language —
116
- gradients, glass surfaces, layered shadows, restrained motion — while keeping every surface
117
- plainly readable. What makes a design yours is the values you choose, never the techniques you
118
- leave out.
114
+ for web applications, and your interfaces are calm, precise and unmistakably the product's own.
115
+ Your house style: a white ground in light mode and a black one in dark mode; colour from ONE
116
+ product accent and never from backgrounds; depth only from hairlines, neutral tiles and a single
117
+ floating shadow; a heavy grotesk display face with tight tracking and generous whitespace carrying
118
+ the brand; motion that is small and belongs to the picture. You never reach for gradients, glass,
119
+ blur or glow, and the older decoration classes a project still defines are painted flat and are
120
+ never your tools. You change any of this only when the customer's specification explicitly asks —
121
+ it names a background colour, a dark-only or coloured theme, a brand palette, or gradients. The
122
+ product's subject matter is not such a request: a bakery is not a warm background, a bank is not a
123
+ blue one. What makes a design this product's is its accent, its type and its illustration — never
124
+ an added effect.
119
125
  This application serves four audiences — anonymous visitors, signed-in end users, the owner,
120
126
  and staff running the business process. Every screen you style belongs to exactly one of them,
121
127
  and you are told which one with each task.
package/src/types.ts CHANGED
@@ -33,6 +33,17 @@ export interface SlotConstMetadata {
33
33
  brandingPrivacyUrl: string
34
34
  brandingCredit: string
35
35
  brandingHideCreditIntent: string
36
+ /**
37
+ * The Google tag the owner attached — a `GTM-` container or a `G-`/`GT-`/`AW-`/`DC-` tag id —
38
+ * loaded by the generated application behind its cookie consent, in the preview and in
39
+ * production alike.
40
+ *
41
+ * OPTIONAL, unlike the branding keys above, and OMITTED from a push while it is unset: a slot
42
+ * publisher built before the key existed validates the signed body against a schema that does
43
+ * not declare it, strips it, and refuses the whole configuration with a 401. An unset tag must
44
+ * therefore never reach one — which only absence guarantees.
45
+ */
46
+ brandingGoogleTag?: string
36
47
  }
37
48
 
38
49
  export interface SlotSecretMetadata {
@@ -82,6 +93,25 @@ export interface SlotListMetadata {
82
93
  * hosts are always accepted and are never in this list.
83
94
  */
84
95
  allowedOrigins: string[]
96
+ /**
97
+ * Extra Content-Security-Policy source expressions the slot's runtime adds to what it serves —
98
+ * a third party a generated app legitimately needs that the platform does not already know about.
99
+ *
100
+ * OPTIONAL and omitted from a push while empty, for the reason `brandingGoogleTag` is: an older
101
+ * publisher refuses a signed body carrying a key its schema does not declare, and a slot
102
+ * provisioned before the key existed simply has no value for it. The runtime reads it
103
+ * defensively and filters every source before it reaches a header.
104
+ *
105
+ * The Google tag's hosts are NOT carried here: the publisher derives them itself from
106
+ * `brandingGoogleTag` (and, for a production build, from the record the build writes), so the
107
+ * tag and the hosts it needs cannot disagree. Nothing writes this key today; it is declared so the
108
+ * publisher's existing reader has a vocabulary entry and a schema slot.
109
+ *
110
+ * Deliberately NOT in `metadataLists`. That array enumerates the lists an OWNER stores — one
111
+ * `project-config` row each, loaded on every metadata read and pushed back verbatim — and no
112
+ * owner-facing setting stores this one.
113
+ */
114
+ cspSources?: string[]
85
115
  }
86
116
 
87
117
  export interface CustomSecretMetadata {
@@ -0,0 +1,98 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ applyBlueprintCase, BLUEPRINT_CASES, BlueprintCase, landingGatePreferenceOf, LandingGatePreference,
4
+ personaSkillsOf,
5
+ } from '../src/blueprint/index.js'
6
+ import type { Blueprint } from '../src/blueprint/index.js'
7
+ import { ViableSkill } from '../src/skills/consts.js'
8
+ import { ViablePersona } from '../src/skills/roles.js'
9
+
10
+ /** Just enough of a blueprint for a case to be applied to — the shape a test builds. */
11
+ const base = (extra: Partial<Blueprint> = {}): Blueprint => ({
12
+ id: 'test-blueprint',
13
+ title: 'Test',
14
+ technology: { id: 'ts', language: 'typescript', runtime: 'bun', extensions: ['.ts'], buildScripts: {}, packageManager: 'bun' },
15
+ stack: { id: 'owlmeans', framework: 'owlmeans', topology: {} as Blueprint['stack']['topology'], skills: [ViableSkill.TsStyle] },
16
+ template: { id: 'seed', overlay: 'seed', patches: [] },
17
+ createApp: { id: 'create-app', package: '@owlmeans/create-app', bare: true, template: 'bare' },
18
+ packages: {
19
+ id: 'packages',
20
+ capabilities: { postgres: true, kv: false, queue: false, worker: false, agents: false, validation: true },
21
+ libraries: {},
22
+ skills: [ViableSkill.ShadcnUi],
23
+ personaSkills: { [ViablePersona.BusinessAnalyst]: [ViableSkill.MainFlowFocus] },
24
+ },
25
+ ...extra,
26
+ })
27
+
28
+ describe('viable-common - the landing-gate preference', () => {
29
+ test('answers Allow for no blueprint, no layer and a value this deploy never heard of', () => {
30
+ expect(landingGatePreferenceOf()).toBe(LandingGatePreference.Allow)
31
+ expect(landingGatePreferenceOf(base())).toBe(LandingGatePreference.Allow)
32
+ expect(landingGatePreferenceOf({ experience: { landingGate: 'insist' as never } }))
33
+ .toBe(LandingGatePreference.Allow)
34
+ expect(landingGatePreferenceOf({ experience: { landingGate: LandingGatePreference.Discourage } }))
35
+ .toBe(LandingGatePreference.Discourage)
36
+ })
37
+
38
+ test('each case weighs the gate: web and the AI kinds encourage, scalable allows, a game discourages', () => {
39
+ const preferences = Object.fromEntries(Object.values(BlueprintCase).map(id =>
40
+ [id, landingGatePreferenceOf(applyBlueprintCase(base(), id))]))
41
+
42
+ expect(preferences).toEqual({
43
+ [BlueprintCase.Web]: LandingGatePreference.Encourage,
44
+ [BlueprintCase.Scalable]: LandingGatePreference.Allow,
45
+ [BlueprintCase.AiPipeline]: LandingGatePreference.Encourage,
46
+ [BlueprintCase.AiAgent]: LandingGatePreference.Encourage,
47
+ [BlueprintCase.Game]: LandingGatePreference.Discourage,
48
+ })
49
+ })
50
+
51
+ test('a case overrides the base layer key by key, and no case leaves a blueprint without one', () => {
52
+ const allowing = base({ experience: { landingGate: LandingGatePreference.Allow } })
53
+
54
+ expect(landingGatePreferenceOf(applyBlueprintCase(allowing, BlueprintCase.Game)))
55
+ .toBe(LandingGatePreference.Discourage)
56
+ expect(applyBlueprintCase(base(), undefined).experience).toBeUndefined()
57
+ expect(Object.isFrozen(applyBlueprintCase(base(), BlueprintCase.Web).experience)).toBe(true)
58
+ })
59
+ })
60
+
61
+ describe('viable-common - the landing-gate skill in the cases', () => {
62
+ const gatePersonas = [
63
+ ViablePersona.BusinessAnalyst, ViablePersona.UxDesigner, ViablePersona.LayoutArchitect,
64
+ ViablePersona.ComponentArchitect, ViablePersona.UiViewCoder, ViablePersona.UiNavCoder,
65
+ ]
66
+
67
+ test('reaches the analyst and the frontend personas of every encouraging case, and nobody else', () => {
68
+ for (const id of [BlueprintCase.Web, BlueprintCase.AiPipeline, BlueprintCase.AiAgent]) {
69
+ const cased = applyBlueprintCase(base(), id)
70
+ for (const persona of gatePersonas) {
71
+ expect(personaSkillsOf(cased, persona), `${id} → ${persona}`).toContain(ViableSkill.LandingGate)
72
+ }
73
+ expect(personaSkillsOf(cased, ViablePersona.ApiCoder)).not.toContain(ViableSkill.LandingGate)
74
+ }
75
+ for (const id of [BlueprintCase.Scalable, BlueprintCase.Game]) {
76
+ const cased = applyBlueprintCase(base(), id)
77
+ for (const persona of gatePersonas) {
78
+ expect(personaSkillsOf(cased, persona), `${id} → ${persona}`).not.toContain(ViableSkill.LandingGate)
79
+ }
80
+ }
81
+ })
82
+
83
+ test('is merged with what a case and the base already give a persona, never in place of it', () => {
84
+ const agent = applyBlueprintCase(base(), BlueprintCase.AiAgent)
85
+
86
+ expect(personaSkillsOf(agent, ViablePersona.BusinessAnalyst)).toEqual([
87
+ ViableSkill.MainFlowFocus, ViableSkill.AgenticChoice, ViableSkill.LandingGate,
88
+ ])
89
+ expect(personaSkillsOf(agent, ViablePersona.UxDesigner))
90
+ .toEqual([ViableSkill.AgenticChoice, ViableSkill.LandingGate])
91
+ expect(personaSkillsOf(agent, ViablePersona.WorkerCoder)).toEqual([
92
+ ViableSkill.AgenticChoice, ViableSkill.TargetLlm, ViableSkill.LibrariesAi,
93
+ ViableSkill.TargetAgentTools,
94
+ ])
95
+ expect(BLUEPRINT_CASES[BlueprintCase.AiPipeline].patch.packages?.personaSkills?.[ViablePersona.BusinessAnalyst])
96
+ .toEqual([ViableSkill.AgenticChoice, ViableSkill.LandingGate])
97
+ })
98
+ })
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'bun:test'
2
2
 
3
- import { brandingEnv } from '../src/branding.js'
3
+ import { BRANDING_ENV_KEYS, brandingEnv } from '../src/branding.js'
4
+ import { metadataConfigs, metadataLists } from '../src/consts.js'
4
5
 
5
6
  /**
6
7
  * `BRANDING_CREDIT` is the one key whose ABSENCE means something different from an explicit
@@ -32,6 +33,25 @@ describe('brandingEnv', () => {
32
33
  BRANDING_PRIVACY_URL: '',
33
34
  BRANDING_CREDIT: '1',
34
35
  BRANDING_PRODUCT: 'Acme CRM',
36
+ BRANDING_GOOGLE_TAG: '',
35
37
  })
36
38
  })
39
+
40
+ it('carries the Google tag, and emits it empty when none is set', () => {
41
+ // The METADATA key is omitted from a push while unset (an older publisher refuses it); the
42
+ // ENVIRONMENT key is always there, and '' is what tells the build to load no tag.
43
+ expect(brandingEnv({ brandingGoogleTag: 'G-ABC123XYZ' }).BRANDING_GOOGLE_TAG).toBe('G-ABC123XYZ')
44
+ expect(brandingEnv({}).BRANDING_GOOGLE_TAG).toBe('')
45
+ })
46
+
47
+ it('emits exactly the keys BRANDING_ENV_KEYS names', () => {
48
+ expect(Object.keys(brandingEnv({})).sort()).toEqual([...BRANDING_ENV_KEYS].sort())
49
+ })
50
+ })
51
+
52
+ describe('the metadata vocabulary', () => {
53
+ it('delivers the Google tag as a config key, and keeps the derived CSP list out of the stored lists', () => {
54
+ expect(metadataConfigs).toContain('brandingGoogleTag')
55
+ expect(metadataLists as string[]).not.toContain('cspSources')
56
+ })
37
57
  })
@@ -1,8 +1,10 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { openProtocol, protocols } from '@owlmeans/entrypoint'
2
+ import Ajv from 'ajv'
3
+ import { aliasOf, openProtocol, protocols } from '@owlmeans/entrypoint'
3
4
  import { DEFAULT_GUARD } from '@owlmeans/auth-common'
4
- import { route } from '@owlmeans/route'
5
- import { connect } from '../src/connect/consts.js'
5
+ import { route, RouteMethod } from '@owlmeans/route'
6
+ import { connect, CONNECT_BRANDING_GOOGLE_TAG_MAX } from '../src/connect/consts.js'
7
+ import { ConnectProjectBrandingSaveSchema } from '../src/connect/schemas.js'
6
8
  import { connectProtocols } from '../src/connect/entrypoints.js'
7
9
  import { connectRef } from '../src/connect/references.js'
8
10
 
@@ -21,8 +23,45 @@ describe('@owlmeans/viable-common — connector protocol tree', () => {
21
23
  expect(tree.convert.proceed.alias).toBe(connect.convert.proceed)
22
24
  expect(tree.inquiry.answer.alias).toBe(connect.inquiry.answer)
23
25
  expect(tree.files.list.alias).toBe(connect.files.list)
24
- expect(protocols(tree)).toHaveLength(32)
25
- expect(new Set(protocols(tree).map(protocol => protocol.alias)).size).toBe(32)
26
+ expect(protocols(tree)).toHaveLength(34)
27
+ expect(new Set(protocols(tree).map(protocol => protocol.alias)).size).toBe(34)
28
+ })
29
+
30
+ test('reads and saves a project\'s branding at one path, under the owned base and no paid gate', () => {
31
+ const paid = connectProtocols({
32
+ guard: DEFAULT_GUARD,
33
+ gate: { alias: 'test-gate', params: ['owner'] },
34
+ localLlm: { alias: 'test-paid-llm', params: ['cap'] },
35
+ updateBase: openProtocol(route('manager:update', '/update')),
36
+ })
37
+ const get = paid.project.branding.get
38
+ const save = paid.project.branding.save
39
+
40
+ expect(get.alias).toBe(connect.project.branding.get)
41
+ expect(save.alias).toBe(connect.project.branding.save)
42
+ expect(get.route.route.path).toBe('/project/:id/branding')
43
+ expect(save.route.route.path).toBe('/project/:id/branding')
44
+ expect(get.route.route.method).toBe(RouteMethod.GET)
45
+ expect(save.route.route.method).toBe(RouteMethod.POST)
46
+ expect(aliasOf(get.route.route.parent)).toBe(connect.base)
47
+ expect(aliasOf(save.route.route.parent)).toBe(connect.base)
48
+ // The paid local-LLM gate sits on its own two routes; branding carries no gate of its own and
49
+ // inherits only the base's guard and ownership gate.
50
+ expect(paid.project.llm.gate?.alias).toBe('test-paid-llm')
51
+ expect(get.gate).toBeUndefined()
52
+ expect(save.gate).toBeUndefined()
53
+ expect(connectRef.project.branding.save.alias).toBe(connect.project.branding.save)
54
+ expect(connectRef.project.branding.get.alias).toBe(connect.project.branding.get)
55
+ })
56
+
57
+ test('the branding save body is a patch of strings that can never carry the credit', () => {
58
+ const validate = new Ajv({ strict: false }).compile(ConnectProjectBrandingSaveSchema)
59
+
60
+ expect(validate({})).toBe(true)
61
+ expect(validate({ googleTag: 'GTM-ABCD123', termsUrl: '/terms', privacyUrl: '' })).toBe(true)
62
+ expect(validate({ copyright: '' })).toBe(false)
63
+ expect(validate({ hideCredit: true })).toBe(false)
64
+ expect(validate({ googleTag: 'G-'.padEnd(CONNECT_BRANDING_GOOGLE_TAG_MAX + 1, 'X') })).toBe(false)
26
65
  })
27
66
 
28
67
  test('keeps story mutations in planning and exposes only the composed story status', () => {
@@ -4,7 +4,7 @@ import addFormats from 'ajv-formats'
4
4
  import { ResilientError } from '@owlmeans/error'
5
5
  import {
6
6
  canTransit, CodeScope, CodeStyle, intrinsicOf, IntrinsicStatus, makeSchemaRegistry,
7
- SpecificationFormat, transitionsFrom, WorkcardKind,
7
+ SpecificationFormat, TITLE_MAX, transitionsFrom, WorkcardKind,
8
8
  } from '@owlmeans/planning'
9
9
  import type { Specification } from '@owlmeans/planning'
10
10
  import { ProjectArea } from '../src/areas/consts.js'
@@ -14,6 +14,9 @@ import { STORY_DESIGN_VERSION } from '../src/design/consts.js'
14
14
  import { emptyStoryDesign } from '../src/design/helpers.js'
15
15
  import { StoryActor } from '../src/design/runtime.js'
16
16
  import type { StoryDesign } from '../src/design/types.js'
17
+ import {
18
+ hasLandingSentence, LANDING_STORY_SENTENCE, withLandingSentence,
19
+ } from '../src/index.js'
17
20
  import {
18
21
  isUserChannel, isViableProject, isViableStory, ProjectAgentOccupied, ProjectNotFound,
19
22
  ProjectStoryMissconfigured, projectBriefOf, storyDraftOf, storyWriteInputOf, userStoryOf,
@@ -255,9 +258,24 @@ describe('viable-common - the viable field schemas', () => {
255
258
  expect(project({
256
259
  formerAliases: ['old-name'], language: 'en', blueprint: 'owlmeans', blueprintCase: 'web',
257
260
  target: null, connectLlmMode: 'local', origin: { kind: 'github', repoUrl: 'https://x/y' },
261
+ landing: { story: 'US-ABC12', at },
258
262
  })).toBe(true)
259
263
  expect(project({ alias: 'slot-booker' })).toBe(false)
260
264
  })
265
+
266
+ test('a story carries the landing flag, spelled or nulled', () => {
267
+ expect(story({ area: ProjectArea.User, primary: false, landing: true })).toBe(true)
268
+ expect(story({ area: ProjectArea.User, primary: false, landing: null })).toBe(true)
269
+ expect(story({ area: ProjectArea.User, primary: false, landing: 'yes' })).toBe(false)
270
+ })
271
+
272
+ test('a project records a decided "no gate" as story null, and never a decision without a time', () => {
273
+ // Absent = never decided; `story: null` = decided, no gate. Both must be spellable.
274
+ expect(project({ landing: { story: null, at } })).toBe(true)
275
+ expect(project({ landing: null })).toBe(true)
276
+ expect(project({ landing: { story: 'US-ABC12' } })).toBe(false)
277
+ expect(project({ landing: { story: 'US-ABC12', at, reason: 'best fit' } })).toBe(false)
278
+ })
261
279
  })
262
280
 
263
281
  describe('viable-common - the viable card helpers', () => {
@@ -311,6 +329,16 @@ describe('viable-common - the viable card helpers', () => {
311
329
  .toThrow(ProjectStoryMissconfigured)
312
330
  })
313
331
 
332
+ test('storyWriteInputOf copies the landing flag from the card, and only when it is set', () => {
333
+ const content = {
334
+ userStory: userStoryOf(storyCard()), screenPaths: {}, componentPaths: {}, transitions: [],
335
+ }
336
+ const landing = storyCard({ fields: { area: ProjectArea.User, primary: false, landing: true } })
337
+
338
+ expect(storyWriteInputOf(landing, content).landing).toBe(true)
339
+ expect('landing' in storyWriteInputOf(storyCard(), content)).toBe(false)
340
+ })
341
+
314
342
  test('only a person writes through the web and connect channels', () => {
315
343
  expect(isUserChannel(ViableChannel.Web)).toBe(true)
316
344
  expect(isUserChannel(ViableChannel.Connect)).toBe(true)
@@ -319,6 +347,33 @@ describe('viable-common - the viable card helpers', () => {
319
347
  })
320
348
  })
321
349
 
350
+ describe('viable-common - the landing sentence', () => {
351
+ test('is appended once, with one space, and a resumed step never adds a second copy', () => {
352
+ const once = withLandingSentence('A member books a slot.')
353
+
354
+ expect(once).toBe(`A member books a slot. ${LANDING_STORY_SENTENCE}`)
355
+ expect(withLandingSentence(once)).toBe(once)
356
+ expect(hasLandingSentence(once)).toBe(true)
357
+ expect(hasLandingSentence('A member books a slot.')).toBe(false)
358
+ })
359
+
360
+ test('is recognised whatever whitespace a person left around it', () => {
361
+ const rewrapped = `A member books a slot.\n${LANDING_STORY_SENTENCE.replace('; after', ';\n after')}`
362
+
363
+ expect(hasLandingSentence(rewrapped)).toBe(true)
364
+ expect(withLandingSentence(rewrapped)).toBe(rewrapped)
365
+ })
366
+
367
+ test('never makes a title longer than a card accepts, and never cuts the narrative', () => {
368
+ const long = 'x'.repeat(TITLE_MAX - 10)
369
+
370
+ expect(withLandingSentence(long)).toBe(long)
371
+ expect(withLandingSentence('x'.repeat(TITLE_MAX - LANDING_STORY_SENTENCE.length - 1)).length)
372
+ .toBe(TITLE_MAX)
373
+ expect(withLandingSentence(' ')).toBe(LANDING_STORY_SENTENCE)
374
+ })
375
+ })
376
+
322
377
  describe('viable-common - the follows anchor', () => {
323
378
  test('mergeConnectingStories carries the clamped anchor on each connective entry', () => {
324
379
  const flow = [
@@ -0,0 +1,131 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import Ajv from 'ajv'
3
+ import { validateSpecificationBody } from '@owlmeans/planning'
4
+ import { ProjectArea } from '../src/areas/consts.js'
5
+ import { ViableSpecCategory, VIABLE_PROJECT_SLOTS } from '../src/planning/index.js'
6
+ import { BENTO_FRAGMENT_KINDS, WidgetKind } from '../src/scaffold/consts.js'
7
+ import { ScaffoldPlanSchema } from '../src/scaffold/schemas.js'
8
+ import type { ScaffoldPlan } from '../src/scaffold/types.js'
9
+
10
+ /**
11
+ * The scaffold plan's schema is TWO things at once: the shape a model answers with, and the
12
+ * validator of the project card's `scaffold` document. A plan written before the landing-page
13
+ * overhaul carries none of its fields, and must still pass — or that project's plan can never be
14
+ * revised again. Compiled the way the planning registry compiles it (no formats, not strict).
15
+ */
16
+ const oldPlan = (): ScaffoldPlan => ({
17
+ identity: { name: 'Crumb & Crust', description: 'Recipes and bakes from a welcoming community.' },
18
+ guestHome: {
19
+ hero: { headline: 'Find your next bake.', sub: 'Photo-led recipes.', cta: 'Get started', eyebrow: 'New' },
20
+ problem: { title: 'Inspiration lacks detail', text: 'A bake is easy to find.' },
21
+ solution: { title: 'Bakes worth making', text: 'The full process in one place.' },
22
+ features: [{ title: 'Complete recipes', text: 'Clear ingredient lists.' }],
23
+ testimonials: [{ quote: 'I found a rye loaf.', name: 'Mara L.', role: 'Home baker' }],
24
+ },
25
+ areas: [{ area: ProjectArea.Guest, intro: { title: 'Welcome', text: 'Browse.' }, sections: ['Bakes'] }],
26
+ stories: [{
27
+ code: 'US-ABC12',
28
+ section: 'Bakes',
29
+ screen: 'Bakes/Find a bake',
30
+ widget: { kind: WidgetKind.List, title: 'Bake finder', description: 'Bakes you can make.' },
31
+ sketch: { headline: 'Your bakes', caption: 'What you can bake', items: ['Rye loaf'] },
32
+ home: true,
33
+ }],
34
+ motifs: 'A rye loaf on a board.',
35
+ })
36
+
37
+ const newPlan = (): ScaffoldPlan => {
38
+ const plan = oldPlan()
39
+
40
+ return {
41
+ ...plan,
42
+ guestHome: {
43
+ ...plan.guestHome,
44
+ hero: {
45
+ headline: 'Find your next bake.', sub: 'Photo-led recipes.', cta: 'Get started',
46
+ secondary: { label: 'Browse bakes' },
47
+ browse: { label: 'Just browsing? Explore bakes', href: '#features' },
48
+ },
49
+ features: [
50
+ {
51
+ title: 'Complete recipes', text: 'Clear ingredient lists.',
52
+ fragment: {
53
+ kind: 'list', title: 'INGREDIENTS · 1 LOAF',
54
+ rows: [{ label: 'Rye flour', value: '350 g' }, { label: 'Water', value: '390 g' }],
55
+ },
56
+ },
57
+ { title: 'Baking notes', text: 'Small adjustments.', fragment: { kind: 'note', rows: [{ label: 'Drop the oven.' }] } },
58
+ { title: 'Photo-led bakes', text: 'Every idea starts with the bake.' },
59
+ { title: 'Baker attribution', text: 'See who made it.', fragment: { kind: 'people', rows: [{ label: 'Mara L.', value: 'Rye' }] } },
60
+ ],
61
+ steps: [
62
+ { title: 'Pick what you have', text: 'Tap the ingredients.' },
63
+ { title: 'See what you can bake', text: 'Ranked by your pantry.' },
64
+ { title: 'Open the full method', text: 'Sign in with your email.' },
65
+ ],
66
+ labels: { steps: 'How it works', stepsTitle: 'From pantry to basket.', problem: 'The problem' },
67
+ closing: {
68
+ headline: 'Your next bake starts here.', lead: 'Browse freely.', primary: 'Get started',
69
+ secondary: { label: 'Browse bakes' },
70
+ links: [{ title: 'Explore bakes', text: 'Open to everyone.' }, { title: 'Share your bake', text: 'Post it.', href: '#' }],
71
+ },
72
+ gate: {
73
+ story: 'US-ABC12',
74
+ target: 'web:bake-finder',
75
+ question: "What's in your pantry?",
76
+ hint: 'No account needed',
77
+ label: 'Your pantry',
78
+ inputs: ['Bread flour', 'Rye flour', 'Butter', 'Eggs', 'Yeast'],
79
+ selected: ['Bread flour', 'Rye flour', 'Butter', 'Yeast'],
80
+ results: [{ title: 'Seeded rye loaf', meta: 'Uses all 3 · 3 h 20 min', needs: ['Bread flour', 'Rye flour', 'Yeast'] }],
81
+ count: { one: '{n} bake matches', many: '{n} bakes match', none: 'No matches yet' },
82
+ empty: 'Pick two or more ingredients.',
83
+ note: 'Sign in with your email. Your picks come with you.',
84
+ cta: 'Open recipes →',
85
+ lock: 'Full method after sign-in',
86
+ },
87
+ },
88
+ }
89
+ }
90
+
91
+ describe('viable-common - the scaffold plan schema', () => {
92
+ const validate = new Ajv({ strict: false, allErrors: true }).compile(ScaffoldPlanSchema)
93
+ const slot = VIABLE_PROJECT_SLOTS.find(entry => entry.category === ViableSpecCategory.Scaffold)!
94
+
95
+ test('a plan stored before the landing overhaul still validates', () => {
96
+ expect(validate(oldPlan()), JSON.stringify(validate.errors)).toBe(true)
97
+ expect(() => validateSpecificationBody(slot, JSON.stringify(oldPlan()))).not.toThrow()
98
+ })
99
+
100
+ test('a plan carrying every new field validates', () => {
101
+ expect(validate(newPlan()), JSON.stringify(validate.errors)).toBe(true)
102
+ expect(() => validateSpecificationBody(slot, JSON.stringify(newPlan()))).not.toThrow()
103
+ })
104
+
105
+ test('a provider that spells an unset optional as null is accepted', () => {
106
+ const plan = newPlan() as unknown as { guestHome: Record<string, unknown> }
107
+ plan.guestHome.gate = null
108
+ plan.guestHome.closing = null
109
+ ;(plan.guestHome.hero as Record<string, unknown>).secondary = null
110
+
111
+ expect(validate(plan), JSON.stringify(validate.errors)).toBe(true)
112
+ })
113
+
114
+ test('still refuses an undeclared field and a fragment kind nothing can draw', () => {
115
+ const stray = newPlan() as unknown as { guestHome: Record<string, unknown> }
116
+ stray.guestHome.badge = 'New'
117
+ expect(validate(stray)).toBe(false)
118
+
119
+ const fragment = newPlan()
120
+ fragment.guestHome.features[0].fragment = { kind: 'chart' as never, rows: [] }
121
+ expect(validate(fragment)).toBe(false)
122
+ })
123
+
124
+ test('declares no count as minItems/maxItems — counts live in the descriptions', () => {
125
+ const text = JSON.stringify(ScaffoldPlanSchema)
126
+
127
+ expect(text).not.toContain('minItems')
128
+ expect(text).not.toContain('maxItems')
129
+ expect(BENTO_FRAGMENT_KINDS).toEqual(['list', 'note', 'people', 'steps'])
130
+ })
131
+ })
@@ -1,7 +1,8 @@
1
1
  import { describe, expect, it } from 'bun:test'
2
2
 
3
- import { ViableSkill } from '../src/skills/consts.js'
4
- import { viableSkill } from '../src/skills/catalogue.js'
3
+ import { SKILL_ORDER, ViableSkill } from '../src/skills/consts.js'
4
+ import { VIABLE_SKILLS, viableSkill } from '../src/skills/catalogue.js'
5
+ import { VIABLE_PERSONAS, ViablePersona } from '../src/skills/roles.js'
5
6
 
6
7
  /**
7
8
  * Pins the catalogue's own "wrap exactly once" teaching — the incident this guards against was
@@ -45,3 +46,63 @@ describe('viable-common catalogue — handler wrap-once', () => {
45
46
  }
46
47
  })
47
48
  })
49
+
50
+ /**
51
+ * The prompt service SKIPS an alias it cannot resolve, so a skill in the enum with no body is a
52
+ * rule no model ever receives — nothing fails, the prompt simply lacks it.
53
+ */
54
+ describe('viable-common catalogue — completeness', () => {
55
+ it('declares one body and one sort weight for every skill in the enum', () => {
56
+ const declared = VIABLE_SKILLS.map(entry => entry.alias)
57
+
58
+ expect(Object.values(ViableSkill).filter(alias => !declared.includes(alias))).toEqual([])
59
+ expect(new Set(declared).size).toBe(declared.length)
60
+ for (const alias of Object.values(ViableSkill)) {
61
+ expect(typeof SKILL_ORDER[alias], alias).toBe('number')
62
+ }
63
+ })
64
+ })
65
+
66
+ /**
67
+ * The house style is taught in two places a designer and a coder read — the visual designer's
68
+ * persona and the shadcn skill — and a template ships the same values. Neither may drift back
69
+ * to teaching the decoration the template now paints flat.
70
+ */
71
+ describe('viable-common catalogue — the house style', () => {
72
+ it('ShadcnUi teaches the white ground, one accent and flat depth, and repaints the old classes', () => {
73
+ const body = viableSkill(ViableSkill.ShadcnUi).body
74
+
75
+ expect(body).toContain('bg-background')
76
+ expect(body).toContain('ONE product accent')
77
+ expect(body).toContain('shadow-floating')
78
+ expect(body).toContain('REPAINTED flat')
79
+ expect(body).toContain('explicitly asks')
80
+ expect(body).not.toContain('DECORATION VOCABULARY')
81
+ expect(body).not.toMatch(/\/\/ right[^\n]*gradient/)
82
+ expect(body).toContain('SelectItem')
83
+ })
84
+
85
+ it('the visual designer defaults to the house style and changes it only on an explicit ask', () => {
86
+ const role = VIABLE_PERSONAS[ViablePersona.VisualDesigner].role
87
+
88
+ expect(role).toContain('white ground')
89
+ expect(role).toContain('ONE')
90
+ expect(role).toContain('explicitly asks')
91
+ expect(role).not.toContain('expressive and bright')
92
+ expect(role).not.toMatch(/default to .*gradients/)
93
+ })
94
+
95
+ it('LandingGate teaches the gate card, the synchronous handoff and the screen that reads it', () => {
96
+ const body = viableSkill(ViableSkill.LandingGate).body
97
+
98
+ expect(body).toContain('data-home-gate')
99
+ expect(body).toContain('aria-pressed')
100
+ expect(body).toContain('writeLandingHandoff(target')
101
+ expect(body).toContain('readLandingHandoff')
102
+ expect(body).toContain('clearLandingHandoff()')
103
+ expect(body).toContain("useLogin(target)")
104
+ expect(body).toContain('SYNCHRONOUS')
105
+ expect(body).toContain('WRONG')
106
+ expect(body).not.toContain('${')
107
+ })
108
+ })