@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.
- package/README.md +3 -3
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/agent-presentation/SKILL.md +1 -1
- package/agent-meta/skills/viable-common/SKILL.md +109 -8
- package/build/blueprint/case.d.ts.map +1 -1
- package/build/blueprint/case.js +58 -16
- package/build/blueprint/case.js.map +1 -1
- package/build/blueprint/resolve.d.ts.map +1 -1
- package/build/blueprint/resolve.js +3 -0
- package/build/blueprint/resolve.js.map +1 -1
- package/build/blueprint/types.d.ts +44 -2
- package/build/blueprint/types.d.ts.map +1 -1
- package/build/blueprint/types.js +35 -1
- package/build/blueprint/types.js.map +1 -1
- package/build/branding.d.ts +5 -1
- package/build/branding.d.ts.map +1 -1
- package/build/branding.js +6 -0
- package/build/branding.js.map +1 -1
- package/build/connect/consts.d.ts +21 -0
- package/build/connect/consts.d.ts.map +1 -1
- package/build/connect/consts.js +21 -0
- package/build/connect/consts.js.map +1 -1
- package/build/connect/entrypoints.d.ts +15 -1
- package/build/connect/entrypoints.d.ts.map +1 -1
- package/build/connect/entrypoints.js +16 -1
- package/build/connect/entrypoints.js.map +1 -1
- package/build/connect/references.d.ts +14 -1
- package/build/connect/references.d.ts.map +1 -1
- package/build/connect/references.js +4 -0
- package/build/connect/references.js.map +1 -1
- package/build/connect/schemas.d.ts +12 -1
- package/build/connect/schemas.d.ts.map +1 -1
- package/build/connect/schemas.js +27 -1
- package/build/connect/schemas.js.map +1 -1
- package/build/connect/types.d.ts +27 -0
- package/build/connect/types.d.ts.map +1 -1
- package/build/consts.d.ts +1 -1
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +3 -0
- package/build/consts.js.map +1 -1
- package/build/metadata/types.d.ts +8 -0
- package/build/metadata/types.d.ts.map +1 -1
- package/build/metadata/types.js.map +1 -1
- package/build/planning/helpers.d.ts +2 -1
- package/build/planning/helpers.d.ts.map +1 -1
- package/build/planning/helpers.js +5 -2
- package/build/planning/helpers.js.map +1 -1
- package/build/planning/index.d.ts +1 -0
- package/build/planning/index.d.ts.map +1 -1
- package/build/planning/index.js +1 -0
- package/build/planning/index.js.map +1 -1
- package/build/planning/landing.d.ts +25 -0
- package/build/planning/landing.d.ts.map +1 -0
- package/build/planning/landing.js +38 -0
- package/build/planning/landing.js.map +1 -0
- package/build/planning/schemas.d.ts.map +1 -1
- package/build/planning/schemas.js +13 -0
- package/build/planning/schemas.js.map +1 -1
- package/build/planning/types.d.ts +28 -3
- package/build/planning/types.d.ts.map +1 -1
- package/build/scaffold/consts.d.ts +8 -0
- package/build/scaffold/consts.d.ts.map +1 -1
- package/build/scaffold/consts.js +7 -0
- package/build/scaffold/consts.js.map +1 -1
- package/build/scaffold/schemas.d.ts.map +1 -1
- package/build/scaffold/schemas.js +257 -12
- package/build/scaffold/schemas.js.map +1 -1
- package/build/scaffold/types.d.ts +119 -4
- package/build/scaffold/types.d.ts.map +1 -1
- package/build/skills/catalogue.d.ts.map +1 -1
- package/build/skills/catalogue.js +128 -24
- package/build/skills/catalogue.js.map +1 -1
- package/build/skills/consts.d.ts +5 -0
- package/build/skills/consts.d.ts.map +1 -1
- package/build/skills/consts.js +8 -0
- package/build/skills/consts.js.map +1 -1
- package/build/skills/roles.d.ts.map +1 -1
- package/build/skills/roles.js +11 -5
- package/build/skills/roles.js.map +1 -1
- package/build/types.d.ts +30 -0
- package/build/types.d.ts.map +1 -1
- package/package.json +8 -8
- package/src/blueprint/case.ts +64 -16
- package/src/blueprint/resolve.ts +3 -0
- package/src/blueprint/types.ts +57 -1
- package/src/branding.ts +6 -0
- package/src/connect/consts.ts +22 -0
- package/src/connect/entrypoints.ts +26 -4
- package/src/connect/references.ts +16 -3
- package/src/connect/schemas.ts +33 -4
- package/src/connect/types.ts +28 -0
- package/src/consts.ts +3 -0
- package/src/metadata/types.ts +8 -0
- package/src/planning/helpers.ts +5 -2
- package/src/planning/index.ts +1 -0
- package/src/planning/landing.ts +43 -0
- package/src/planning/schemas.ts +13 -0
- package/src/planning/types.ts +29 -3
- package/src/scaffold/consts.ts +10 -0
- package/src/scaffold/schemas.ts +262 -13
- package/src/scaffold/types.ts +105 -5
- package/src/skills/catalogue.ts +129 -24
- package/src/skills/consts.ts +8 -0
- package/src/skills/roles.ts +11 -5
- package/src/types.ts +30 -0
- package/tests/blueprint.spec.ts +98 -0
- package/tests/branding.spec.ts +21 -1
- package/tests/connect-entrypoints.spec.ts +44 -5
- package/tests/planning.spec.ts +56 -1
- package/tests/scaffold.spec.ts +131 -0
- package/tests/skills.spec.ts +63 -2
package/src/skills/roles.ts
CHANGED
|
@@ -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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
+
})
|
package/tests/branding.spec.ts
CHANGED
|
@@ -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
|
|
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(
|
|
25
|
-
expect(new Set(protocols(tree).map(protocol => protocol.alias)).size).toBe(
|
|
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', () => {
|
package/tests/planning.spec.ts
CHANGED
|
@@ -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
|
+
})
|
package/tests/skills.spec.ts
CHANGED
|
@@ -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
|
+
})
|